From c03d5a88e8f47fbc1fcee5c8258988410529bbfc Mon Sep 17 00:00:00 2001 From: aaron <> Date: Thu, 23 Apr 2026 17:24:55 +0800 Subject: [PATCH] 1 --- backend/.env | 5 +- .../app/__pycache__/config.cpython-313.pyc | Bin 5800 -> 6125 bytes backend/app/__pycache__/main.cpython-313.pyc | Bin 4892 -> 6658 bytes .../__pycache__/intraday.cpython-313.pyc | Bin 16942 -> 16945 bytes backend/app/analysis/intraday.py | 10 +- .../app/api/__pycache__/chat.cpython-313.pyc | Bin 2814 -> 3134 bytes .../api/__pycache__/stocks.cpython-313.pyc | Bin 33729 -> 34117 bytes backend/app/api/chat.py | 8 ++ backend/app/api/stocks.py | 7 ++ backend/app/config.py | 8 ++ .../tencent_client.cpython-313.pyc | Bin 11149 -> 11609 bytes .../tushare_client.cpython-313.pyc | Bin 15808 -> 15936 bytes backend/app/data/eastmoney_client.py | 44 +++++++-- backend/app/data/tencent_client.py | 12 +++ backend/app/data/tushare_client.py | 5 + backend/app/db/error_logger.py | 52 +++++++++- .../llm/__pycache__/client.cpython-313.pyc | Bin 3618 -> 4057 bytes .../__pycache__/tool_executor.cpython-313.pyc | Bin 16764 -> 17137 bytes backend/app/llm/batch_screener.py | 11 +++ backend/app/llm/client.py | 11 +++ backend/app/llm/tool_executor.py | 8 ++ backend/app/main.py | 54 ++++++++-- backend/app/notifications/__init__.py | 1 + backend/app/notifications/feishu.py | 93 ++++++++++++++++++ 24 files changed, 304 insertions(+), 25 deletions(-) create mode 100644 backend/app/notifications/__init__.py create mode 100644 backend/app/notifications/feishu.py 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 57859c909d2e32c4a247606acc6bf6b8f78215e7..bc830f608085603dd5e7b47e42d598a4e6a8612d 100644 GIT binary patch delta 1094 zcmZvZNo>FH=GVY{G7SckCP z&@3wV2-^!S!a9ZRgEhkT3p)U7g&h=j2-b-%hlO>u!FqAnE%XR%5Y{8?D6|Swg&k9m zt0$TRuu(k;n+AH5eWgrrdvL^$>J|rA<2k+G;)WkP&rN5rpk3tsgSI`S6~OXmS4s?S z6!MzuTAIT%cFu6jN3taO@ycmdzoZ%5)lEyw4C>$vM^dV5XND4~0iJXH>PnTK&uck7 z%l(jLXLQSt)&73{1rzLC7E8Um;WFD?a;HUEb1N8dWKmL68UWUSY_5?QgJmtLe1&LMo}I=BKUfe#$>o<`y! zj@s+Jid@8dJ<;cE{YgwfA0dv|3#TYNO-K{Y5Hg5=8+F&r4h>)~;|Gy13|5Xr*1&{AMPh3F^&_UE<$-G%%XT=J zm;$QlL=>g>@{-q8vD=L|8^^pak%)J_wxu$X_oDVe-=B$w!wr26*#;vS`rjzN;<#|$ zHM1P(hQkoJGNeD#wGI8U=7Mf;&Cp#A7N&>2iC7cv@MkO@XrNtG#-gKS0J3y3BrZAu zjK%u5)GZC=QHtKVx(*!iUfr&dZgimk1pf9oL`sc-s)4=|RDu{s*_O49g2i`00dq8` z<08HW8Dp=cIq#(DZ={89q*YVl1B|8EjjL0V2)>;PNRe5$+KS58u)!&1zc|6`O GQ}17U2p2d2 delta 764 zcmZuu&1(};5Z{+%W72j*66^LNenBX!NoqZ)Xsc;rKWtN*cKwQtrqOKE)g~+RHu#Bp z5yXq=6Z{7h#ETz$@S@^>;Gwr59t1%T1w~JuoOz)U#CQ0;-*0B#%)FV^fv2g+PfhDm z@LXH@YJZBojA)Eyy^ZWMvkt=cl3EIynSOMeBd|lNtW-Hjmcr;eB-JRSq;E{BaY#!w zA=P0RkZMw@DKl?Qr+u)~JOYEY8KdC6_7D5T?R6_`msbpFEV>(u!Cr!W1ViFs*kBp) zE}Uj>ywz}%iO1bjEbo2q{=nF*c(3*EnWey!1Vw}vb9n&oEqGv>3{jhxU8q#;MyfOVs-hS{IDs7GPyEE>cTN$TM=< z@|G$eavyTqlenR8Q*9KN#S=Zp^5UDG9iE^^v@1E!c8*b!vIa#%hJcpXUUg`w?o^x0 d)&XdUrM}J4EMp_=&h=l4BtIs_qrN#gfj?wturL4s diff --git a/backend/app/__pycache__/main.cpython-313.pyc b/backend/app/__pycache__/main.cpython-313.pyc index 82e9880d5ec618a59c63cd5938715e19f889c9af..414bbbe9b45908b6552bfc61e8ebad360c2f68a4 100644 GIT binary patch delta 3115 zcmb7GeQZM0u*d1P(M*o#hUl9pA(P7&aUl3 zr>?G`t=3H&gqMmjMJiSIkyhF&jX#!6nzAW+>I0}P z#z?l9p_`KIKJXQ0=Y|n#j~SYQxjOvA--pd33wmT3ZV9ImZv;h zDXB*CZiV_&+soqJbVQHcW3s`mMs^s7UbvdWie2zN&Mvqy5q20J+Il`P}iaxwA=IT!#9V9>3eGouR-!_N#7c%^Py{=uL0bg@AnK@c zU(C&$agaEP?`00-YBG$g%FFN_dVuAD83cfWu?*Y?Q!oYg(v2Vm@rK;hZ&c$L0E6ml z=7zd^PEb|vp;Itu8Yr}?pVEHyx_MvGi>=GQn7BPLb$jC7^7yaH<5RaL-!1?0OnG`* z<44jd9Bw&NAWnUh+U|hHCr~<{PRJucN;4#tyqq3E9&Az|Zn!o1?eaI@EC1}LNoRK-(~ix(GQ*+=wsrPEHCliIo>;4yJLaxUJ`2-&7MWEbIM+vqG!!dE;;K;&W=x<9rMoMqN8rj$XZQz z0c$j^5wdCqM)9*%JO0pTcU%uy@$=jXe4sO){SJ7^(&q<1gnc#4kJ>SJ)dKr!nX6(a z;d=sDVFuT6KbWc2so(Tu{}c2~+wT5$`dTCGZ#G;D7zp3aV*J_@bbkn33pMSx(!a6N z`yCg=arm@)K~&l_4ppjvMq{5Rh?A>>1n?|=*1x8{Dg=wiKonov2M0zC4`MhCNESXY zcG499Ut)40^zjI568Q(l-XiSWfRcEVxG7TAG|o`>xqjfb_FVaC;GmI+v;cQYXGGlUnSYvT*N3R5Fv>UIb zr>w>$vYEVcI-kCS+q#Yy$X++H_|jDQtv6TBPOVH_`uj&8;dB>rc{yLm#S_`2vSFLa zS95$1`RV{B8eKpm=!nkc@(3L*5(&v`w3iU_Zv>Gmg12&r@P`RGib;-Kh`>flYyLuJ zSk5Fzlq5<1N~MTb5QJXDnnT3o158%H>)>yUsl>SE7}uJJ3y8IU-NIN*w*WULGoauypz06F|j(PR2_yOeA_pJ2} zQqDBPup*D(4*6yL@~S#(?eT8cxOhC7O~m6Ghdc35Iy0oZQ}x*zM-fYm>?z#G<*{@K zZS?J&?q;o~ijSs~*qx_j+<$Qs&LNIinMj|;_aK!Xsx(>WM&s76QJk!WIX!%BSRa|x ztj*^j39Hv*jU(!KRrNz#gBdvq?jppk{?@j`rk8)c=Ia%Dm&)6pFWUb*p3e{y5>$;I zQu6w_(U%AoCq(xNzh^X?EQ~16qEi?n|3hXg0TtmCVl>gSF+hV1Y^=#J0G delta 1481 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 YYu9|n$wJr0FJ$4f9yZ_3D>2Z&0i4qLGXMYp delta 3156 zcmbVOeQZ_zxWsn2&uN=6 zDXj#Bma?^6DuGzbHfUz%XrH#+k?cNw5A{BN0wmv(v zE3&@d(dT4NMK<)i`rOQ|$i{w8pO<+R+0^grD`lktGE4{W;eiUr?67|g#o&ana?w0& z7K?|?ZGvd=k%t9VAzFt6!$u8QK_3_nq6F3w#ab!ahKoU81sc1esTLh~@D)$6Iu&cp zxGLnD-lHPq#))GepZ(>v(=T41+f6@EQ5EkMf_zpuK)22nXrpEGI{U7~xHAyXVR^B?FOyiw;a^@!dMVGA_r z4qr>uGT6#bSNMSE^!k=+ug3Bl?@n5L2_Q zoe(2|rNfHLzoKG$)I5lbvOM@{ofhgh*4c+3QelW+-3v$5@jrX9f^l~*s_yQEGinNx zj4SHAvm?F#Uq@V>ohv$`75)5Ih8B8`UpLguRzx+$L<~f=#iUhO>S2IH)kDy)!iWl@ ze`)2KjL=K>P{*>(EmopVtZIVZiB(IyjAsQ$BUTqU%T{kxRp>Q;NoSd@5iQFW+ikxU z8Q8>HC4OBKJoq+y#q)eaUCTLnqF&@heG0ou!Q~1vrFTD}3>~ATE5g<N9fJIqe=A}CY?zW}5I zpKxYwHLACj64I@%AY=51xs?X_$L1E=Fy|_^)4EN~Q7Or!=6IZCGtE+VnkAC4M0PaU z9G7z0#O|i}crue~=3&bxK_8^Ruo6}dAmNJ^2(Tb3DiNx9hqaDY^SJdxE|$!=Wnna) zivy;{#$?*WAPXYX)m&pcK>hr%?YGviquc!mn-F^WPWz9wvhc8!&2Y8jYwNIhwgsUN zAXLUm;72y3GNZ}IVoGqGG!av#?7w2|an#XPe%w*d|L6$wFC8NS=FXo8+xfF$0sqyI zJ>TyP8omt%tV3dRXPlLkHu42mrEbw3zUmI28slHO)_1($+yC~=`I!rQ-<~}`JO98p z7khW*dv_fZPU?^APZ&>Hj$2M?&!`qWt&5)ayr=!F_D$7Q&wcZwV|?1Zx99!H&c#S9 zABinQ;){`FK9Zatn_P^f^O5wihfeM~zH7nXwCHci`&-V`o~>N)cQ5*T^8TJTYcEw^ z^>3S(rg_M-sid8F8X&1F?(k=Nb-Cfz*10DJAKup4YI=h1a0%bA) zX(U*juP~w9phsq#VKAu9ulEd=sjs*&+Lba5Xs!e?nrxiN=3-JZk;^hEQMfbb>PoG~ z5(lg^a{_E)E)+d{b8R`@$j57I%(z2CX68Y?gCDOggMIp1?GgJSQ2dO9s7xP|Fc$8c zp}KZ@gY&wMf&HMF)}i|PYp1ULdVe?bqec%<6S#Tcd)Hq++RdIq6`W!~I{c&SPaV4P z-2>h1;BDStU*31^;FI0#5ck!G%D00-E=pu4r_&ip)}?Yu#1-}3YotV$C1urAlEKkQWX(tndL`2l_6!Dg@Ud`nCBEIVE0xKyIMf;& zOHD#c3wcWH_h5W-?)C75K;M`PG`}lo*Q4jX{Igb%b_0st+|m~M7Jdrcggc1b#QJX* zBWM9w4?wCBc1B27=$g_MTwPfoPftyza??=f(@4d}vzHKl3J@w{Z=ejH6NF9iV{JhT zX39=2!L7MVZGWKj=-lD8M+Eu;4|MrxDevlf#z4KjzpIu{KKvW-w0$P>0tgi zIZJ6*7CCSFYPxB_s0#)TkJI;Sly`fIE+KesWzSP4LU>`VxOa#zP3pJC3~o zQ(4QT97|0>|M91==`g9n9*lt$GO(+tz!0(_nO=GySqc?J>3A+NksM|F`Al~kTuZ0B zeVUtKSv&V$cUVt59#Oz3{{e+d>q-Cs 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 5acba5d36c57aba2edd40ba5561a624ba50409da..fac88c92bdbcbb6f8601c9716bcd2a6699661073 100644 GIT binary patch delta 1404 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 delta 832 zcmXAnOH30{6o%)_bbt;8?L|iH%W#5K$scDFsRk1GjB_E#wga ziI2pauyNxOcIa%3J8TUWh!#cBu1{IC>(ph}-&wu8hbLQUrdW@YOVdHn= z;sl0V8%vwccU{loqlzJ2QWpmRal&(n4>wrUSnoCe(kq3gU?JH8? zj)#p|Azv^6l5x$Tj#UC~8T>>x!!%T(qNh?;^okxYYQndNGN4NuAG!*SI!Kz}b{KNo z;TSeQ%n}to1q>TAq8-qN;<6V(zfxTOB2B8^VlMMg4^Q>sh?r>DjR=~|*$VrC& E530%mfB*mh 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 83686a71c804cb58a1ac1ad02c01d92130ca31ed..49b53eefd852d77e200a5e57037091efe0f86ce3 100644 GIT binary patch delta 2448 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 L9@fw031|NW3eEf> delta 2796 zcmaJ@du&_P8TawqiQ_nqoj7rv#!0a2M{pWDPd6c13gy)TZF#iHa9#U0wd&Z}xwp_X z9i&CuP$^~MTdFZ0(*yznBWQAY386wMn)U|>F>Oe9(ln`mj52M@TBdG8dwk!q-6mN! z|MBy^=X~dT{=VDS*o?m^D{bOL>=4Pko19#~sv^$z%-sbF?u=O(9 z*JyaHThp%DX>8W)H1%rBYxW6yyQa7FqTzLu+Ms#@*BEO;|Byp?eyQsl zR_zC^PQ-K8sx5xQxrVX2Ek47CT@l3dO$?XL`wUzB#gHw-jD6%$5Pnd5{a_tE4iF3y zbQ5eM=s{48=~ViFl;LrFk)I$Ags}ZMW^T!TQX8V=suqQ(GEy>~$^~`& zCS2jd!i}UrhcGuchi5bOBkT@%zCKdxqC=*voGeUBd43)Iwm!(lpA5K$m6VcA^B`%s zfn0`HXZp zDIy-?x5LAI6R^CYZ`&gzaVNoD1eBhNlhrGZEb7-#gVRA-kAVEBBn-yljc}sP%TB?| zZIOFKu1qUyL?-AAA19#l^BIIiW2pqfnOkX<@>#Mdcxc2|;>qYJCO71Zu}9&4-z+jk z^=)CJci^t7S7xTARsLI)19DQtfDxS{g;cNAiKdf1FdArp=FTd3sKddYhWUzP=rk~tFtHSJ>8*)XvhRF1Rd-Rcq_QEQ&4sR zr7H;&+SepZ7mBghpg%NRZjno&kIa=8fwSR8$2wi{MR<^X0PT^I3DiDL@rwF9OHXS$ zBp(@O@4-(a)P-ZMO-0tFWggKNPm#3f3yZ82(g%}M`zuXx-fsyEd@l%wS{qUZ@2^OMtVnzhbJP%7z_iek$)HOee?sT2dsz?jbT{X)}isL9y z))L=@yF~mF^Xs}3>=&@D`#a^zZQt~`Z;3Qf;`r+X>tt;t+6eVMKK2{v?fF0RyJJjIDrFZ6CS(W z(E4`!*~mMQuQd3$+*y1v@gN%#;v^JwsHS8xQ%EP1oIamaQ?4*MnaxkCCd})B6qmT0 zmW|V?d?qFH5bPX|Z?jBF%IzqJUs<2wF7F~Bb*im=Dw)n@C5&06ovY5N=FL-u%%Pk# z!Y|+keHzMJ;ZMVz;on*8pIe$gvo!y3;Fp%pIpY 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 931d0c33ebf2a52095c65cd196dbf473d3c81cd0..c3747eb4ee254c6170d87be49c919dfca5e5059c 100644 GIT binary patch delta 1480 zcma)6UuauZ7(XZX-jn3so13&v*LF>t^p9;**KRY>x@ij;>DcNv!Rtg!Af)M~f0jS( z$*t&%WmC{Tt=mOV@u3Jp_cjnj&{P>;k z-^n?@@4H`wcEZ}e&nFXDKfU+ce3yJpYo~Xw4L-Y~Q(bz6O}V){LeliL0ERrhdP&;* zJgwWfth<(!w4l32+=Qf6oehFRcQ-6?m+m=C7DVprCc01ernR(R!U*|*Ihl~*cWS&u z;!IaCWr6a*e<#I7U40VSH0z3L*J;TYrM5R;n#~xTm-tsD8m5$4S6zLy!`g8TNO+xq zB?<|uS*L{_S%Llg@Y^4@WY3WGsqi~htPjKteZ%@yjJKz{z~?O8t+R`gMq+TG6ua&U z9oLBNO@$l&i?s115F_gH3E?c!U2`HKZ_|&33zCPZR;$!YRqI9Rvv3on*>WNKPR1-< zGKyteF^$5a!L#NvH+ZwPCxwS?A)hZiWQEd|S`JT1M-lVcM z&R`-=pC(XTW#9bBI`}-LRIbGZteX1%+I0NRw!2O zjNd%--Rs{!^KI&z^VOEAJ-6tW|01GS-b1)2lR)rM%@2himtTeBt|a=CE%b-M=&565 zGeA#`iknR#};EhVHp2bKi7 z+NgC|IWH)ZY;7q^(@c^Nn&Ie8x>2sS#%_D!jb<2FUs}&q)#3lu3=gmw9@QkM`MCTp z9CjrUm}FK#Jsq25^j4odDG0YlK<^2m5~Il%SS2nYKOup;G9iKcM?nA{r5B>j){c67 zr6s$#I65;u>I`}YMt&BUGXvgP!#wBgZBHgs%odDHrv7FOPPYA-Otx4onOU5JGPqC& zTg=awO#a9<<0`R+ve@$FH`H8rtglZBPYK$=XqvYk(eK+pTQ4;Cj TgD-`AkOlmoSr5?Z{HwnK9F0pD delta 1051 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 delta 3172 zcmZuzeQaA-6@T}==V!-p;y7{R#QCt3G)qhpw`r0l%}3iTwUdptY`mhXG~mA2FUh0+ zNbhsjrJCfff`5=G8kf?-CT*paSmBROJU|H0g!9p^V;vn6MZAF!e;~nMgq3L$8xrSS zCrg$QDL-Mt3c_@mDs=#>%A1VpP{n>(Rf57-Wa?I()8(oQY>w&jenl|%)4GiXgWREhPK-3L3na@vB#*l!NI-;8 znH?1;94=72?6TO$o^31>eRH`B$M^z zt2J?_aD~KrD}HTii=x`{iOU z=le+k4FhOcc9GZtdASS?%N`uCT+xa00Evyr%lkQhfb+x1ue6fbn7q=4{K_N9uXF@Z zK1gDR54V-W0d?*kf1jBw3m*mw!ZW!i<5e#UplEfYIniu)C zYUI~y>QU|nnHd%F@MNuY+c!MvlkSugkneaL;B?35K)F_$thfGDF)n)XQ0Re87_z#n zn&c;As81qHAv}igIKpQUo{Ab@B0skHPs;6`zFHXdkI!svH` z8G+_bblEYx?at&gaI3iRI^)wy3!(TI0E4$dk{EBs@So*52jCm9#LolZ92kf zgsp)2G!9y|X}6uGp3LVc6tHQ_DJ`#=4zNMFZxlS;jjjkY*0uU?5b`Cqr=hzL2g3HG z)0u>p)zV9*Va6$6+f`b7YdfdWQ_&U{^lWNpAPVJv8oJ0-PnvWdMF~V(HXu=^0y+<- zbp7ZfpaZ~_G^{^2{7oVuwi49%WD&nM@#beaz046Z~5<}k8zwv^Vh+e*O0SD+-% zPPR2ZhEdqgIhtEs&{Om*YSI~f$#!eWyphXlGijZk0@m~Zl(x8iqaDCfghlJMw*L}k z7wWZEvHcfBsYN-&p6lo^!YE?7ACC|?Kbl*h&w*w;A%!`bGH()F$x%bk=1Uo=v;#Z= zu={9!(VW+)o=B#-{o6RM7Ok5{8qMp;`K*!D()2R(bv7!cI%0jDJri4%uh3F#7zxfn zFXKdFYLyThf65o1TXzX)ynrS4sYAEAw( zX$5@|WgczK9NZ@}pG%%eWO7-3X*QiZM_)qoR>i*zs_lYeqBM8TEM2iIat=5eD9I6Y z=hwoub0asB=yhDnK{3iwHXVGh?aXLU?t1Ai?(7DiL2rb+ z)_CvlNSepuwEdqb8`GGBreM-vp))U((L*!2FH0)}!tBGNS+Fbfc=*wzP|ko`465n&l&1z{E8 zlc8+-|fZ{KYyt_+=tKBeC9~pS{rNvF;7MBa%^e zZDfGG)fJNAE3KYIM)!~)J30C^G_w1nt<_;1&QIR4K#inMf5_yqk?6+-LEk`!-ypn+ za1Y@vgx@0EM|hiM#*UB^?8jrR?2lt%Qpx@^7JTw`FhTQy>kV} 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