diff --git a/backend/.env b/backend/.env index 1d08304f..d78762ec 100644 --- a/backend/.env +++ b/backend/.env @@ -1,4 +1,7 @@ ASTOCK_TUSHARE_TOKEN=0ed6419a00d8923dc19c0b58fc92d94c9a0696949ab91a13aa58a0cc ASTOCK_DEBUG=true -ASTOCK_DEEPSEEK_API_KEY=sk-9f6b56f08796435d988cf202e37f6ee3 \ No newline at end of file +ASTOCK_DEEPSEEK_API_KEY=sk-9f6b56f08796435d988cf202e37f6ee3 +ASTOCK_ALERT_ENABLED=true +ASTOCK_FEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/6307668f-10aa-4fc1-8c1e-bad1b6b78d4d +ASTOCK_ALERT_ENVIRONMENT=local diff --git a/backend/app/__pycache__/config.cpython-313.pyc b/backend/app/__pycache__/config.cpython-313.pyc index 57859c90..bc830f60 100644 Binary files a/backend/app/__pycache__/config.cpython-313.pyc and b/backend/app/__pycache__/config.cpython-313.pyc differ diff --git a/backend/app/__pycache__/main.cpython-313.pyc b/backend/app/__pycache__/main.cpython-313.pyc index 82e9880d..414bbbe9 100644 Binary files a/backend/app/__pycache__/main.cpython-313.pyc and b/backend/app/__pycache__/main.cpython-313.pyc differ diff --git a/backend/app/analysis/__pycache__/intraday.cpython-313.pyc b/backend/app/analysis/__pycache__/intraday.cpython-313.pyc index 2654aa7b..666c13d0 100644 Binary files a/backend/app/analysis/__pycache__/intraday.cpython-313.pyc and b/backend/app/analysis/__pycache__/intraday.cpython-313.pyc differ diff --git a/backend/app/analysis/intraday.py b/backend/app/analysis/intraday.py index f0f4896e..0a9aebf0 100644 --- a/backend/app/analysis/intraday.py +++ b/backend/app/analysis/intraday.py @@ -20,7 +20,7 @@ from datetime import datetime from app.data.tushare_client import tushare_client from app.data import tencent_client from app.data.models import SectorInfo, Recommendation, MarketTemperature, StockQuote -from app.data.eastmoney_client import SECTOR_LIST_URL, SECTOR_HEADERS +from app.data.eastmoney_client import SECTOR_LIST_URL, SECTOR_HEADERS, _parse_eastmoney_json from app.analysis.sector_scanner import scan_hot_sectors from app.analysis.technical import add_all_indicators from app.analysis.signals import generate_signals @@ -68,7 +68,7 @@ async def intraday_market_temperature(prev_temp: MarketTemperature) -> MarketTem ("m:0+t:6,m:0+t:80,m:0+t:81+s:2048", 9.9), # 主板 10% ("m:1+t:2,m:1+t:23", 19.9), # 创业板/科创板 20% ]: - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(follow_redirects=True) as client: # 涨停:按涨幅降序取 top 200 params_up = { "pn": "1", "pz": "200", "po": "1", "np": "1", @@ -77,7 +77,8 @@ async def intraday_market_temperature(prev_temp: MarketTemperature) -> MarketTem "fields": "f3,f12,f14", } resp = await client.get(SECTOR_LIST_URL, params=params_up, headers=SECTOR_HEADERS, timeout=10) - items = resp.json().get("data", {}).get("diff", []) if resp.json().get("data") else [] + data_up = _parse_eastmoney_json(resp, "涨停统计") + items = data_up.get("data", {}).get("diff", []) if data_up.get("data") else [] for item in items: pct = item.get("f3") if pct == "-" or pct is None: @@ -93,7 +94,8 @@ async def intraday_market_temperature(prev_temp: MarketTemperature) -> MarketTem "fields": "f3,f12,f14", } resp_down = await client.get(SECTOR_LIST_URL, params=params_down, headers=SECTOR_HEADERS, timeout=10) - items_down = resp_down.json().get("data", {}).get("diff", []) if resp_down.json().get("data") else [] + data_down = _parse_eastmoney_json(resp_down, "跌停统计") + items_down = data_down.get("data", {}).get("diff", []) if data_down.get("data") else [] neg_threshold = -threshold for item in items_down: pct = item.get("f3") diff --git a/backend/app/api/__pycache__/chat.cpython-313.pyc b/backend/app/api/__pycache__/chat.cpython-313.pyc index 5acba5d3..fac88c92 100644 Binary files a/backend/app/api/__pycache__/chat.cpython-313.pyc and b/backend/app/api/__pycache__/chat.cpython-313.pyc differ diff --git a/backend/app/api/__pycache__/stocks.cpython-313.pyc b/backend/app/api/__pycache__/stocks.cpython-313.pyc index 12c28f24..e55885e9 100644 Binary files a/backend/app/api/__pycache__/stocks.cpython-313.pyc and b/backend/app/api/__pycache__/stocks.cpython-313.pyc differ diff --git a/backend/app/api/chat.py b/backend/app/api/chat.py index 9707c7f8..38179f13 100644 --- a/backend/app/api/chat.py +++ b/backend/app/api/chat.py @@ -5,12 +5,14 @@ POST /api/chat/stream - SSE 流式对话 import json import logging +import traceback from fastapi import APIRouter, Depends from fastapi.responses import StreamingResponse from pydantic import BaseModel from app.core.deps import get_current_user +from app.db.error_logger import log_error from app.llm.chat_agent import chat_stream logger = logging.getLogger(__name__) @@ -39,6 +41,12 @@ async def chat_stream_endpoint(req: ChatRequest, current_user: dict = Depends(ge yield "data: [DONE]\n\n" except Exception as e: logger.error(f"Chat stream error: {e}") + await log_error( + "chat", + f"Chat stream error: {e}", + detail=traceback.format_exc(), + context={"method": "POST", "path": "/api/chat/stream"}, + ) error_data = json.dumps( {"type": "content", "content": f"出错了: {e}"}, ensure_ascii=False, diff --git a/backend/app/api/stocks.py b/backend/app/api/stocks.py index e8d00083..372e437d 100644 --- a/backend/app/api/stocks.py +++ b/backend/app/api/stocks.py @@ -304,6 +304,7 @@ async def get_diagnose_history(ts_code: str): return history except Exception as e: logger.error(f"获取诊断历史失败: {e}") + await log_error("stocks", f"获取诊断历史失败: {e}", detail=traceback.format_exc()) return [] @@ -648,6 +649,12 @@ async def diagnose_stock(ts_code: str, mode: str = Query("entry")): except Exception as e: error_msg = str(e) logger.error(f"诊断流式调用失败: {error_msg}") + await log_error( + "stocks", + f"诊断流式调用失败: {error_msg}", + detail=traceback.format_exc(), + context={"method": "POST", "path": f"/api/stocks/{ts_code}/diagnose"}, + ) yield f"data: {json.dumps({'error': error_msg}, ensure_ascii=False)}\n\n" yield f"data: {json.dumps({'done': True, 'ts_code': ts_code}, ensure_ascii=False)}\n\n" diff --git a/backend/app/config.py b/backend/app/config.py index e730b439..fea0d7ee 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -59,6 +59,14 @@ class Settings(BaseSettings): llm_max_tokens: int = 2000 llm_temperature: float = 0.3 + # 告警(Feishu / Lark Incoming Webhook) + alert_enabled: bool = False + feishu_webhook_url: str = "" + alert_dedup_ttl_seconds: int = 300 + alert_max_detail_chars: int = 1200 + alert_app_name: str = "AStock Agent" + alert_environment: str = "local" + # 前端 frontend_url: str = "http://localhost:3002" diff --git a/backend/app/data/__pycache__/tencent_client.cpython-313.pyc b/backend/app/data/__pycache__/tencent_client.cpython-313.pyc index 83686a71..49b53eef 100644 Binary files a/backend/app/data/__pycache__/tencent_client.cpython-313.pyc and b/backend/app/data/__pycache__/tencent_client.cpython-313.pyc differ diff --git a/backend/app/data/__pycache__/tushare_client.cpython-313.pyc b/backend/app/data/__pycache__/tushare_client.cpython-313.pyc index 6c2da9d6..d91ba0d6 100644 Binary files a/backend/app/data/__pycache__/tushare_client.cpython-313.pyc and b/backend/app/data/__pycache__/tushare_client.cpython-313.pyc differ diff --git a/backend/app/data/eastmoney_client.py b/backend/app/data/eastmoney_client.py index 4f935be7..7b8dd70c 100644 --- a/backend/app/data/eastmoney_client.py +++ b/backend/app/data/eastmoney_client.py @@ -14,18 +14,19 @@ from datetime import datetime from app.data.cache import cache from app.config import settings +from app.db.error_logger import log_error logger = logging.getLogger(__name__) # 东方财富接口 -EASTMONEY_KLINE_URL = "http://push2his.eastmoney.com/api/qt/stock/kline/get" -SECTOR_LIST_URL = "http://push2.eastmoney.com/api/qt/clist/get" +EASTMONEY_KLINE_URL = "https://push2his.eastmoney.com/api/qt/stock/kline/get" +SECTOR_LIST_URL = "https://push2.eastmoney.com/api/qt/clist/get" HEADERS = { - "Referer": "http://finance.eastmoney.com", + "Referer": "https://finance.eastmoney.com", "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36", } SECTOR_HEADERS = { - "Referer": "http://data.eastmoney.com", + "Referer": "https://data.eastmoney.com", "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36", } @@ -95,8 +96,9 @@ async def get_sector_realtime_ranking( params=params, headers=SECTOR_HEADERS, timeout=10, + follow_redirects=True, ) - data = resp.json() + data = _parse_eastmoney_json(resp, "板块实时排名") items = data.get("data", {}).get("diff", []) if not items: @@ -130,6 +132,11 @@ async def get_sector_realtime_ranking( except Exception as e: logger.error(f"东方财富板块实时排名获取失败: {e}") + await log_error( + "eastmoney", + f"东方财富板块实时排名获取失败: {e}", + detail=f"fs={fs}, sort_by={sort_by}, page_size={page_size}", + ) return [] @@ -174,8 +181,9 @@ async def get_min_kline( params=params, headers=HEADERS, timeout=10, + follow_redirects=True, ) - data = resp.json() + data = _parse_eastmoney_json(resp, f"分钟K线 {ts_code}") klines = data.get("data", {}).get("klines", []) if not klines: @@ -209,9 +217,31 @@ async def get_min_kline( except Exception as e: logger.error(f"东方财富分钟K线获取失败 {ts_code}: {e}") + await log_error( + "eastmoney", + f"东方财富分钟K线获取失败 {ts_code}: {e}", + detail=f"period={period}, count={count}", + ) return pd.DataFrame() +def _parse_eastmoney_json(resp: httpx.Response, label: str) -> dict: + """解析东方财富 JSON 响应,遇到 302/HTML 等非 JSON 情况给出更清晰日志。""" + resp.raise_for_status() + content_type = resp.headers.get("content-type", "") + text_preview = (resp.text or "")[:160].replace("\n", " ").replace("\r", " ") + if "json" not in content_type.lower() and not resp.text.strip().startswith("{"): + raise ValueError( + f"{label} 返回非JSON响应(status={resp.status_code}, content_type={content_type}, body={text_preview})" + ) + try: + return resp.json() + except Exception as e: + raise ValueError( + f"{label} JSON解析失败(status={resp.status_code}, content_type={content_type}, body={text_preview})" + ) from e + + def analyze_intraday_volume_distribution(min_df: pd.DataFrame) -> dict: """分析盘中量能分布(基于5分钟K线) @@ -300,4 +330,4 @@ def _is_trading_hours() -> bool: return True if (hour == 13) or (hour == 14) or (hour == 15 and minute == 0): return True - return False \ No newline at end of file + return False diff --git a/backend/app/data/tencent_client.py b/backend/app/data/tencent_client.py index 9750e9f8..8b990d00 100644 --- a/backend/app/data/tencent_client.py +++ b/backend/app/data/tencent_client.py @@ -9,6 +9,7 @@ import httpx from app.data.cache import cache from app.config import settings from app.data.models import StockQuote +from app.db.error_logger import log_error logger = logging.getLogger(__name__) @@ -109,6 +110,7 @@ async def get_realtime_quote(ts_code: str) -> StockQuote | None: return quote except Exception as e: logger.error(f"腾讯行情获取失败 {ts_code}: {e}") + await log_error("tencent", f"腾讯行情获取失败 {ts_code}: {e}") return None @@ -172,6 +174,11 @@ async def get_realtime_quotes_batch(ts_codes: list[str]) -> dict[str, StockQuote results[ts_code] = quote except Exception as e: logger.error(f"腾讯批量行情获取失败: {e}") + await log_error( + "tencent", + f"腾讯批量行情获取失败: {e}", + detail=f"batch_size={len(batch)}", + ) return results @@ -217,5 +224,10 @@ async def get_index_realtime(index_codes: list[str] = None) -> dict[str, dict]: } except Exception as e: logger.error(f"腾讯指数行情获取失败: {e}") + await log_error( + "tencent", + f"腾讯指数行情获取失败: {e}", + detail=f"indices={','.join(index_codes)}", + ) return results diff --git a/backend/app/data/tushare_client.py b/backend/app/data/tushare_client.py index 3fa70d5c..178e07c1 100644 --- a/backend/app/data/tushare_client.py +++ b/backend/app/data/tushare_client.py @@ -11,6 +11,7 @@ from datetime import datetime, timedelta from app.config import settings from app.data.cache import cache +from app.db.error_logger import log_error_background logger = logging.getLogger(__name__) @@ -51,6 +52,10 @@ class TushareClient: time.sleep((2 ** attempt) * 1) else: logger.error(f"Tushare 请求最终失败: {e}") + log_error_background( + "tushare", + f"Tushare 请求最终失败: {e}", + ) return pd.DataFrame() return pd.DataFrame() diff --git a/backend/app/db/error_logger.py b/backend/app/db/error_logger.py index 4e914a6e..cfabf8b1 100644 --- a/backend/app/db/error_logger.py +++ b/backend/app/db/error_logger.py @@ -1,13 +1,22 @@ """错误日志持久化""" +import asyncio import traceback from datetime import datetime from app.db.database import get_db from app.db import tables +from app.notifications.feishu import send_feishu_alert -async def log_error(source: str, message: str, detail: str = "", level: str = "error"): - """将错误写入数据库,失败时静默(不影响主流程)""" +async def log_error( + source: str, + message: str, + detail: str = "", + level: str = "error", + context: dict | None = None, + notify: bool = True, +): + """将错误写入数据库,并按策略发送告警。""" try: async with get_db() as db: stmt = tables.error_logs_table.insert().values( @@ -20,4 +29,41 @@ async def log_error(source: str, message: str, detail: str = "", level: str = "e await db.execute(stmt) await db.commit() except Exception: - pass # 写日志失败不应影响主业务 \ No newline at end of file + pass # 写日志失败不应影响主业务 + + if notify and level.lower() in {"error", "critical"}: + try: + await send_feishu_alert( + source=source, + message=message, + detail=detail, + level=level, + context=context, + ) + except Exception: + pass + + +def log_error_background( + source: str, + message: str, + detail: str = "", + level: str = "error", + context: dict | None = None, + notify: bool = True, +): + """在存在事件循环时后台投递错误记录。""" + try: + loop = asyncio.get_running_loop() + loop.create_task( + log_error( + source=source, + message=message, + detail=detail, + level=level, + context=context, + notify=notify, + ) + ) + except RuntimeError: + pass diff --git a/backend/app/llm/__pycache__/client.cpython-313.pyc b/backend/app/llm/__pycache__/client.cpython-313.pyc index 931d0c33..c3747eb4 100644 Binary files a/backend/app/llm/__pycache__/client.cpython-313.pyc and b/backend/app/llm/__pycache__/client.cpython-313.pyc differ diff --git a/backend/app/llm/__pycache__/tool_executor.cpython-313.pyc b/backend/app/llm/__pycache__/tool_executor.cpython-313.pyc index f5a3f1d4..354fff11 100644 Binary files a/backend/app/llm/__pycache__/tool_executor.cpython-313.pyc and b/backend/app/llm/__pycache__/tool_executor.cpython-313.pyc differ diff --git a/backend/app/llm/batch_screener.py b/backend/app/llm/batch_screener.py index b7aeac70..36167405 100644 --- a/backend/app/llm/batch_screener.py +++ b/backend/app/llm/batch_screener.py @@ -10,6 +10,7 @@ import logging import re from app.config import settings +from app.db.error_logger import log_error logger = logging.getLogger(__name__) @@ -62,6 +63,11 @@ async def prefilter_single_stock(candidate: dict, market_summary: str) -> dict: return _parse_prefilter_response(content) except Exception as e: logger.error(f"LLM 预筛 {candidate.get('ts_code')} 失败: {e}") + await log_error( + "llm_prefilter", + f"LLM 预筛 {candidate.get('ts_code')} 失败: {e}", + detail=f"candidate={candidate.get('ts_code')}|{candidate.get('name', '')}", + ) return { "decision": "watch", "confidence": 5, @@ -135,6 +141,11 @@ async def analyze_single_stock(candidate: dict, market_summary: str) -> dict: except Exception as e: logger.error(f"LLM 分析 {candidate.get('ts_code')} 失败: {e}") + await log_error( + "llm_final", + f"LLM 分析 {candidate.get('ts_code')} 失败: {e}", + detail=f"candidate={candidate.get('ts_code')}|{candidate.get('name', '')}", + ) return { "verdict": "watch", "action_plan": "重点关注", diff --git a/backend/app/llm/client.py b/backend/app/llm/client.py index 546f7cb5..b5c74005 100644 --- a/backend/app/llm/client.py +++ b/backend/app/llm/client.py @@ -6,6 +6,7 @@ import logging from openai import AsyncOpenAI from app.config import settings +from app.db.error_logger import log_error logger = logging.getLogger(__name__) @@ -53,6 +54,11 @@ async def chat_completion( return resp.choices[0].message except Exception as e: logger.error(f"LLM 调用失败: {e}") + await log_error( + "llm", + f"LLM 调用失败: {e}", + detail=f"model={settings.deepseek_model}, tools={bool(tools)}", + ) return None @@ -86,3 +92,8 @@ async def stream_chat_completion( yield chunk.choices[0].delta except Exception as e: logger.error(f"LLM 流式调用失败: {e}") + await log_error( + "llm", + f"LLM 流式调用失败: {e}", + detail=f"model={settings.deepseek_model}, tools={bool(tools)}", + ) diff --git a/backend/app/llm/tool_executor.py b/backend/app/llm/tool_executor.py index 7bfc2419..9f5c9243 100644 --- a/backend/app/llm/tool_executor.py +++ b/backend/app/llm/tool_executor.py @@ -7,6 +7,8 @@ import json import logging import math +from app.db.error_logger import log_error + logger = logging.getLogger(__name__) _chat_user_context: dict | None = None @@ -50,6 +52,11 @@ async def execute_tool(name: str, arguments: dict) -> str: return json.dumps({"error": f"未知工具: {name}"}, ensure_ascii=False) except Exception as e: logger.error(f"工具执行失败 {name}: {e}") + await log_error( + "llm_tool", + f"工具执行失败 {name}: {e}", + detail=f"arguments={json.dumps(arguments, ensure_ascii=False, default=str)}", + ) return json.dumps({"error": str(e)}, ensure_ascii=False) @@ -272,4 +279,5 @@ async def _get_realtime_indices() -> str: }, ensure_ascii=False, default=str) except Exception as e: logger.error(f"获取实时指数失败: {e}") + await log_error("llm_tool", f"获取实时指数失败: {e}") return json.dumps({"error": f"获取指数数据失败: {e}"}, ensure_ascii=False) diff --git a/backend/app/main.py b/backend/app/main.py index b3c2a7b6..b8085ab9 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,11 +1,14 @@ """A 股分析推荐 Agent - FastAPI 入口""" import logging +import traceback from contextlib import asynccontextmanager -from fastapi import FastAPI +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse from fastapi.middleware.cors import CORSMiddleware from app.config import settings +from app.db.error_logger import log_error from app.db.database import init_db from app.engine.scheduler import start_scheduler, stop_scheduler from app.api import market, sectors, recommendations, stocks, watchlists, websocket, chat, auth, debug @@ -46,15 +49,25 @@ async def ensure_admin_exists(): async def lifespan(app: FastAPI): # 启动 logger.info("A 股分析推荐 Agent 启动中...") - await init_db() - logger.info("数据库初始化完成") - await ensure_admin_exists() - start_scheduler() - logger.info("调度器已启动") - yield - # 关闭 - stop_scheduler() - logger.info("服务已关闭") + try: + await init_db() + logger.info("数据库初始化完成") + await ensure_admin_exists() + start_scheduler() + logger.info("调度器已启动") + yield + except Exception as e: + logger.exception("应用生命周期异常") + await log_error( + "lifespan", + f"应用生命周期异常: {e}", + detail=traceback.format_exc(), + level="critical", + ) + raise + finally: + stop_scheduler() + logger.info("服务已关闭") app = FastAPI( @@ -87,6 +100,27 @@ app.include_router(debug.router) app.websocket("/ws")(websocket.ws_endpoint) +@app.exception_handler(Exception) +async def unhandled_exception_handler(request: Request, exc: Exception): + logger.exception("未处理的接口异常: %s %s", request.method, request.url.path) + query = str(request.url.query or "") + await log_error( + "asgi", + f"未处理的接口异常: {exc}", + detail=traceback.format_exc(), + level="error", + context={ + "method": request.method, + "path": request.url.path, + "query": query, + }, + ) + return JSONResponse( + status_code=500, + content={"detail": "服务器内部错误"}, + ) + + @app.get("/api/health") async def health(): return { diff --git a/backend/app/notifications/__init__.py b/backend/app/notifications/__init__.py new file mode 100644 index 00000000..3ca4a8b4 --- /dev/null +++ b/backend/app/notifications/__init__.py @@ -0,0 +1 @@ +"""通知模块""" diff --git a/backend/app/notifications/feishu.py b/backend/app/notifications/feishu.py new file mode 100644 index 00000000..71bc00a4 --- /dev/null +++ b/backend/app/notifications/feishu.py @@ -0,0 +1,93 @@ +"""Feishu/Lark 告警发送""" + +from __future__ import annotations + +import hashlib +import logging +from datetime import datetime +from zoneinfo import ZoneInfo + +import httpx + +from app.config import settings +from app.data.cache import cache + +logger = logging.getLogger(__name__) + + +def _build_signature( + source: str, + message: str, + level: str, + context: dict | None = None, +) -> str: + context = context or {} + basis = "|".join([ + source, + level, + message.strip(), + str(context.get("method", "")), + str(context.get("path", "")), + ]) + return hashlib.sha1(basis.encode("utf-8")).hexdigest() + + +def _truncate(text: str, limit: int) -> str: + text = (text or "").strip() + if len(text) <= limit: + return text + return f"{text[:limit]}..." + + +async def send_feishu_alert( + source: str, + message: str, + detail: str = "", + level: str = "error", + context: dict | None = None, +) -> bool: + """发送 Feishu 告警,内置去重,失败不抛异常。""" + if not settings.alert_enabled or not settings.feishu_webhook_url: + return False + + signature = _build_signature(source, message, level, context) + dedup_key = f"feishu_alert:{signature}" + if cache.get(dedup_key): + return False + + cache.set(dedup_key, True, settings.alert_dedup_ttl_seconds) + now = datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S") + context = context or {} + detail = _truncate(detail, settings.alert_max_detail_chars) + + lines = [ + f"[{settings.alert_app_name}] {level.upper()}", + f"环境: {settings.alert_environment}", + f"时间: {now}", + f"来源: {source}", + f"摘要: {message}", + ] + if context.get("method") or context.get("path"): + lines.append( + f"请求: {context.get('method', '')} {context.get('path', '')}".strip() + ) + if context.get("query"): + lines.append(f"Query: {context['query']}") + if detail: + lines.append(f"详情: {detail}") + + payload = { + "msg_type": "text", + "content": { + "text": "\n".join(lines), + }, + } + + try: + async with httpx.AsyncClient(timeout=8, follow_redirects=True) as client: + resp = await client.post(settings.feishu_webhook_url, json=payload) + resp.raise_for_status() + return True + except Exception as e: + logger.warning("Feishu 告警发送失败: %s", e) + return False