Skip to content

Commit d8a067b

Browse files
authored
feat: improve first-run configuration validation (#1558)
* feat: improve first-run configuration validation * fix: clarify invalid LLM channel validation
1 parent 3ea007c commit d8a067b

6 files changed

Lines changed: 205 additions & 21 deletions

File tree

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
99
1010
## [Unreleased]
1111

12+
- [改进] 首次运行配置校验补充缺失 AI Key、空 STOCK_LIST、Telegram/邮件成对字段和 Webhook URL 前缀诊断。
1213
- [改进] AlphaSift 选股入口在 Web 侧边栏中移动到“问股”下方,贴近 Agent/研究辅助工作流。
1314
- [改进] Docker 镜像构建阶段预置默认 AlphaSift 适配层,与桌面发布包一样避免运行期额外安装。
1415
- [新功能] 新增默认关闭的 AlphaSift 选股页签,通过 `ALPHASIFT_ENABLED` 开启后经由稳定适配层读取策略并执行选股。

docs/full-guide.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ daily_stock_analysis/
6565
| `OPENAI_BASE_URL` | OpenAI 兼容 API 地址(如 `https://api.deepseek.com`| 可选 |
6666
| `OPENAI_MODEL` | 模型名称(如 `gemini-3.1-pro-preview``deepseek-v4-flash``gpt-5.5`| 可选 |
6767

68-
> *注:以上模型 Key / 渠道至少配置一个;推荐优先从 Anspire 或 AIHubMix 这类一 Key 多模型服务开始。
68+
> *注:以上模型 Key / 渠道至少配置一个;推荐优先从 Anspire 或 AIHubMix 这类一 Key 多模型服务开始。启动时配置校验会在缺少可用 AI 模型 Key 或模型渠道时给出明确错误提示。
6969
7070
#### 通知渠道配置(可同时配置多个,全部推送)
7171

@@ -104,7 +104,7 @@ daily_stock_analysis/
104104
| `CUSTOM_WEBHOOK_BODY_TEMPLATE` | 自定义 Webhook JSON body 模板,适配 AstrBot、NapCat、自建服务等特殊 payload | 可选 |
105105
| `WEBHOOK_VERIFY_SSL` | 读取该配置的 webhook-style HTTPS 通知请求证书校验(默认 true)。设为 false 可支持自签名证书。警告:关闭有严重安全风险(MITM),仅限可信内网 | 可选 |
106106

107-
> *注:至少配置一个渠道,配置多个则同时推送
107+
> *注:至少配置一个渠道,配置多个则同时推送。启动时配置校验会提示 Telegram / 邮件成对字段缺失,以及常见 Webhook URL 未以 `http://``https://` 开头的问题。
108108
>
109109
> 当前默认 `00-daily-analysis.yml` 只显式映射固定 Secret / Variable 名称,不会自动把 `STOCK_GROUP_1``EMAIL_GROUP_1` 这类任意编号变量导入运行环境。所以分组邮箱功能目前不适用于仓库自带默认 GitHub Actions workflow;它适用于本地 `.env`、Docker,或你自行显式扩展过 `env:` 映射的运行环境。Actions 已显式映射 `CUSTOM_WEBHOOK_BODY_TEMPLATE``WEBHOOK_VERIFY_SSL``FEISHU_WEBHOOK_SECRET``FEISHU_WEBHOOK_KEYWORD``PUSHPLUS_TOPIC``NTFY_URL``NTFY_TOKEN``GOTIFY_URL``GOTIFY_TOKEN`、P3 通知路由键以及 P4 通知降噪键;`MARKDOWN_TO_IMAGE_CHANNELS``MERGE_EMAIL_NOTIFICATION` 仍作为行为开关不在默认 workflow 中自动映射。
110110

docs/full-guide_EN.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ Go to your forked repo → `Settings` → `Secrets and variables` → `Actions`
6565
| `OPENAI_BASE_URL` | OpenAI-compatible API endpoint (e.g., `https://api.deepseek.com`) | Optional |
6666
| `OPENAI_MODEL` | Model name (e.g., `deepseek-v4-flash`) | Optional |
6767

68-
> *Note: Configure at least one model key or channel. Anspire or AIHubMix is the simplest starting point for one-key multi-model access.
68+
> *Note: Configure at least one model key or channel. Anspire or AIHubMix is the simplest starting point for one-key multi-model access. Startup validation reports a clear error when no usable AI model key or model channel is configured.
6969
7070
#### Notification Channels (Multiple can be configured, all will receive notifications)
7171

@@ -105,7 +105,7 @@ Go to your forked repo → `Settings` → `Secrets and variables` → `Actions`
105105
| `CUSTOM_WEBHOOK_BODY_TEMPLATE` | Custom Webhook JSON body template for AstrBot, NapCat, or self-hosted services with special payloads | Optional |
106106
| `WEBHOOK_VERIFY_SSL` | HTTPS certificate verification for webhook-style notification requests that read this setting (default true). Set to false for self-signed certs. WARNING: Disabling has serious security risk (MITM), use only on trusted internal networks | Optional |
107107

108-
> *Note: Configure at least one channel; multiple channels will all receive notifications
108+
> *Note: Configure at least one channel; multiple channels will all receive notifications. Startup validation reports missing paired Telegram / email fields and common Webhook URLs that do not start with `http://` or `https://`.
109109
>
110110
> The default `00-daily-analysis.yml` in this repository only exports fixed Secret / Variable names. Arbitrary numbered env vars such as `STOCK_GROUP_1` and `EMAIL_GROUP_1` are not auto-injected into the job, so grouped email routing is not available in the stock workflow unless you explicitly extend the workflow's `env:` mapping in your own fork. Actions now maps `CUSTOM_WEBHOOK_BODY_TEMPLATE`, `WEBHOOK_VERIFY_SSL`, `FEISHU_WEBHOOK_SECRET`, `FEISHU_WEBHOOK_KEYWORD`, `PUSHPLUS_TOPIC`, `NTFY_URL`, `NTFY_TOKEN`, `GOTIFY_URL`, `GOTIFY_TOKEN`, the P3 notification route keys, and the P4 notification noise-control keys; `MARKDOWN_TO_IMAGE_CHANNELS` and `MERGE_EMAIL_NOTIFICATION` remain behavior toggles outside the default workflow mapping.
111111

src/config.py

Lines changed: 84 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -651,6 +651,9 @@ class Config:
651651
llm_models_source: str = "legacy_env"
652652
# LLM_CHANNELS: list of channel dicts, each with name/base_url/api_keys/models
653653
llm_channels: List[Dict[str, Any]] = field(default_factory=list)
654+
# Raw channel names requested through LLM_CHANNELS, including channels that
655+
# were skipped during parsing because required channel fields were missing.
656+
llm_channel_names: List[str] = field(default_factory=list)
654657
# Pre-built LiteLLM Router model_list (populated from channels, YAML, or legacy keys)
655658
llm_model_list: List[Dict[str, Any]] = field(default_factory=list)
656659

@@ -1122,10 +1125,6 @@ def _load_from_env(cls) -> 'Config':
11221125
if (c or "").strip()
11231126
]
11241127

1125-
# 如果没有配置,使用默认的示例股票
1126-
if not stock_list:
1127-
stock_list = ['600519', '000001', '300750']
1128-
11291128
# === LiteLLM multi-key parsing ===
11301129
# GEMINI_API_KEYS (comma-separated) > GEMINI_API_KEY (single)
11311130
_gemini_keys_raw = os.getenv('GEMINI_API_KEYS', '')
@@ -1235,6 +1234,7 @@ def _load_from_env(cls) -> 'Config':
12351234
litellm_config_path = os.getenv('LITELLM_CONFIG', '').strip() or None
12361235
llm_models_source = "legacy_env"
12371236
llm_channels: List[Dict[str, Any]] = []
1237+
llm_channel_names: List[str] = []
12381238
llm_model_list: List[Dict[str, Any]] = []
12391239

12401240
# Priority 1: LITELLM_CONFIG (standard LiteLLM YAML config file)
@@ -1247,6 +1247,11 @@ def _load_from_env(cls) -> 'Config':
12471247
if not llm_model_list:
12481248
_channels_str = os.getenv('LLM_CHANNELS', '').strip()
12491249
if _channels_str:
1250+
llm_channel_names = [
1251+
ch.strip().lower()
1252+
for ch in _channels_str.split(',')
1253+
if ch.strip()
1254+
]
12501255
llm_channels = cls._parse_llm_channels(_channels_str)
12511256
llm_model_list = cls._channels_to_model_list(llm_channels)
12521257
if llm_model_list:
@@ -1435,6 +1440,7 @@ def _load_from_env(cls) -> 'Config':
14351440
litellm_config_path=litellm_config_path,
14361441
llm_models_source=llm_models_source,
14371442
llm_channels=llm_channels,
1443+
llm_channel_names=llm_channel_names,
14381444
llm_model_list=llm_model_list,
14391445
gemini_api_keys=gemini_api_keys,
14401446
anthropic_api_keys=anthropic_api_keys,
@@ -2376,9 +2382,6 @@ def refresh_stock_list(self) -> None:
23762382
if (c or "").strip()
23772383
]
23782384

2379-
if not stock_list:
2380-
stock_list = ['000001']
2381-
23822385
self.stock_list = stock_list
23832386

23842387
def validate_structured(self) -> List[ConfigIssue]:
@@ -2400,7 +2403,7 @@ def validate_structured(self) -> List[ConfigIssue]:
24002403
if not self.stock_list:
24012404
issues.append(ConfigIssue(
24022405
severity="error",
2403-
message="未配置自选股列表 (STOCK_LIST)",
2406+
message="未配置 STOCK_LIST。请设置至少一个股票代码,例如:600519,hk00700,AAPL。",
24042407
field="STOCK_LIST",
24052408
))
24062409
elif self.stock_email_groups:
@@ -2449,14 +2452,36 @@ def validate_structured(self) -> List[ConfigIssue]:
24492452
# direct litellm env path and therefore do not populate llm_model_list.
24502453
has_direct_env_model = bool(self.litellm_model) and _uses_direct_env_provider(self.litellm_model)
24512454
if not self.llm_model_list and not has_direct_env_model:
2452-
issues.append(ConfigIssue(
2453-
severity="error",
2454-
message=(
2455-
"未配置任何可用的 AI 模型接入(高级模型路由配置 / 渠道 / API Key),"
2456-
"AI 分析功能将不可用"
2457-
),
2458-
field="LITELLM_CONFIG",
2459-
))
2455+
if self.litellm_config_path:
2456+
issues.append(ConfigIssue(
2457+
severity="error",
2458+
message=(
2459+
"已配置 LITELLM_CONFIG,但未解析出可用模型。"
2460+
"请检查 YAML 中的 model_list、litellm_params 和环境变量引用。"
2461+
),
2462+
field="LITELLM_CONFIG",
2463+
))
2464+
elif self.llm_channel_names:
2465+
issues.append(ConfigIssue(
2466+
severity="error",
2467+
message=(
2468+
"已配置 LLM_CHANNELS,但未解析出可用模型渠道。"
2469+
"请检查对应 LLM_<CHANNEL>_API_KEY(S)、"
2470+
"LLM_<CHANNEL>_MODELS、LLM_<CHANNEL>_PROTOCOL 或 Base URL。"
2471+
),
2472+
field="LLM_CHANNELS",
2473+
))
2474+
else:
2475+
issues.append(ConfigIssue(
2476+
severity="error",
2477+
message=(
2478+
"未配置任何可用的 AI 模型接入。请至少配置 ANSPIRE_API_KEYS、"
2479+
"AIHUBMIX_KEY、GEMINI_API_KEY、ANTHROPIC_API_KEY、"
2480+
"OPENAI_API_KEY 或 DEEPSEEK_API_KEY 中的一个,或配置 "
2481+
"LITELLM_CONFIG / LLM_CHANNELS 可用模型渠道。"
2482+
),
2483+
field="LITELLM_CONFIG",
2484+
))
24602485
elif not self.litellm_model:
24612486
issues.append(ConfigIssue(
24622487
severity="info",
@@ -2597,6 +2622,49 @@ def _has_runtime_source_for_model(model: str) -> bool:
25972622
field="WECHAT_WEBHOOK_URL",
25982623
))
25992624

2625+
has_telegram_token = bool((self.telegram_bot_token or "").strip())
2626+
has_telegram_chat_id = bool((self.telegram_chat_id or "").strip())
2627+
if has_telegram_token != has_telegram_chat_id:
2628+
issues.append(ConfigIssue(
2629+
severity="error",
2630+
message="Telegram 通知配置不完整:TELEGRAM_BOT_TOKEN 和 TELEGRAM_CHAT_ID 必须同时配置。",
2631+
field="TELEGRAM_CHAT_ID" if has_telegram_token else "TELEGRAM_BOT_TOKEN",
2632+
))
2633+
2634+
has_email_sender = bool((self.email_sender or "").strip())
2635+
has_email_password = bool((self.email_password or "").strip())
2636+
if has_email_sender != has_email_password:
2637+
issues.append(ConfigIssue(
2638+
severity="error",
2639+
message="邮件通知配置不完整:EMAIL_SENDER 和 EMAIL_PASSWORD 必须同时配置。",
2640+
field="EMAIL_PASSWORD" if has_email_sender else "EMAIL_SENDER",
2641+
))
2642+
2643+
def _warn_if_webhook_url_invalid(field: str, value: Optional[str]) -> None:
2644+
raw_url = (value or "").strip()
2645+
if not raw_url:
2646+
return
2647+
parsed = urlparse(raw_url)
2648+
if parsed.scheme.lower() in {"http", "https"} and parsed.netloc:
2649+
return
2650+
issues.append(ConfigIssue(
2651+
severity="warning",
2652+
message=f"{field} 看起来不是有效 URL,请确认是否以 http:// 或 https:// 开头。",
2653+
field=field,
2654+
))
2655+
2656+
for field, value in (
2657+
("WECHAT_WEBHOOK_URL", self.wechat_webhook_url),
2658+
("FEISHU_WEBHOOK_URL", self.feishu_webhook_url),
2659+
("DISCORD_WEBHOOK_URL", self.discord_webhook_url),
2660+
("SLACK_WEBHOOK_URL", self.slack_webhook_url),
2661+
("ASTRBOT_URL", self.astrbot_url),
2662+
):
2663+
_warn_if_webhook_url_invalid(field, value)
2664+
2665+
for custom_url in self.custom_webhook_urls:
2666+
_warn_if_webhook_url_invalid("CUSTOM_WEBHOOK_URLS", custom_url)
2667+
26002668
if self.ntfy_url and not _has_ntfy_topic_endpoint(self.ntfy_url):
26012669
issues.append(ConfigIssue(
26022670
severity="error",

tests/test_config_env_compat.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,21 @@ def test_runtime_mutable_keys_use_process_env_when_absent_from_file(
431431

432432
self.assertEqual(config.stock_list, ["600519", "000001"])
433433

434+
def test_refresh_stock_list_preserves_empty_required_config(self) -> None:
435+
with tempfile.TemporaryDirectory() as temp_dir:
436+
env_path = Path(temp_dir) / ".env"
437+
env_path.write_text("STOCK_LIST=\n", encoding="utf-8")
438+
439+
config = Config(stock_list=["600519"])
440+
with patch.dict(os.environ, {"ENV_FILE": str(env_path)}, clear=True):
441+
config.refresh_stock_list()
442+
443+
self.assertEqual(config.stock_list, [])
444+
issues = config.validate_structured()
445+
self.assertTrue(
446+
any(issue.severity == "error" and issue.field == "STOCK_LIST" for issue in issues)
447+
)
448+
434449
def test_parse_report_language_accepts_known_alias_without_warning(self) -> None:
435450
with self.assertNoLogs("src.config", level="WARNING"):
436451
parsed = Config._parse_report_language("zh-cn")

tests/test_config_validate_structured.py

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,10 @@ def test_empty_stock_list_is_error(self):
118118
cfg = _make_config(stock_list=[])
119119
issues = cfg.validate_structured()
120120
errors = [i for i in issues if i.severity == "error"]
121-
assert any("STOCK_LIST" in i.field for i in errors)
121+
stock_errors = [i for i in errors if i.field == "STOCK_LIST"]
122+
assert stock_errors
123+
assert "未配置 STOCK_LIST" in stock_errors[0].message
124+
assert "600519,hk00700,AAPL" in stock_errors[0].message
122125

123126
def test_configured_stock_list_no_stock_error(self):
124127
cfg = _make_config(stock_list=["600519", "000001"])
@@ -188,6 +191,48 @@ def test_no_llm_is_error(self):
188191
issues = cfg.validate_structured()
189192
assert any(i.severity == "error" and "AI 模型" in i.message for i in issues)
190193

194+
def test_validate_missing_all_llm_keys_reports_error(self):
195+
cfg = _make_config(
196+
llm_model_list=[],
197+
litellm_model="",
198+
gemini_api_keys=[],
199+
anthropic_api_keys=[],
200+
openai_api_keys=[],
201+
deepseek_api_keys=[],
202+
anspire_api_keys=[],
203+
)
204+
205+
issues = cfg.validate_structured()
206+
207+
error = next(i for i in issues if i.severity == "error" and i.field == "LITELLM_CONFIG")
208+
assert "未配置任何可用的 AI 模型接入" in error.message
209+
assert "ANSPIRE_API_KEYS" in error.message
210+
assert "DEEPSEEK_API_KEY" in error.message
211+
212+
@patch("src.config.setup_env")
213+
@patch.object(Config, "_parse_litellm_yaml", return_value=[])
214+
def test_declared_llm_channels_without_models_reports_channel_error(
215+
self,
216+
_mock_parse_yaml,
217+
_mock_setup_env,
218+
):
219+
with patch.dict(
220+
"os.environ",
221+
{
222+
"LLM_CHANNELS": "primary",
223+
"LLM_PRIMARY_API_KEY": "sk-primary-test-value",
224+
},
225+
clear=True,
226+
):
227+
cfg = Config._load_from_env()
228+
229+
issues = cfg.validate_structured()
230+
231+
error = next(i for i in issues if i.severity == "error" and i.field == "LLM_CHANNELS")
232+
assert "已配置 LLM_CHANNELS" in error.message
233+
assert "LLM_<CHANNEL>_MODELS" in error.message
234+
assert not any(i.severity == "error" and i.field == "ANSPIRE_API_KEYS" for i in issues)
235+
191236
def test_llm_channels_only_no_error(self):
192237
"""LLM_CHANNELS populated via llm_model_list must NOT trigger an error.
193238
@@ -337,6 +382,61 @@ def test_notification_configured_no_warning(self):
337382
issues = cfg.validate_structured()
338383
assert not any(i.severity == "warning" and "通知渠道" in i.message for i in issues)
339384

385+
@pytest.mark.parametrize(
386+
("kwargs", "missing_field"),
387+
[
388+
({"telegram_bot_token": "bot-token", "telegram_chat_id": None}, "TELEGRAM_CHAT_ID"),
389+
({"telegram_bot_token": None, "telegram_chat_id": "123456"}, "TELEGRAM_BOT_TOKEN"),
390+
],
391+
)
392+
def test_validate_incomplete_telegram_config_reports_error(self, kwargs, missing_field):
393+
cfg = _make_config(**kwargs)
394+
issues = cfg.validate_structured()
395+
396+
assert any(
397+
i.severity == "error"
398+
and i.field == missing_field
399+
and "Telegram 通知配置不完整" in i.message
400+
for i in issues
401+
)
402+
403+
@pytest.mark.parametrize(
404+
("kwargs", "missing_field"),
405+
[
406+
({"email_sender": "sender@example.com", "email_password": None}, "EMAIL_PASSWORD"),
407+
({"email_sender": None, "email_password": "app-password"}, "EMAIL_SENDER"),
408+
],
409+
)
410+
def test_validate_incomplete_email_config_reports_error(self, kwargs, missing_field):
411+
cfg = _make_config(**kwargs)
412+
issues = cfg.validate_structured()
413+
414+
assert any(
415+
i.severity == "error"
416+
and i.field == missing_field
417+
and "邮件通知配置不完整" in i.message
418+
for i in issues
419+
)
420+
421+
@pytest.mark.parametrize(
422+
("field", "kwargs"),
423+
[
424+
("WECHAT_WEBHOOK_URL", {"wechat_webhook_url": "abc"}),
425+
("FEISHU_WEBHOOK_URL", {"feishu_webhook_url": "xxx"}),
426+
("DISCORD_WEBHOOK_URL", {"discord_webhook_url": "test"}),
427+
],
428+
)
429+
def test_validate_invalid_webhook_url_reports_warning(self, field, kwargs):
430+
cfg = _make_config(**kwargs)
431+
issues = cfg.validate_structured()
432+
433+
assert any(
434+
i.severity == "warning"
435+
and i.field == field
436+
and "http:// 或 https://" in i.message
437+
for i in issues
438+
)
439+
340440
def test_astrbot_url_counts_as_notification_channel(self):
341441
cfg = _make_config(
342442
wechat_webhook_url=None,

0 commit comments

Comments
 (0)