Skip to content

Commit 961643e

Browse files
committed
fix(review-feedback-1242): address latest review comments
1 parent e4998ad commit 961643e

2 files changed

Lines changed: 344 additions & 27 deletions

File tree

api/v1/endpoints/analysis.py

Lines changed: 202 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,18 @@
1717
"""
1818

1919
import asyncio
20+
import errno
2021
import json
2122
import logging
23+
import os
2224
import re
2325
import threading
26+
from dataclasses import dataclass
2427
from datetime import datetime
28+
from pathlib import Path
2529
from typing import Optional, Union, Dict, Any
2630

27-
from fastapi import APIRouter, HTTPException, Depends, Query, BackgroundTasks
31+
from fastapi import APIRouter, HTTPException, Depends, Query, BackgroundTasks, Body
2832
from fastapi.responses import JSONResponse, StreamingResponse
2933

3034
from api.deps import get_config_dep
@@ -71,25 +75,182 @@
7175

7276
router = APIRouter()
7377

78+
try:
79+
import fcntl
80+
except ImportError: # pragma: no cover - Windows fallback
81+
fcntl = None
82+
7483
_SUPPORTED_FREE_TEXT_RE = re.compile(r"^[A-Za-z0-9.*\-+\u3400-\u9fff\s]+$")
7584
_market_review_lock = threading.Lock()
7685
_market_review_running = False
7786

7887

79-
def _run_market_review_background(send_notification: bool) -> None:
80-
"""Run market review after the API response has been accepted."""
88+
@dataclass
89+
class _MarketReviewExecutionLock:
90+
handle: Any
91+
path: Path
92+
uses_flock: bool
93+
94+
95+
def _market_review_lock_path(config: Config) -> Path:
96+
database_path = getattr(config, "database_path", "./data/stock_analysis.db")
97+
return Path(database_path).parent / "market_review.lock"
98+
99+
100+
def _write_market_review_lock_metadata(handle: Any) -> None:
101+
handle.seek(0)
102+
handle.truncate()
103+
handle.write(f"pid={os.getpid()}\nstarted_at={datetime.now().isoformat()}\n")
104+
handle.flush()
105+
106+
107+
def _try_acquire_market_review_lock(
108+
config: Config,
109+
) -> Optional[_MarketReviewExecutionLock]:
110+
"""Acquire an in-process and cross-process market-review execution lock."""
81111
global _market_review_running
112+
lock_path = _market_review_lock_path(config)
113+
114+
with _market_review_lock:
115+
if _market_review_running:
116+
return None
117+
118+
lock_path.parent.mkdir(parents=True, exist_ok=True)
119+
120+
if fcntl is not None:
121+
handle = open(lock_path, "a+", encoding="utf-8")
122+
try:
123+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
124+
except (BlockingIOError, OSError) as exc:
125+
handle.close()
126+
if isinstance(exc, BlockingIOError) or getattr(exc, "errno", None) in (
127+
errno.EACCES,
128+
errno.EAGAIN,
129+
):
130+
return None
131+
raise
132+
uses_flock = True
133+
else: # pragma: no cover - exercised only on platforms without fcntl
134+
try:
135+
fd = os.open(str(lock_path), os.O_CREAT | os.O_EXCL | os.O_RDWR)
136+
except FileExistsError:
137+
return None
138+
handle = os.fdopen(fd, "w+", encoding="utf-8")
139+
uses_flock = False
140+
141+
_write_market_review_lock_metadata(handle)
142+
_market_review_running = True
143+
return _MarketReviewExecutionLock(
144+
handle=handle,
145+
path=lock_path,
146+
uses_flock=uses_flock,
147+
)
148+
149+
150+
def _release_market_review_lock(
151+
lock_token: Optional[_MarketReviewExecutionLock],
152+
) -> None:
153+
global _market_review_running
154+
with _market_review_lock:
155+
_market_review_running = False
156+
157+
if lock_token is None:
158+
return
159+
160+
try:
161+
if lock_token.uses_flock and fcntl is not None:
162+
fcntl.flock(lock_token.handle.fileno(), fcntl.LOCK_UN)
163+
finally:
164+
lock_token.handle.close()
165+
if not lock_token.uses_flock:
166+
try:
167+
lock_token.path.unlink()
168+
except FileNotFoundError:
169+
pass
170+
171+
172+
def _compute_market_review_override_region(config: Config) -> Optional[str]:
173+
if not getattr(config, "trading_day_check_enabled", True):
174+
return None
175+
176+
try:
177+
from src.core.trading_calendar import (
178+
get_open_markets_today,
179+
compute_effective_region,
180+
)
181+
182+
open_markets = get_open_markets_today()
183+
return compute_effective_region(
184+
getattr(config, "market_review_region", "cn") or "cn",
185+
open_markets,
186+
)
187+
except Exception as exc:
188+
logger.warning("大盘复盘交易日过滤失败,按配置继续执行: %s", exc)
189+
return None
190+
191+
192+
def _build_market_review_runtime(config: Config) -> tuple[Any, Any, Any]:
193+
from src.analyzer import GeminiAnalyzer
194+
from src.notification import NotificationService
195+
from src.search_service import SearchService
196+
197+
notifier = NotificationService()
198+
199+
search_service = None
200+
has_search_capability = getattr(config, "has_search_capability_enabled", None)
201+
if callable(has_search_capability) and has_search_capability():
202+
search_service = SearchService(
203+
bocha_keys=getattr(config, "bocha_api_keys", None),
204+
tavily_keys=getattr(config, "tavily_api_keys", None),
205+
anspire_keys=getattr(config, "anspire_api_keys", None),
206+
brave_keys=getattr(config, "brave_api_keys", None),
207+
serpapi_keys=getattr(config, "serpapi_keys", None),
208+
minimax_keys=getattr(config, "minimax_api_keys", None),
209+
searxng_base_urls=getattr(config, "searxng_base_urls", None),
210+
searxng_public_instances_enabled=getattr(
211+
config,
212+
"searxng_public_instances_enabled",
213+
True,
214+
),
215+
news_max_age_days=getattr(config, "news_max_age_days", 3),
216+
news_strategy_profile=getattr(config, "news_strategy_profile", "short"),
217+
)
218+
219+
analyzer = None
220+
if getattr(config, "gemini_api_key", None) or getattr(config, "openai_api_key", None):
221+
analyzer = GeminiAnalyzer(api_key=getattr(config, "gemini_api_key", None))
222+
if not analyzer.is_available():
223+
logger.warning("AI 分析器初始化后不可用,请检查 API Key 配置")
224+
analyzer = None
225+
else:
226+
logger.warning("未检测到 API Key (Gemini/OpenAI),将仅使用模板生成报告")
227+
228+
return notifier, analyzer, search_service
229+
230+
231+
def _run_market_review_background(
232+
send_notification: bool,
233+
override_region: Optional[str] = None,
234+
lock_token: Optional[_MarketReviewExecutionLock] = None,
235+
config: Optional[Config] = None,
236+
) -> None:
237+
"""Run market review after the API response has been accepted."""
82238
try:
83239
from src.core.market_review import run_market_review
84-
from src.notification import NotificationService
85240

86-
notifier = NotificationService()
87-
run_market_review(notifier, send_notification=send_notification)
241+
runtime_config = config or get_config_dep()
242+
notifier, analyzer, search_service = _build_market_review_runtime(runtime_config)
243+
run_market_review(
244+
notifier=notifier,
245+
analyzer=analyzer,
246+
search_service=search_service,
247+
send_notification=send_notification,
248+
override_region=override_region,
249+
)
88250
except Exception as exc:
89251
logger.error("大盘复盘后台任务失败: %s", exc, exc_info=True)
90252
finally:
91-
with _market_review_lock:
92-
_market_review_running = False
253+
_release_market_review_lock(lock_token)
93254

94255

95256
def _invalid_analysis_input_error() -> HTTPException:
@@ -430,23 +591,43 @@ def _handle_sync_analysis(
430591
description="提交一个后台大盘复盘任务,复用 CLI 的大盘复盘链路并保存报告。",
431592
)
432593
def trigger_market_review(
433-
request: MarketReviewRequest,
434594
background_tasks: BackgroundTasks,
595+
request: Optional[MarketReviewRequest] = Body(None),
596+
config: Config = Depends(get_config_dep),
435597
) -> MarketReviewAccepted:
436598
"""Trigger market review from Web/API without blocking the request."""
437-
global _market_review_running
438-
with _market_review_lock:
439-
if _market_review_running:
440-
raise HTTPException(
441-
status_code=409,
442-
detail={
443-
"error": "duplicate_market_review",
444-
"message": "大盘复盘正在执行中,请稍后再试",
445-
},
446-
)
447-
_market_review_running = True
599+
request = request or MarketReviewRequest()
600+
601+
override_region = _compute_market_review_override_region(config)
602+
if override_region == "":
603+
return MarketReviewAccepted(
604+
status="accepted",
605+
message="今日大盘复盘相关市场均为非交易日,已跳过大盘复盘",
606+
send_notification=request.send_notification,
607+
)
608+
609+
lock_token = _try_acquire_market_review_lock(config)
610+
if lock_token is None:
611+
raise HTTPException(
612+
status_code=409,
613+
detail={
614+
"error": "duplicate_market_review",
615+
"message": "大盘复盘正在执行中,请稍后再试",
616+
},
617+
)
618+
619+
try:
620+
background_tasks.add_task(
621+
_run_market_review_background,
622+
request.send_notification,
623+
override_region,
624+
lock_token,
625+
config,
626+
)
627+
except Exception:
628+
_release_market_review_lock(lock_token)
629+
raise
448630

449-
background_tasks.add_task(_run_market_review_background, request.send_notification)
450631
return MarketReviewAccepted(
451632
status="accepted",
452633
message="大盘复盘任务已提交,完成后会保存报告并按配置推送通知",

0 commit comments

Comments
 (0)