1
This commit is contained in:
parent
ea9fa6e5ff
commit
c03d5a88e8
@ -2,3 +2,6 @@ ASTOCK_TUSHARE_TOKEN=0ed6419a00d8923dc19c0b58fc92d94c9a0696949ab91a13aa58a0cc
|
||||
ASTOCK_DEBUG=true
|
||||
|
||||
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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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")
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@ -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,
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@ -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线)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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(
|
||||
@ -21,3 +30,40 @@ async def log_error(source: str, message: str, detail: str = "", level: str = "e
|
||||
await db.commit()
|
||||
except Exception:
|
||||
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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@ -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": "重点关注",
|
||||
|
||||
@ -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)}",
|
||||
)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
1
backend/app/notifications/__init__.py
Normal file
1
backend/app/notifications/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""通知模块"""
|
||||
93
backend/app/notifications/feishu.py
Normal file
93
backend/app/notifications/feishu.py
Normal file
@ -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
|
||||
Loading…
Reference in New Issue
Block a user