|
36 | 36 | get_run_detail, |
37 | 37 | get_run_history, |
38 | 38 | ) |
39 | | -from ..models import CommandContext, UserConfig, log_error, log_info, new_run_id |
40 | | -from ..orchestrator import load_catalog_or_raise, run_update_with_settings |
| 39 | +from ..http_client import get_latest_time |
| 40 | +from ..models import CommandContext, FatalRequestError, UserConfig, log_error, log_info, new_run_id |
| 41 | +from ..orchestrator import _build_headers, load_catalog_or_raise, run_update_with_settings |
41 | 42 | from ..status_store import report_dir_path |
42 | 43 |
|
43 | 44 |
|
@@ -464,6 +465,131 @@ def get_health_report(self) -> Dict[str, Any]: |
464 | 465 |
|
465 | 466 | return {"ok": True, "health": health} |
466 | 467 |
|
| 468 | + def check_updates(self) -> Dict[str, Any]: |
| 469 | + """查询 API 获取各产品最新日期,返回实时 overview。 |
| 470 | +
|
| 471 | + 并发查询,总超时 30 秒。区分全局错误(401/403 立即中止) |
| 472 | + 和单产品错误(跳过计入失败列表)。 |
| 473 | + """ |
| 474 | + from concurrent.futures import ThreadPoolExecutor, as_completed, TimeoutError as FuturesTimeoutError |
| 475 | + from datetime import date as _date |
| 476 | + |
| 477 | + user_config, data_root, catalog, err = self._resolve_config() |
| 478 | + if err: |
| 479 | + return {"ok": False, "error": err} |
| 480 | + |
| 481 | + if not catalog: |
| 482 | + return { |
| 483 | + "ok": True, "products": [], |
| 484 | + "summary": {"green": 0, "yellow": 0, "red": 0, "gray": 0}, |
| 485 | + "checked": 0, "failed": 0, "failed_products": [], |
| 486 | + } |
| 487 | + |
| 488 | + # 解析凭证 |
| 489 | + secrets_file = DEFAULT_USER_SECRETS_FILE.resolve() |
| 490 | + try: |
| 491 | + api_key, hid, _ = resolve_credentials_for_update( |
| 492 | + cli_api_key="", cli_hid="", secrets_file=secrets_file, |
| 493 | + ) |
| 494 | + except Exception as exc: |
| 495 | + return {"ok": False, "error": f"凭证解析失败:{exc}"} |
| 496 | + |
| 497 | + if not api_key or not hid: |
| 498 | + return {"ok": False, "error": "未找到有效凭证(API Key / HID),请先完成配置。"} |
| 499 | + |
| 500 | + headers = _build_headers(api_key) |
| 501 | + api_base = DEFAULT_API_BASE |
| 502 | + |
| 503 | + # 并发查询各产品 API 最新日期 |
| 504 | + api_latest_dates: Dict[str, str] = {} |
| 505 | + failed_products: list = [] |
| 506 | + # 全局中止信号:401/403 时通知其他 worker 提前退出 |
| 507 | + abort_event = threading.Event() |
| 508 | + global_error_holder: list = [] |
| 509 | + |
| 510 | + def _query_one(product: str) -> tuple: |
| 511 | + """查询单个产品,返回 (product, date_str, error)。""" |
| 512 | + if abort_event.is_set(): |
| 513 | + return (product, None, "已中止") |
| 514 | + try: |
| 515 | + latest = get_latest_time(api_base, product, hid, headers) |
| 516 | + return (product, latest, None) |
| 517 | + except FatalRequestError as exc: |
| 518 | + if exc.status_code in (401, 403): |
| 519 | + abort_event.set() |
| 520 | + global_error_holder.append(exc) |
| 521 | + return (product, None, str(exc)) |
| 522 | + return (product, None, str(exc)) |
| 523 | + except Exception as exc: |
| 524 | + return (product, None, str(exc)) |
| 525 | + |
| 526 | + executor = ThreadPoolExecutor(max_workers=max(1, min(8, len(catalog)))) |
| 527 | + try: |
| 528 | + futures = {executor.submit(_query_one, p): p for p in catalog} |
| 529 | + for future in as_completed(futures, timeout=30): |
| 530 | + product, latest, error = future.result() |
| 531 | + if error: |
| 532 | + failed_products.append(product) |
| 533 | + else: |
| 534 | + api_latest_dates[product] = latest |
| 535 | + # 检测到全局错误后不再等待剩余 future |
| 536 | + if abort_event.is_set(): |
| 537 | + break |
| 538 | + except FuturesTimeoutError: |
| 539 | + pass # 超时后下面统一处理未完成的产品 |
| 540 | + except Exception as exc: |
| 541 | + return {"ok": False, "error": f"检查更新失败:{exc}"} |
| 542 | + finally: |
| 543 | + executor.shutdown(wait=False, cancel_futures=True) |
| 544 | + |
| 545 | + # 全局错误(401/403):立即返回 |
| 546 | + if global_error_holder: |
| 547 | + exc = global_error_holder[0] |
| 548 | + return {"ok": False, "error": f"API 凭证或额度异常(HTTP {exc.status_code}):{exc}"} |
| 549 | + |
| 550 | + # 按产品名补漏:未进入 api_latest_dates 也未进入 failed_products 的归入失败 |
| 551 | + for product in catalog: |
| 552 | + if product not in api_latest_dates and product not in failed_products: |
| 553 | + failed_products.append(product) |
| 554 | + |
| 555 | + # 用 API 日期生成实时 overview |
| 556 | + try: |
| 557 | + raw_products = get_products_overview( |
| 558 | + data_root, catalog, today=_date.today(), api_latest_dates=api_latest_dates, |
| 559 | + ) |
| 560 | + except Exception as exc: |
| 561 | + return {"ok": False, "error": f"状态计算失败:{exc}"} |
| 562 | + |
| 563 | + # 转换为前端字段名,附加 source 标记 |
| 564 | + products = [] |
| 565 | + for p in raw_products: |
| 566 | + source = "api" if p["name"] in api_latest_dates else "cached" |
| 567 | + products.append({ |
| 568 | + "name": p["name"], |
| 569 | + "color": p["status_color"], |
| 570 | + "local_date": p["local_date"], |
| 571 | + "behind_days": p["days_behind"], |
| 572 | + "last_result": p["last_status"], |
| 573 | + "last_error": p["last_error"], |
| 574 | + "source": source, |
| 575 | + }) |
| 576 | + |
| 577 | + # 统计卡片 |
| 578 | + summary = {"green": 0, "yellow": 0, "red": 0, "gray": 0} |
| 579 | + for p in products: |
| 580 | + color = p.get("color", "gray") |
| 581 | + if color in summary: |
| 582 | + summary[color] += 1 |
| 583 | + |
| 584 | + return { |
| 585 | + "ok": True, |
| 586 | + "products": products, |
| 587 | + "summary": summary, |
| 588 | + "checked": len(api_latest_dates), |
| 589 | + "failed": len(failed_products), |
| 590 | + "failed_products": sorted(failed_products), |
| 591 | + } |
| 592 | + |
467 | 593 | # ------------------------------------------------------------------ |
468 | 594 | # 同步线程内部逻辑(不对外暴露) |
469 | 595 | # ------------------------------------------------------------------ |
|
0 commit comments