Skip to content

Commit 4c791a2

Browse files
authored
Fix/improve docker consistency and resolve critical batch/notification bugs(ZhuLinsen#303)
- Fix ZhuLinsen#297: Replace non-existent 'webui' service with 'server' in documentation. - Fix ZhuLinsen#281, ZhuLinsen#298: Generate unique query_id per stock in batch mode to prevent dashboard content mismatch. - Fix ZhuLinsen#287: Add exponential backoff retry and rate-limit handling for Telegram notifications. - Sync Dockerfile and docker-compose.yml environment variables to WEBUI_HOST (PR ZhuLinsen#291 follow-up).
1 parent aafbb5f commit 4c791a2

6 files changed

Lines changed: 80 additions & 43 deletions

File tree

docker/Dockerfile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,8 @@ RUN mkdir -p /app/data /app/logs /app/reports
4949
ENV PYTHONUNBUFFERED=1
5050
ENV LOG_DIR=/app/logs
5151
ENV DATABASE_PATH=/app/data/stock_analysis.db
52-
# API 服务
53-
ENV API_HOST=0.0.0.0
52+
# Web/API service
53+
ENV WEBUI_HOST=0.0.0.0
5454
ENV API_PORT=8000
5555

5656
# 暴露 API 端口

docker/docker-compose.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ x-common: &common
3030
environment:
3131
- TZ=Asia/Shanghai
3232

33-
# API 服务绑定地址(容器内需要绑定到 0.0.0.0
33+
# Web/API service bind address (must be 0.0.0.0 inside container)
3434
- WEBUI_HOST=0.0.0.0
3535
# API_PORT 从 .env 文件读取,无需在此硬编码
3636

docs/full-guide.md

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -234,29 +234,25 @@ cp .env.example .env
234234
vim .env # 填入 API Key 和配置
235235

236236
# 3. 启动容器
237-
docker-compose -f ./docker/docker-compose.yml up -d webui # WebUI 模式(推荐)
237+
docker-compose -f ./docker/docker-compose.yml up -d server # Web 服务模式(推荐,提供 API 与 WebUI
238238
docker-compose -f ./docker/docker-compose.yml up -d analyzer # 定时任务模式
239-
docker-compose -f ./docker/docker-compose.yml up -d server # FastAPI Web模式(和WebUI模式占用相同端口注意避免冲突)
240239
docker-compose -f ./docker/docker-compose.yml up -d # 同时启动两种模式
241240

242241
# 4. 访问 WebUI
243242
# http://localhost:8000
244243

245244
# 5. 查看日志
246-
docker-compose -f ./docker/docker-compose.yml logs -f webui
245+
docker-compose -f ./docker/docker-compose.yml logs -f server
247246
```
248247

249248
### 运行模式说明
250249

251250
| 命令 | 说明 | 端口 |
252251
|------|------|------|
253-
| `docker-compose -f ./docker/docker-compose.yml up -d webui` | WebUI 模式,手动触发分析 | 8000 |
252+
| `docker-compose -f ./docker/docker-compose.yml up -d server` | Web 服务模式,提供 API 与 WebUI | 8000 |
254253
| `docker-compose -f ./docker/docker-compose.yml up -d analyzer` | 定时任务模式,每日自动执行 | - |
255-
| `docker-compose -f ./docker/docker-compose.yml up -d server` | FastAPI 模式,提供 API 与静态资源 | 8000 |
256254
| `docker-compose -f ./docker/docker-compose.yml up -d` | 同时启动两种模式 | 8000 |
257255

258-
> 注意:WebUI 与 FastAPI 默认端口都是 8000,若需同时启动请设置 `WEBUI_PORT``API_PORT`
259-
260256
### Docker Compose 配置
261257

262258
`docker-compose.yml` 使用 YAML 锚点复用配置:

docs/full-guide_EN.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -227,22 +227,22 @@ cp .env.example .env
227227
vim .env # Fill in API Keys and configuration
228228

229229
# 3. Start container
230-
docker-compose -f ./docker/docker-compose.yml up -d webui # WebUI mode (recommended)
230+
docker-compose -f ./docker/docker-compose.yml up -d server # Web service mode (recommended, provides API & WebUI)
231231
docker-compose -f ./docker/docker-compose.yml up -d analyzer # Scheduled task mode
232232
docker-compose -f ./docker/docker-compose.yml up -d # Start both modes
233233

234234
# 4. Access WebUI
235235
# http://localhost:8000
236236

237237
# 5. View logs
238-
docker-compose -f ./docker/docker-compose.yml logs -f webui
238+
docker-compose -f ./docker/docker-compose.yml logs -f server
239239
```
240240

241241
### Run Mode Description
242242

243243
| Command | Description | Port |
244244
|------|------|------|
245-
| `docker-compose -f ./docker/docker-compose.yml up -d webui` | WebUI mode, manually trigger analysis | 8000 |
245+
| `docker-compose -f ./docker/docker-compose.yml up -d server` | Web service mode, provides API & WebUI | 8000 |
246246
| `docker-compose -f ./docker/docker-compose.yml up -d analyzer` | Scheduled task mode, daily auto execution | - |
247247
| `docker-compose -f ./docker/docker-compose.yml up -d` | Start both modes simultaneously | 8000 |
248248

src/core/pipeline.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
import logging
1515
import time
16+
import uuid
1617
from concurrent.futures import ThreadPoolExecutor, as_completed
1718
from datetime import date
1819
from typing import List, Dict, Any, Optional, Tuple
@@ -292,8 +293,13 @@ def analyze_stock(self, code: str, report_type: ReportType) -> Optional[Analysis
292293
result.change_pct = realtime_data.get('change_pct')
293294

294295
# Step 8: 保存分析历史记录
296+
# Fix #281/#298: generate a unique query_id per stock so each
297+
# history detail page shows its own analysis result instead of
298+
# reusing the batch-level id which caused all stocks to resolve
299+
# to the same detail record.
295300
if result:
296301
try:
302+
per_stock_query_id = uuid.uuid4().hex
297303
context_snapshot = self._build_context_snapshot(
298304
enhanced_context=enhanced_context,
299305
news_content=news_context,
@@ -302,7 +308,7 @@ def analyze_stock(self, code: str, report_type: ReportType) -> Optional[Analysis
302308
)
303309
self.db.save_analysis_history(
304310
result=result,
305-
query_id=self.query_id or "",
311+
query_id=per_stock_query_id,
306312
report_type=report_type.value,
307313
news_content=news_context,
308314
context_snapshot=context_snapshot,

src/notification.py

Lines changed: 64 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import json
2121
import smtplib
2222
import re
23+
import time
2324
import markdown2
2425
from datetime import datetime
2526
from typing import List, Dict, Any, Optional
@@ -2039,9 +2040,8 @@ def send_to_telegram(self, content: str) -> bool:
20392040
return False
20402041

20412042
def _send_telegram_message(self, api_url: str, chat_id: str, text: str, message_thread_id: Optional[str] = None) -> bool:
2042-
"""发送单条 Telegram 消息"""
2043-
# 转换 Markdown 为 Telegram 支持的格式
2044-
# Telegram 的 Markdown 格式稍有不同,做简单处理
2043+
"""Send a single Telegram message with exponential backoff retry (Fixes #287)"""
2044+
# Convert Markdown to Telegram-compatible format
20452045
telegram_text = self._convert_to_telegram_markdown(text)
20462046

20472047
payload = {
@@ -2053,35 +2053,70 @@ def _send_telegram_message(self, api_url: str, chat_id: str, text: str, message_
20532053

20542054
if message_thread_id:
20552055
payload['message_thread_id'] = message_thread_id
2056+
2057+
max_retries = 3
2058+
for attempt in range(1, max_retries + 1):
2059+
try:
2060+
response = requests.post(api_url, json=payload, timeout=10)
2061+
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as e:
2062+
if attempt < max_retries:
2063+
delay = 2 ** attempt # 2s, 4s
2064+
logger.warning(f"Telegram request failed (attempt {attempt}/{max_retries}): {e}, "
2065+
f"retrying in {delay}s...")
2066+
time.sleep(delay)
2067+
continue
2068+
else:
2069+
logger.error(f"Telegram request failed after {max_retries} attempts: {e}")
2070+
return False
20562071

2057-
response = requests.post(api_url, json=payload, timeout=10)
2058-
2059-
if response.status_code == 200:
2060-
result = response.json()
2061-
if result.get('ok'):
2062-
logger.info("Telegram 消息发送成功")
2063-
return True
2064-
else:
2065-
error_desc = result.get('description', '未知错误')
2066-
logger.error(f"Telegram 返回错误: {error_desc}")
2067-
2068-
# 如果 Markdown 解析失败,尝试纯文本发送
2069-
if 'parse' in error_desc.lower() or 'markdown' in error_desc.lower():
2070-
logger.info("尝试使用纯文本格式重新发送...")
2071-
payload['parse_mode'] = None
2072-
payload['text'] = text # 使用原始文本
2073-
del payload['parse_mode']
2072+
if response.status_code == 200:
2073+
result = response.json()
2074+
if result.get('ok'):
2075+
logger.info("Telegram 消息发送成功")
2076+
return True
2077+
else:
2078+
error_desc = result.get('description', '未知错误')
2079+
logger.error(f"Telegram 返回错误: {error_desc}")
20742080

2075-
response = requests.post(api_url, json=payload, timeout=10)
2076-
if response.status_code == 200 and response.json().get('ok'):
2077-
logger.info("Telegram 消息发送成功(纯文本)")
2078-
return True
2079-
2081+
# If Markdown parsing failed, fall back to plain text
2082+
if 'parse' in error_desc.lower() or 'markdown' in error_desc.lower():
2083+
logger.info("尝试使用纯文本格式重新发送...")
2084+
plain_payload = dict(payload)
2085+
plain_payload.pop('parse_mode', None)
2086+
plain_payload['text'] = text # Use original text
2087+
2088+
try:
2089+
response = requests.post(api_url, json=plain_payload, timeout=10)
2090+
if response.status_code == 200 and response.json().get('ok'):
2091+
logger.info("Telegram 消息发送成功(纯文本)")
2092+
return True
2093+
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as e:
2094+
logger.error(f"Telegram plain-text fallback failed: {e}")
2095+
2096+
return False
2097+
elif response.status_code == 429:
2098+
# Rate limited — respect Retry-After header
2099+
retry_after = int(response.headers.get('Retry-After', 2 ** attempt))
2100+
if attempt < max_retries:
2101+
logger.warning(f"Telegram rate limited, retrying in {retry_after}s "
2102+
f"(attempt {attempt}/{max_retries})...")
2103+
time.sleep(retry_after)
2104+
continue
2105+
else:
2106+
logger.error(f"Telegram rate limited after {max_retries} attempts")
2107+
return False
2108+
else:
2109+
if attempt < max_retries and response.status_code >= 500:
2110+
delay = 2 ** attempt
2111+
logger.warning(f"Telegram server error HTTP {response.status_code} "
2112+
f"(attempt {attempt}/{max_retries}), retrying in {delay}s...")
2113+
time.sleep(delay)
2114+
continue
2115+
logger.error(f"Telegram 请求失败: HTTP {response.status_code}")
2116+
logger.error(f"响应内容: {response.text}")
20802117
return False
2081-
else:
2082-
logger.error(f"Telegram 请求失败: HTTP {response.status_code}")
2083-
logger.error(f"响应内容: {response.text}")
2084-
return False
2118+
2119+
return False
20852120

20862121
def _send_telegram_chunked(self, api_url: str, chat_id: str, content: str, max_length: int, message_thread_id: Optional[str] = None) -> bool:
20872122
"""分段发送长 Telegram 消息"""

0 commit comments

Comments
 (0)