diff --git a/Dockerfile b/Dockerfile index eee57bc..162fadf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,13 @@ # 使用官方Python镜像作为基础镜像 FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/python:3.12-slim +# 设置时区环境变量 +ENV TZ=Asia/Shanghai + # 设置工作目录 WORKDIR /app -# 安装Node.js (pywencai需要) 和中文字体 (PDF生成需要) +# 安装Node.js (pywencai需要)、中文字体 (PDF生成需要) 和时区数据 RUN apt-get update && apt-get install -y \ curl \ gnupg \ @@ -12,6 +15,9 @@ RUN apt-get update && apt-get install -y \ fonts-wqy-zenhei \ fonts-wqy-microhei \ fontconfig \ + tzdata \ + && ln -snf /usr/share/zoneinfo/$TZ /etc/localtime \ + && echo $TZ > /etc/timezone \ && curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \ && apt-get install -y nodejs \ && fc-cache -fv \ diff --git a/Dockerfile国内源版 b/Dockerfile国内源版 index cd380eb..48b9f37 100644 --- a/Dockerfile国内源版 +++ b/Dockerfile国内源版 @@ -1,28 +1,34 @@ # 使用官方Python镜像作为基础镜像 FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/python:3.12-slim +# 设置时区环境变量 +ENV TZ=Asia/Shanghai + # 替换apt-get源为国内源(阿里源) RUN echo "deb https://mirrors.aliyun.com/debian/ bookworm main" > /etc/apt/sources.list && \ -    echo "deb https://mirrors.aliyun.com/debian/ bookworm-updates main" >> /etc/apt/sources.list && \ -    echo "deb https://mirrors.aliyun.com/debian-security bookworm-security main" >> /etc/apt/sources.list && \ -    rm -rf /etc/apt/sources.list.d/* || true + echo "deb https://mirrors.aliyun.com/debian/ bookworm-updates main" >> /etc/apt/sources.list && \ + echo "deb https://mirrors.aliyun.com/debian-security bookworm-security main" >> /etc/apt/sources.list && \ + rm -rf /etc/apt/sources.list.d/* || true # 设置工作目录 WORKDIR /app -# 安装Node.js和中文字体 - 使用修正版方案一 +# 安装Node.js、中文字体和时区数据 - 使用修正版方案一 RUN apt-get update && apt-get install -y \ -    curl \ -    gnupg \ -    fonts-noto-cjk \ -    fonts-wqy-zenhei \ -    fonts-wqy-microhei \ -    fontconfig \ -    && curl -fsSL https://mirrors.aliyun.com/nodesource/setup_18.x | bash - \ -    && apt-get install -y nodejs npm \ -    && fc-cache -fv \ -    && apt-get clean \ -    && rm -rf /var/lib/apt/lists/* + curl \ + gnupg \ + fonts-noto-cjk \ + fonts-wqy-zenhei \ + fonts-wqy-microhei \ + fontconfig \ + tzdata \ + && ln -snf /usr/share/zoneinfo/$TZ /etc/localtime \ + && echo $TZ > /etc/timezone \ + && curl -fsSL https://mirrors.aliyun.com/nodesource/setup_18.x | bash - \ + && apt-get install -y nodejs npm \ + && fc-cache -fv \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* # 验证安装 - 添加详细检查 RUN echo "Node.js版本:" && node --version && \ diff --git a/app.py b/app.py index cfafcc9..23a7c87 100644 --- a/app.py +++ b/app.py @@ -290,7 +290,7 @@ def main(): # 快捷导航 - 移到顶部 st.markdown("### 🔍 快捷导航") - if st.button("📖 历史记录", use_container_width=True, key="nav_history"): + if st.button("📖 历史记录", width='stretch', key="nav_history"): st.session_state.show_history = True if 'show_monitor' in st.session_state: del st.session_state.show_monitor @@ -299,7 +299,7 @@ def main(): if 'show_portfolio' in st.session_state: del st.session_state.show_portfolio - if st.button("📊 实时监测", use_container_width=True, key="nav_monitor"): + if st.button("📊 实时监测", width='stretch', key="nav_monitor"): st.session_state.show_monitor = True if 'show_history' in st.session_state: del st.session_state.show_history @@ -310,7 +310,7 @@ def main(): if 'show_portfolio' in st.session_state: del st.session_state.show_portfolio - if st.button("🎯 主力选股", use_container_width=True, key="nav_main_force"): + if st.button("🎯 主力选股", width='stretch', key="nav_main_force"): st.session_state.show_main_force = True if 'show_history' in st.session_state: del st.session_state.show_history @@ -325,7 +325,7 @@ def main(): if 'show_portfolio' in st.session_state: del st.session_state.show_portfolio - if st.button("🎯 智策板块", use_container_width=True, key="nav_sector_strategy"): + if st.button("🎯 智策板块", width='stretch', key="nav_sector_strategy"): st.session_state.show_sector_strategy = True if 'show_history' in st.session_state: del st.session_state.show_history @@ -340,7 +340,7 @@ def main(): if 'show_portfolio' in st.session_state: del st.session_state.show_portfolio - if st.button("🎯 智瞰龙虎", use_container_width=True, key="nav_longhubang"): + if st.button("🎯 智瞰龙虎", width='stretch', key="nav_longhubang"): st.session_state.show_longhubang = True if 'show_history' in st.session_state: del st.session_state.show_history @@ -355,7 +355,7 @@ def main(): if 'show_portfolio' in st.session_state: del st.session_state.show_portfolio - if st.button("📊 持仓分析", use_container_width=True, key="nav_portfolio"): + if st.button("📊 持仓分析", width='stretch', key="nav_portfolio"): st.session_state.show_portfolio = True if 'show_history' in st.session_state: del st.session_state.show_history @@ -370,7 +370,7 @@ def main(): if 'show_longhubang' in st.session_state: del st.session_state.show_longhubang - if st.button("🏠 返回首页", use_container_width=True, key="nav_home"): + if st.button("🏠 返回首页", width='stretch', key="nav_home"): if 'show_history' in st.session_state: del st.session_state.show_history if 'show_monitor' in st.session_state: @@ -386,7 +386,7 @@ def main(): if 'show_portfolio' in st.session_state: del st.session_state.show_portfolio - if st.button("⚙️ 环境配置", use_container_width=True, key="nav_config"): + if st.button("⚙️ 环境配置", width='stretch', key="nav_config"): st.session_state.show_config = True if 'show_history' in st.session_state: del st.session_state.show_history @@ -546,10 +546,10 @@ def main(): ) with col2: - analyze_button = st.button("🚀 开始分析", type="primary", use_container_width=True) + analyze_button = st.button("🚀 开始分析", type="primary", width='stretch') with col3: - if st.button("🔄 清除缓存", use_container_width=True): + if st.button("🔄 清除缓存", width='stretch'): st.cache_data.clear() st.success("缓存已清除") @@ -564,13 +564,13 @@ def main(): col1, col2, col3 = st.columns(3) with col1: - analyze_button = st.button("🚀 开始批量分析", type="primary", use_container_width=True) + analyze_button = st.button("🚀 开始批量分析", type="primary", width='stretch') with col2: - if st.button("🔄 清除缓存", use_container_width=True): + if st.button("🔄 清除缓存", width='stretch'): st.cache_data.clear() st.success("缓存已清除") with col3: - if st.button("🗑️ 清除结果", use_container_width=True): + if st.button("🗑️ 清除结果", width='stretch'): if 'batch_analysis_results' in st.session_state: del st.session_state.batch_analysis_results st.success("已清除批量分析结果") @@ -1450,7 +1450,7 @@ def display_stock_chart(stock_data, stock_info): # 生成唯一的key chart_key = f"main_stock_chart_{stock_info.get('symbol', 'unknown')}_{int(time.time())}" - st.plotly_chart(fig, use_container_width=True, key=chart_key) + st.plotly_chart(fig, width='stretch', key=chart_key) # 成交量图 if 'Volume' in stock_data.columns: @@ -1471,7 +1471,7 @@ def display_stock_chart(stock_data, stock_info): # 生成唯一的key volume_key = f"volume_chart_{stock_info.get('symbol', 'unknown')}_{int(time.time())}" - st.plotly_chart(fig_volume, use_container_width=True, key=volume_key) + st.plotly_chart(fig_volume, width='stretch', key=volume_key) def display_agents_analysis(agents_results): """显示各分析师报告""" @@ -1841,10 +1841,10 @@ def display_add_to_monitor_dialog(record): col_a, col_b, col_c = st.columns(3) with col_a: - submit = st.form_submit_button("✅ 确认加入监测", type="primary", use_container_width=True) + submit = st.form_submit_button("✅ 确认加入监测", type="primary", width='stretch') with col_b: - cancel = st.form_submit_button("❌ 取消", use_container_width=True) + cancel = st.form_submit_button("❌ 取消", width='stretch') if submit: if new_entry_min > 0 and new_entry_max > 0 and new_entry_max > new_entry_min: @@ -2050,7 +2050,7 @@ def display_record_detail(record_id): col1, col2 = st.columns([1, 3]) with col1: - if st.button("➕ 加入监测", type="primary", use_container_width=True): + if st.button("➕ 加入监测", type="primary", width='stretch'): st.session_state.add_to_monitor_id = record_id st.rerun() @@ -2362,7 +2362,7 @@ def display_config_manager(): # 测试连通按钮 if new_webhook_enabled and new_webhook_url: - if st.button("🧪 测试Webhook连通", use_container_width=True, key="test_webhook_btn"): + if st.button("🧪 测试Webhook连通", width='stretch', key="test_webhook_btn"): with st.spinner("正在发送测试消息..."): # 临时更新配置 temp_env_backup = {} @@ -2411,7 +2411,7 @@ def display_config_manager(): col1, col2, col3, col4 = st.columns([1, 1, 1, 2]) with col1: - if st.button("💾 保存配置", type="primary", use_container_width=True): + if st.button("💾 保存配置", type="primary", width='stretch'): # 验证配置 is_valid, message = config_manager.validate_config(st.session_state.temp_config) @@ -2436,14 +2436,14 @@ def display_config_manager(): st.error(f"❌ 配置验证失败: {message}") with col2: - if st.button("🔄 重置", use_container_width=True): + if st.button("🔄 重置", width='stretch'): # 重置为当前文件中的值 st.session_state.temp_config = {key: info["value"] for key, info in config_info.items()} st.success("✅ 已重置为当前配置") st.rerun() with col3: - if st.button("⬅️ 返回", use_container_width=True): + if st.button("⬅️ 返回", width='stretch'): if 'show_config' in st.session_state: del st.session_state.show_config if 'temp_config' in st.session_state: @@ -2571,6 +2571,10 @@ def display_comparison_table(results): confidence = 'N/A' target_price = 'N/A' + # 确保信心度为字符串类型,避免类型混合导致的序列化错误 + if isinstance(confidence, (int, float)): + confidence = str(confidence) + row = { '股票代码': stock_info.get('symbol', 'N/A'), '股票名称': stock_info.get('name', 'N/A'), @@ -2593,7 +2597,7 @@ def display_comparison_table(results): # 显示表格(不使用样式,避免matplotlib导入问题) st.dataframe( df, - use_container_width=True, + width='stretch', height=400 ) @@ -2633,7 +2637,7 @@ def display_comparison_table(results): filtered_df = filtered_df.sort_values('RSI', ascending=False) if not filtered_df.empty: - st.dataframe(filtered_df, use_container_width=True) + st.dataframe(filtered_df, width='stretch') else: st.info("没有符合条件的股票") diff --git a/portfolio_scheduler.py b/portfolio_scheduler.py index af07b15..466b2c8 100644 --- a/portfolio_scheduler.py +++ b/portfolio_scheduler.py @@ -489,9 +489,14 @@ class PortfolioScheduler: def _reschedule(self): """重新调度任务(支持多个时间点)""" - schedule.clear() + # 只清除持仓定时分析的任务,不影响其他模块 + jobs_to_remove = [job for job in schedule.jobs if not any(tag in ['sector_strategy', 'monitor'] for tag in job.tags)] + for job in jobs_to_remove: + schedule.cancel_job(job) + for time_str in self.schedule_times: - schedule.every().day.at(time_str).do(self._scheduled_job) + job = schedule.every().day.at(time_str).do(self._scheduled_job) + job.tag('portfolio_analysis') self._update_next_run_time() print(f"[OK] 重新调度任务: 每天 {', '.join(self.schedule_times)}") @@ -537,9 +542,15 @@ class PortfolioScheduler: return False # 调度任务(为每个时间点创建任务) - schedule.clear() + # 只清除持仓定时分析的任务,不影响智策和监测任务 + jobs_to_remove = [job for job in schedule.jobs if 'portfolio_analysis' in job.tags] + for job in jobs_to_remove: + schedule.cancel_job(job) + print(f"[OK] 清除了 {len(jobs_to_remove)} 个旧的持仓任务") + for time_str in self.schedule_times: - schedule.every().day.at(time_str).do(self._scheduled_job) + job = schedule.every().day.at(time_str).do(self._scheduled_job) + job.tag('portfolio_analysis') print(f"[OK] 添加调度任务: 每天 {time_str}") self._update_next_run_time() @@ -570,7 +581,15 @@ class PortfolioScheduler: return False self._is_running = False - schedule.clear() + + # 只清除持仓定时分析的任务,不影响其他模块(智策、监测) + try: + jobs_to_remove = [job for job in schedule.jobs if 'portfolio_analysis' in job.tags] + for job in jobs_to_remove: + schedule.cancel_job(job) + print(f"[OK] 清除了 {len(jobs_to_remove)} 个持仓任务") + except Exception as e: + print(f"[WARN] 清除任务时出错: {e}") # 等待线程结束(最多等待2秒) if self.thread and self.thread.is_alive(): diff --git a/sector_strategy_scheduler.py b/sector_strategy_scheduler.py index 06c528b..60436e9 100644 --- a/sector_strategy_scheduler.py +++ b/sector_strategy_scheduler.py @@ -23,6 +23,8 @@ class SectorStrategyScheduler: self.enabled = False self.last_run_time = None self.last_result = None + self.last_notification_time = None # 记录上次通知时间,防止重复 + self._analysis_lock = threading.Lock() # 添加锁,防止并发执行 print("[智策定时] 调度器初始化完成") def start(self, schedule_time="09:00"): @@ -38,14 +40,24 @@ class SectorStrategyScheduler: self.schedule_time = schedule_time self.enabled = True + + # 先清除所有带sector_strategy标签的任务 + try: + jobs_to_remove = [job for job in schedule.jobs if 'sector_strategy' in job.tags] + for job in jobs_to_remove: + schedule.cancel_job(job) + print(f"[智策定时] 清除了 {len(jobs_to_remove)} 个旧任务") + except Exception as e: + print(f"[智策定时] 清除旧任务时出错: {e}") + + # 设置定时任务(确保只添加一次) + job = schedule.every().day.at(schedule_time).do(self._run_analysis_safe) + job.tag('sector_strategy') + print(f"[智策定时] 添加新任务: 每天 {schedule_time}") + + # 设置运行标志 self.running = True - # 清除之前的任务 - schedule.clear('sector_strategy') - - # 设置定时任务 - schedule.every().day.at(schedule_time).do(self._run_analysis).tag('sector_strategy') - # 启动后台线程 self.thread = threading.Thread(target=self._schedule_loop, daemon=True) self.thread.start() @@ -61,7 +73,12 @@ class SectorStrategyScheduler: self.running = False self.enabled = False - schedule.clear('sector_strategy') + + # 只清除智策的任务,不影响其他模块 + jobs_to_remove = [job for job in schedule.jobs if 'sector_strategy' in job.tags] + for job in jobs_to_remove: + schedule.cancel_job(job) + print(f"[智策定时] 清除了 {len(jobs_to_remove)} 个任务") print("[智策定时] ✓ 定时任务已停止") return True @@ -78,6 +95,18 @@ class SectorStrategyScheduler: print(f"[智策定时] ✗ 调度循环出错: {e}") time.sleep(60) + def _run_analysis_safe(self): + """运行智策分析(带锁保护,防止并发执行)""" + # 尝试获取锁,如果已被占用则跳过本次执行 + if not self._analysis_lock.acquire(blocking=False): + print("[智策定时] ⚠️ 上一次分析还未完成,跳过本次执行") + return + + try: + self._run_analysis() + finally: + self._analysis_lock.release() + def _run_analysis(self): """运行智策分析""" print("\n" + "="*60) @@ -128,8 +157,16 @@ class SectorStrategyScheduler: self._send_error_notification(f"分析异常: {str(e)}") def _send_analysis_notification(self, result): - """发送分析结果通知(邮件和/或webhook)""" + """发送分析结果通知(邮件和/或webhook)- 带去重保护""" try: + # 去重检查:如果5分钟内已发送过通知,则跳过 + current_time = datetime.now() + if self.last_notification_time: + time_diff = (current_time - self.last_notification_time).total_seconds() + if time_diff < 300: # 5分钟 = 300秒 + print(f"[智策定时] ⚠️ 距离上次通知仅{time_diff:.0f}秒,跳过重复发送") + return + config = notification_service.config predictions = result.get("final_predictions", {}) timestamp = result.get("timestamp", datetime.now().strftime('%Y-%m-%d %H:%M:%S')) @@ -163,6 +200,11 @@ class SectorStrategyScheduler: else: print("[智策定时] ✗ 邮件发送失败") + # 更新最后通知时间 + if sent_count > 0: + self.last_notification_time = current_time + print(f"[智策定时] 📝 已记录通知时间: {current_time.strftime('%H:%M:%S')}") + if sent_count == 0: print("[智策定时] ⚠️ 未配置通知方式或发送全部失败") diff --git a/stock_analysis.db b/stock_analysis.db index 7feb718..06b5a80 100644 Binary files a/stock_analysis.db and b/stock_analysis.db differ diff --git a/修复说明-智策定时分析通知重复.md b/修复说明-智策定时分析通知重复.md new file mode 100644 index 0000000..ef2ad56 --- /dev/null +++ b/修复说明-智策定时分析通知重复.md @@ -0,0 +1,302 @@ +# 智策定时分析通知重复问题 - 修复说明 + +## 📋 问题描述 + +**症状**:智策定时分析完成后 +- ✉️ 邮箱收到 **3 封相同邮件** +- 📱 钉钉收到 **2 次相同通知** +- 所有通知内容完全一样,同时收到 + +**定时设置**: +- 定时时间: 08:15 +- 任务状态: 运行中 +- 上次运行: 2025-10-21 08:21:55 + +## 🔍 根本原因分析 + +### 原因1:schedule库任务重复添加 ⭐ 主要原因 +**问题**:`schedule.clear('sector_strategy')` 在某些情况下无法正确清除所有旧任务,导致任务累积。 + +**证据**: +- 3封邮件表示 `_send_analysis_notification()` 被调用了3次 +- 说明同一时刻有3个定时任务被触发 + +### 原因2:持仓定时分析的干扰 +**问题**:`portfolio_scheduler.py` 中使用 `schedule.clear()` 会清除**所有**定时任务,包括智策的! + +**代码位置**: +- `portfolio_scheduler.py` 第540行:`schedule.clear()` +- `portfolio_scheduler.py` 第492行:`schedule.clear()` +- `portfolio_scheduler.py` 第573行:`schedule.clear()` + +### 原因3:缺少并发保护 +**问题**:如果分析还没完成,定时器再次触发,会导致多个分析任务并发执行。 + +## ✅ 修复方案 + +### 修复1:智策调度器改进(sector_strategy_scheduler.py) + +#### 1.1 添加并发保护锁 +```python +def __init__(self): + # ... + self._analysis_lock = threading.Lock() # 防止并发执行 +``` + +#### 1.2 安全的任务执行包装 +```python +def _run_analysis_safe(self): + """运行智策分析(带锁保护,防止并发执行)""" + if not self._analysis_lock.acquire(blocking=False): + print("[智策定时] ⚠️ 上一次分析还未完成,跳过本次执行") + return + + try: + self._run_analysis() + finally: + self._analysis_lock.release() +``` + +#### 1.3 改进任务清除逻辑 +**修复前**: +```python +schedule.clear('sector_strategy') # 可能无法正确清除 +``` + +**修复后**: +```python +# 手动遍历并清除所有带sector_strategy标签的任务 +jobs_to_remove = [job for job in schedule.jobs if 'sector_strategy' in job.tags] +for job in jobs_to_remove: + schedule.cancel_job(job) +print(f"[智策定时] 清除了 {len(jobs_to_remove)} 个旧任务") +``` + +#### 1.4 添加通知去重机制 ⭐ 核心防护 +```python +def _send_analysis_notification(self, result): + # 去重检查:如果5分钟内已发送过通知,则跳过 + current_time = datetime.now() + if self.last_notification_time: + time_diff = (current_time - self.last_notification_time).total_seconds() + if time_diff < 300: # 5分钟 = 300秒 + print(f"[智策定时] ⚠️ 距离上次通知仅{time_diff:.0f}秒,跳过重复发送") + return + + # ... 发送通知 ... + + # 更新最后通知时间 + if sent_count > 0: + self.last_notification_time = current_time + print(f"[智策定时] 📝 已记录通知时间: {current_time.strftime('%H:%M:%S')}") +``` + +### 修复2:持仓调度器改进(portfolio_scheduler.py) + +#### 2.1 使用标签化任务管理 +**修复前**: +```python +schedule.clear() # 危险!会清除所有任务 +``` + +**修复后**: +```python +# 只清除持仓定时分析的任务,不影响智策和监测 +jobs_to_remove = [job for job in schedule.jobs if 'portfolio_analysis' in job.tags] +for job in jobs_to_remove: + schedule.cancel_job(job) +``` + +#### 2.2 添加任务标签 +```python +for time_str in self.schedule_times: + job = schedule.every().day.at(time_str).do(self._scheduled_job) + job.tag('portfolio_analysis') # 打标签,方便管理 +``` + +## 📊 修复效果 + +### 修复前 +``` +08:15:00 → 触发任务1 → 发送邮件 ✉️ +08:15:00 → 触发任务2 → 发送邮件 ✉️ (重复!) +08:15:00 → 触发任务3 → 发送邮件 ✉️ (重复!) +``` +**结果**:收到3封邮件 + +### 修复后 +``` +08:15:00 → 触发任务1 → 获取锁 → 发送邮件 ✉️ +08:15:00 → 触发任务2 → 无法获取锁 → 跳过 +08:15:00 → 触发任务3 → 无法获取锁 → 跳过 +``` +**结果**:只收到1封邮件 + +### 多层防护机制 +1. **任务去重**:改进的清除逻辑防止任务累积 +2. **并发锁**:防止多个任务同时执行 +3. **时间去重**:5分钟内重复通知直接跳过 +4. **模块隔离**:各模块任务独立管理,互不影响 + +## 🧪 测试方法 + +### 测试1:验证任务唯一性 +```python +# 在智策定时分析UI中 +1. 启动定时任务 +2. 运行以下代码检查任务数量: +import schedule +sector_jobs = [job for job in schedule.jobs if 'sector_strategy' in job.tags] +print(f"智策任务数量: {len(sector_jobs)}") # 应该只有1个 +``` + +### 测试2:验证去重机制 +```python +# 手动触发两次分析 +1. 点击"立即运行" +2. 等待1分钟 +3. 再次点击"立即运行" +# 第二次应该显示"距离上次通知仅XX秒,跳过重复发送" +``` + +### 测试3:验证模块隔离 +```python +# 同时运行智策和持仓定时分析 +1. 启动智策定时任务 +2. 启动持仓定时任务 +3. 检查两个任务是否都正常运行 +``` + +### 测试4:验证实际效果(最重要) +```python +# 等待下一次定时触发(明天08:15) +1. 确认只收到1封邮件 +2. 确认只收到1次钉钉通知 +3. 查看日志输出,确认只有1次发送记录 +``` + +## 📝 日志输出示例 + +### 正常情况(修复后) +``` +[智策定时] 开始定时分析 - 2025-10-22 08:15:00 +[智策定时] [1/3] 获取市场数据... +[智策定时] ✓ 数据获取成功 +[智策定时] [2/3] AI智能体分析中... +[智策定时] ✓ 分析完成 +[智策定时] [3/3] 发送邮件通知... +[智策定时] [Webhook] 准备发送... +[智策定时] ✓ Webhook发送成功 +[智策定时] [邮件] 准备发送... +[智策定时] ✓ 邮件发送成功 +[智策定时] 📝 已记录通知时间: 08:15:23 +[智策定时] ✓ 定时分析完成! +``` + +### 重复触发时(修复后) +``` +[智策定时] ⚠️ 上一次分析还未完成,跳过本次执行 +``` + +### 5分钟内重复通知时(修复后) +``` +[智策定时] ⚠️ 距离上次通知仅123秒,跳过重复发送 +``` + +## 🔧 如何使用修复后的版本 + +### 步骤1:重启系统 +```bash +# 停止当前运行的streamlit +Ctrl+C + +# 重新启动 +python run.py +# 或 +streamlit run app.py +``` + +### 步骤2:重新配置定时任务 +1. 进入"智策板块分析"页面 +2. 找到"定时分析"标签页 +3. 点击"停止"(如果正在运行) +4. 重新设置定时时间为 08:15 +5. 点击"启动" + +### 步骤3:验证状态 +查看页面显示: +``` +✅ 定时任务运行中 +⏰ 定时时间: 08:15 +📅 下次运行: 2025-10-22 08:15:00 +``` + +### 步骤4:等待下次执行 +明天08:15自动执行后,检查: +- ✅ 邮箱只收到1封邮件 +- ✅ 钉钉只收到1次通知 +- ✅ UI显示正常的执行时间 + +## 🛡️ 预防措施 + +### 1. 避免重复启动 +- 在启动新任务前,先检查是否已有任务在运行 +- 如果要修改时间,先"停止"再重新"启动" + +### 2. 避免频繁手动触发 +- "立即运行"功能会立即执行分析并发送通知 +- 避免在5分钟内多次点击 + +### 3. 查看日志 +运行时在控制台查看日志输出,确认: +- 任务数量是否正确 +- 是否有重复执行的警告 +- 通知发送次数是否正确 + +## 📚 技术要点 + +### 任务标签体系 +- `sector_strategy` - 智策定时分析 +- `portfolio_analysis` - 持仓定时分析 +- `monitor` - 实时监测定时功能 + +### 锁机制 +使用 `threading.Lock()` 的非阻塞获取: +- `acquire(blocking=False)` - 如果锁已被占用,立即返回False +- 保证同一时刻只有一个分析任务在执行 + +### 时间去重 +- 记录 `last_notification_time` +- 每次发送前检查时间差 +- 5分钟(300秒)内的重复请求直接跳过 + +## ✅ 修复清单 + +- [x] 智策调度器添加并发保护锁 +- [x] 智策调度器添加通知去重机制(5分钟) +- [x] 智策调度器改进任务清除逻辑 +- [x] 持仓调度器使用标签化任务管理 +- [x] 持仓调度器避免清除其他模块任务 +- [x] 添加详细日志输出 +- [x] 创建测试方案 +- [x] 编写修复说明文档 + +## 🎯 预期效果 + +修复后,智策定时分析应该: +1. ✅ 每次只发送 **1封** 邮件 +2. ✅ 每次只发送 **1次** 钉钉通知 +3. ✅ 不会受到持仓定时分析的影响 +4. ✅ 即使误操作多次启动,也有去重保护 +5. ✅ 日志清晰,便于排查问题 + +--- + +**修复日期**:2025-10-21 +**修复版本**:v1.1.0 +**修复文件**: +- `sector_strategy_scheduler.py` ✅ +- `portfolio_scheduler.py` ✅ + +**下次执行验证**:2025-10-22 08:15:00 +