|
21 | 21 | import logging |
22 | 22 | import re |
23 | 23 | from datetime import datetime |
| 24 | +from pathlib import Path |
24 | 25 | from typing import Optional, Union, Dict, Any |
25 | 26 |
|
26 | | -from fastapi import APIRouter, HTTPException, Depends, Query |
| 27 | +from fastapi import APIRouter, HTTPException, Depends, Query, Body |
27 | 28 | from fastapi.responses import JSONResponse, StreamingResponse |
28 | 29 |
|
29 | 30 | from api.deps import get_config_dep |
|
38 | 39 | TaskInfo, |
39 | 40 | TaskListResponse, |
40 | 41 | DuplicateTaskErrorResponse, |
| 42 | + MarketReviewRequest, |
| 43 | + MarketReviewAccepted, |
41 | 44 | ) |
42 | 45 | from api.v1.schemas.common import ErrorResponse |
43 | 46 | from api.v1.schemas.history import ( |
|
49 | 52 | ) |
50 | 53 | from data_provider.base import canonical_stock_code, normalize_stock_code |
51 | 54 | from src.config import Config |
| 55 | +from src.core.market_review_lock import ( |
| 56 | + MarketReviewExecutionLock as _MarketReviewExecutionLock, |
| 57 | + market_review_lock_path, |
| 58 | + release_market_review_lock as _release_market_review_lock, |
| 59 | + try_acquire_market_review_lock as _try_acquire_market_review_lock, |
| 60 | +) |
| 61 | +from src.core.market_review_runtime import ( |
| 62 | + build_market_review_runtime as _runtime_build_market_review_runtime, |
| 63 | +) |
52 | 64 | from src.report_language import get_localized_stock_name, normalize_report_language |
53 | 65 | from src.services.name_to_code_resolver import resolve_name_to_code |
54 | 66 | from src.services.stock_code_utils import is_code_like |
|
71 | 83 | _SUPPORTED_FREE_TEXT_RE = re.compile(r"^[A-Za-z0-9.*\-+\u3400-\u9fff\s]+$") |
72 | 84 |
|
73 | 85 |
|
| 86 | +def _market_review_lock_path(config: Config) -> Path: |
| 87 | + return market_review_lock_path(config) |
| 88 | + |
| 89 | + |
| 90 | +def _compute_market_review_override_region(config: Config) -> Optional[str]: |
| 91 | + if not getattr(config, "trading_day_check_enabled", True): |
| 92 | + return None |
| 93 | + |
| 94 | + try: |
| 95 | + from src.core.trading_calendar import ( |
| 96 | + get_open_markets_today, |
| 97 | + compute_effective_region, |
| 98 | + ) |
| 99 | + |
| 100 | + open_markets = get_open_markets_today() |
| 101 | + return compute_effective_region( |
| 102 | + getattr(config, "market_review_region", "cn") or "cn", |
| 103 | + open_markets, |
| 104 | + ) |
| 105 | + except Exception as exc: |
| 106 | + logger.warning("大盘复盘交易日过滤失败,按配置继续执行: %s", exc) |
| 107 | + return None |
| 108 | + |
| 109 | + |
| 110 | +def _build_market_review_runtime(config: Config, source_message: Optional[Any] = None) -> tuple[Any, Any, Any]: |
| 111 | + return _runtime_build_market_review_runtime(config, source_message) |
| 112 | + |
| 113 | + |
| 114 | +def _run_market_review_background( |
| 115 | + send_notification: bool, |
| 116 | + override_region: Optional[str] = None, |
| 117 | + lock_token: Optional[_MarketReviewExecutionLock] = None, |
| 118 | + config: Optional[Config] = None, |
| 119 | +) -> None: |
| 120 | + """Run market review after the API response has been accepted.""" |
| 121 | + from src.core.market_review import run_market_review |
| 122 | + |
| 123 | + runtime_config = config or get_config_dep() |
| 124 | + try: |
| 125 | + notifier, analyzer, search_service = _build_market_review_runtime(runtime_config) |
| 126 | + report = run_market_review( |
| 127 | + notifier=notifier, |
| 128 | + analyzer=analyzer, |
| 129 | + search_service=search_service, |
| 130 | + send_notification=send_notification, |
| 131 | + override_region=override_region, |
| 132 | + ) |
| 133 | + if not report: |
| 134 | + raise RuntimeError("大盘复盘未返回可持久化报告") |
| 135 | + return {"result": report} |
| 136 | + finally: |
| 137 | + _release_market_review_lock(lock_token) |
| 138 | + |
| 139 | + |
74 | 140 | def _invalid_analysis_input_error() -> HTTPException: |
75 | 141 | return HTTPException( |
76 | 142 | status_code=400, |
@@ -392,6 +458,71 @@ def _handle_sync_analysis( |
392 | 458 | ) |
393 | 459 |
|
394 | 460 |
|
| 461 | +# ============================================================ |
| 462 | +# POST /market-review - 触发大盘复盘 |
| 463 | +# ============================================================ |
| 464 | + |
| 465 | +@router.post( |
| 466 | + "/market-review", |
| 467 | + response_model=MarketReviewAccepted, |
| 468 | + status_code=202, |
| 469 | + responses={ |
| 470 | + 202: {"description": "大盘复盘任务已接受", "model": MarketReviewAccepted}, |
| 471 | + 409: {"description": "大盘复盘正在执行", "model": ErrorResponse}, |
| 472 | + 500: {"description": "提交失败", "model": ErrorResponse}, |
| 473 | + }, |
| 474 | + summary="触发大盘复盘", |
| 475 | + description="提交一个后台大盘复盘任务,复用 CLI 的大盘复盘链路并保存报告。接口内部仅提供进程内/单机防重,如多实例(多 Worker/多容器)部署,需结合外部幂等机制避免重复触发。", |
| 476 | +) |
| 477 | +def trigger_market_review( |
| 478 | + request: Optional[MarketReviewRequest] = Body(None), |
| 479 | + config: Config = Depends(get_config_dep), |
| 480 | +) -> MarketReviewAccepted: |
| 481 | + """Trigger market review from Web/API without blocking the request.""" |
| 482 | + request = request or MarketReviewRequest() |
| 483 | + |
| 484 | + override_region = _compute_market_review_override_region(config) |
| 485 | + if override_region == "": |
| 486 | + return MarketReviewAccepted( |
| 487 | + status="accepted", |
| 488 | + message="今日大盘复盘相关市场均为非交易日,已跳过大盘复盘", |
| 489 | + send_notification=request.send_notification, |
| 490 | + ) |
| 491 | + |
| 492 | + lock_token = _try_acquire_market_review_lock(config) |
| 493 | + if lock_token is None: |
| 494 | + raise HTTPException( |
| 495 | + status_code=409, |
| 496 | + detail={ |
| 497 | + "error": "duplicate_market_review", |
| 498 | + "message": "大盘复盘正在执行中,请稍后再试", |
| 499 | + }, |
| 500 | + ) |
| 501 | + |
| 502 | + try: |
| 503 | + task = get_task_queue().submit_background_task( |
| 504 | + lambda: _run_market_review_background( |
| 505 | + request.send_notification, |
| 506 | + override_region=override_region, |
| 507 | + lock_token=lock_token, |
| 508 | + config=config, |
| 509 | + ), |
| 510 | + stock_code="market_review", |
| 511 | + stock_name="大盘复盘", |
| 512 | + message="大盘复盘任务已提交", |
| 513 | + ) |
| 514 | + except Exception: |
| 515 | + _release_market_review_lock(lock_token) |
| 516 | + raise |
| 517 | + |
| 518 | + return MarketReviewAccepted( |
| 519 | + status="accepted", |
| 520 | + message="大盘复盘任务已提交,完成后会保存报告并按配置推送通知", |
| 521 | + send_notification=request.send_notification, |
| 522 | + task_id=task.task_id, |
| 523 | + ) |
| 524 | + |
| 525 | + |
395 | 526 | # ============================================================ |
396 | 527 | # GET /tasks - 获取任务列表 |
397 | 528 | # ============================================================ |
@@ -582,11 +713,29 @@ def get_analysis_status(task_id: str) -> TaskStatus: |
582 | 713 | task = task_queue.get_task(task_id) |
583 | 714 |
|
584 | 715 | if task: |
| 716 | + result: Optional[AnalysisResultResponse] = None |
| 717 | + market_review_report = None |
| 718 | + |
| 719 | + if task.status == TaskStatusEnum.COMPLETED and isinstance(task.result, dict): |
| 720 | + if task.stock_code == "market_review": |
| 721 | + report_text = task.result.get("result") |
| 722 | + if isinstance(report_text, str) and report_text.strip(): |
| 723 | + market_review_report = report_text |
| 724 | + else: |
| 725 | + try: |
| 726 | + result = AnalysisResultResponse.model_validate(task.result) |
| 727 | + except Exception: |
| 728 | + logger.warning( |
| 729 | + "解析任务结果失败,回退为空返回: task_id=%s", |
| 730 | + task.task_id, |
| 731 | + ) |
| 732 | + |
585 | 733 | return TaskStatus( |
586 | 734 | task_id=task.task_id, |
587 | 735 | status=task.status.value, |
588 | 736 | progress=task.progress, |
589 | | - result=None, # In-progress tasks do not carry a result payload. |
| 737 | + result=result, |
| 738 | + market_review_report=market_review_report, |
590 | 739 | error=task.error, |
591 | 740 | stock_name=task.stock_name, |
592 | 741 | original_query=task.original_query, |
|
0 commit comments