From 7f581e3fbb1e69945a4eeec1e375490a336a0beb Mon Sep 17 00:00:00 2001 From: OpenClaw Agent Date: Sat, 7 Feb 2026 18:13:24 +0000 Subject: [PATCH] feat: add FastAPI routers for channels, notify, and history --- backend/__pycache__/main.cpython-310.pyc | Bin 0 -> 1471 bytes backend/__pycache__/schemas.cpython-310.pyc | Bin 0 -> 3189 bytes backend/main.py | 49 +++++++++++ backend/routers/__init__.py | 1 + .../__pycache__/__init__.cpython-310.pyc | Bin 0 -> 174 bytes .../__pycache__/channels.cpython-310.pyc | Bin 0 -> 2923 bytes .../__pycache__/history.cpython-310.pyc | Bin 0 -> 1587 bytes .../__pycache__/notify.cpython-310.pyc | Bin 0 -> 2419 bytes backend/routers/channels.py | 82 ++++++++++++++++++ backend/routers/history.py | 41 +++++++++ backend/routers/notify.py | 82 ++++++++++++++++++ 11 files changed, 255 insertions(+) create mode 100644 backend/__pycache__/main.cpython-310.pyc create mode 100644 backend/__pycache__/schemas.cpython-310.pyc create mode 100644 backend/main.py create mode 100644 backend/routers/__init__.py create mode 100644 backend/routers/__pycache__/__init__.cpython-310.pyc create mode 100644 backend/routers/__pycache__/channels.cpython-310.pyc create mode 100644 backend/routers/__pycache__/history.cpython-310.pyc create mode 100644 backend/routers/__pycache__/notify.cpython-310.pyc create mode 100644 backend/routers/channels.py create mode 100644 backend/routers/history.py create mode 100644 backend/routers/notify.py diff --git a/backend/__pycache__/main.cpython-310.pyc b/backend/__pycache__/main.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..57491678d735d0f8b2664f2cb355826a917341c5 GIT binary patch literal 1471 zcmZux&2Jk;6yMohdpGvlahi|1Ux}}76+7jG5JIg26+I9TK^*q7n(RDhC)wSxX4XyP zQ=(KtLihyYP$AJsAk+im+8YP{i#c$Cq{_cg5aP``2?a5udH%hbd2il(zqb~RM$N+U z;@~s>t7}=m8Rhlip>hwGS|gUlEJji*BOxIM+o_#7p;KZfb+bxXDY2VYGcWW?TuJ@V z$Gg>ZCaZRvjwO?8(&zQ*leCHO)xj~d4n07HoNx3!q{%YEp~m^mS1C5m2^2=hUJ|Vc7xqy zx6pEV;$n`K&2n{D&P*-4M9)*TpU1Jt3wT^)G^g7jaoc-9RdN5LhY0JN1ub~|fTy5P zZLWX#@#Y7du@v^GgpKETndiKS*jCrkew@%ehg9imE($)_*IvR^A>_Vx=x`V#SC_+J z8`nAt)kbh=7uJ#W5E*>mGJJem%1sP&Lss#;OJp5?o?9}&pI7ho6OlnbPI$iE?};Jg zaZ304dqVE2A&sGr4@$0JbsEWP449!p*?8+dl|-P5y!r)P&>qI~)7_rJgR@#N>D&T8lMtDjE4IPAQ+ zcBk|5hspHt^qa}a=f~s9-QHSnt?TMa!HX1hoq>ua9~NBX+Is|2At)c#_tNIZJsMVH&tT^4CCMw>wJDAgCY^^QcZv|^ftD8J?#&qp*Lr*NWW|A8O9~VB_(cB zWGQm$<}`zG9Sg?$XqgsCjQRa*7@~f`rA(C$2_wwr1gUX0bX8%4jFh&;Zb{64MFTB# zF!dY73BKXkli?IuDM+8qj6 z=&FQNP}c3}+IbVFd`p^WwKu>X#+0OEwE1(bXQJitz_hb8G3cuoC)s6J_hKQHte8_< zD18|cqXpff3Z(h>(X$tkN-@IDQqsp=Ib(#WQpsc4vARhai~e!1n;d&}#2}hlViu(b z%!w9>c6S8Fc0isCFQzmm+9=YE3nruTUKJkWx21F?6QjJ)&NdX6#Wa>^;f)?~%q7>3 z9OeH@jQ@5f*eHc}%;%`yMG;s5G1Di3y+CZcMeM3grnCB|>)XUd&Ba;$BbYHa+YT<` O+cn}lK3O8}Uw;Es7oi~l literal 0 HcmV?d00001 diff --git a/backend/__pycache__/schemas.cpython-310.pyc b/backend/__pycache__/schemas.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..efe030e67099e9d59c5d33949b0853773b413001 GIT binary patch literal 3189 zcmZ`*OONA35Vm8-cAR9AdF<}Y@)}rP0d|8xT+nDWut;!#AT7HR^2PGFduGhqakkrG zl>;Y`kdXKx`2kx`J547o<(;HR~S-;1okoAfy%9 z!iri?E$K*C*eCUG>%_{es4YFr`sb}93p(e-y0D@^cBI4DJ%K}`PnU0gR%6^uUQO^W%OiSa~gmGMyVxH4;FOHwj zlf2P5x>9Bk$9R2NCFBMj$EpV-RGS2-W2rs~B8=LBFz#7d<}WREi)Kjt`_1^WEaW)7 z%%&IP2jxso(>!@Geo?AtdX}VeoXln_)ABwx$<7z|Q#q}q8lNTUGdUGwonFc!(GO+| z)xqm%PfbS%t-ucK1AB-sYi_vR#;Vih@}=8_4X3qCzj4|lB@ye%$Wa5VR6`QGB(Bb1 zKi#IeI}l{9CUXPJwRz^ozE|1v6P7LaeTpoK5OCQ>C_RC21N&DPp#~0sJF+c9A^@+W zklPUgE_?J(o@?`5A9Fj5i3((ue`VFv88LXGD9crs1P6^nMNt|JuA9p%iKl9h#6F4D zAGbwY9niW>t~!K*oY{nmf^>*r9oULAH(W+LpVIP=AyzJvj<7}RPdkF!;50Z%`uK%Y z_?H~vvEhCb&L=NzGdNde5hqopva@+5wP|I-geeDFVBUmtb`L#IM!|JY1UP}|AtPiS zHwolq)2en!3(Af59zj9$(B#O_3hkg3*gad({B~>Kf_B|n=^~(Qt_dD~i6jo}bFXro zOHh#339n7NLSE*Rk$>F?&PUq2KK3y^Z3@$yP`r+zDXl`QM@Oc)?MMCu+SQS-b9xyj z;ey)~GUh5w@XT;R>7AuqN2pd!aQv*wQLWBOu`u4WR0YaxfMQUptXkX*%{$mz5mnYm z`aZ!;ro9ctJq%3&4Xq)H1qubtZa0pyd}Z9SK8qF%aLnGi0d?H~t?E$3nV5{)*Q0QZ z*VO^obifg`bb78PN8+VGgtSX4(2c;edZ9{!MRJEp=aU`^_al)nJ0_K z^7&lqYNDuy)t3NH&vyyFM}oLL>Rx9BFX!NMjavDHn-jl+{Bh4vA~6#;svVBAwkGZ`Il;usHCJzT76BJpdl znl(!4LL}2FOO;PsaYE7^imxr)h`B#;#plY%6^Jv7S*FOXiniMn4ilWz_r`~G(b7~a Wde$MOr!(|<_`^27&M+MM2mXJOUO!y` literal 0 HcmV?d00001 diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..048f88a --- /dev/null +++ b/backend/main.py @@ -0,0 +1,49 @@ +from contextlib import asynccontextmanager +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles +from fastapi.middleware.cors import CORSMiddleware +import os + +from backend.database import init_db +from backend.routers import channels, notify, history + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Startup + await init_db() + yield + # Shutdown + +app = FastAPI( + title="Apprise Notify Center", + description="多通道通知中心 - 支持 80+ 种通知服务", + version="1.0.0", + lifespan=lifespan +) + +# CORS +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# 注册路由 +app.include_router(channels.router) +app.include_router(notify.router) +app.include_router(history.router) + +# 静态文件(前端) +frontend_path = os.path.join(os.path.dirname(__file__), "..", "frontend") +if os.path.exists(frontend_path): + app.mount("/", StaticFiles(directory=frontend_path, html=True), name="frontend") + +@app.get("/api/health") +async def health_check(): + return {"status": "ok"} + +if __name__ == "__main__": + import uvicorn + uvicorn.run("backend.main:app", host="0.0.0.0", port=8000, reload=True) diff --git a/backend/routers/__init__.py b/backend/routers/__init__.py new file mode 100644 index 0000000..9c8ddfa --- /dev/null +++ b/backend/routers/__init__.py @@ -0,0 +1 @@ +# routers package diff --git a/backend/routers/__pycache__/__init__.cpython-310.pyc b/backend/routers/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f17b39132f1e3b16ccbaaeb50be1d9d81db2590a GIT binary patch literal 174 zcmd1j<>g`kf;|oGnLMequpEQD$+fZeD&#W?H3ga%x^lYLR|YVsdtB rUW$HEekqV!tREkrnU`4-AFo$Xd5gm)H$SB`C)EyQRWTEgU||3Ng4!%l literal 0 HcmV?d00001 diff --git a/backend/routers/__pycache__/channels.cpython-310.pyc b/backend/routers/__pycache__/channels.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..020f7781779b2794ea144f3e1ce475e86bba4c28 GIT binary patch literal 2923 zcmZveTW{RP6~{S4F1a_Zu9js>b~Z7ZWZOilleZuZnmPd-AP}5dd07-#P@Gwr63N}n zaAQeb15#C^HUc#Pn!95}0+OQ1LtUV^pheM#J{0{5^V(?R{R#nM=bRyTC0X9Z4CiuY zIP;tTIm4Fg77h6Q`OV)1|1BBD|FLrTmxszvAxTCIgBgs3Mnvj_V3~$AGV5k!)vYL3 z&*?TZv?HhP=&}{&qe8u)%ek-^mFgv3w!?Dd*4?O5uS6sDk!Z9&N(^o?hvk#`|!a>pE6PVEBDET`GSW}N0D+1qE>thPPH$97M%@jWt} z@8Hd6*ja5e12$9ak%!t;*`wNKmOXaasGaL0{~A%&tAR{vrYgL6>6M?iItdrbd5O1q z%%pNJE-qerdClkTBxuE8Tz*lm$Npt5Wm>nFdD37@IE^nltY#_a!WA!$d8pj1a6xb{ z;lt|dZHCoS){8ISaOb^tFz=@WrOFRVHLC2k;LTu7S&6qS6|T1;xNNs#$s19N@vzTe z8_JTaL0jd*APN#i*^+PK?y_GFo|ygvN&XPBEs_{3q)WR-moTzpz5*hhf`}h5~-|yYr?0s^xzxi>0>v#J%KDfL0hZ+?V&@Cn*TY!oS zPnAnri5J#PfudGrBsAENGD9BMatDLN2^@~Y#1v#-7~&K>zI|=}N-N?(B#4*i=UQ+W ze&}7DzuFS3vhDdCc(p|!`O|SL37YFq`#gqon_u$$RXDeKp^s4xiO#jxl^X)-;X=?G zC9aQdPC0o}Bw_|Ca1g)Dmf8r|HB~&Q_!RmFKZI*R(m_XF9e5T>s7u62P(xQFsx>$TAMh;p|P~yh{0Z2m}@Gk;%i?V zs6Z992Q^qHY6CIcVM;8o=LYRPh^z)CXCP(BCT&DEMFqAWb2$eZwL+Ru*aCH|7-A+N zyPym#4-uJNQ_OVD6|-xsLMgEh!6Li4)J<1xmSgq~*+XYVeOX;go(Efq=9#`Mz*y*% z;bpyBe>q?%egG}vam>8Dd;^=*bv?^ zH$zMT0Thl#v43N?|IufeoDO!XQ79*ZK<+K`joE?1AfWPuv#RX)KJ+y^VYohsWRM~sfP0yXHv^xiVjCEL^Xcblbo=*5y$|nv z{ncjg_Fg(u?{7DHTX&Qlw?yQH3(8Jo8QfYBw--0gEyAWT*agkN_wc5zV#m=#pyC-= zg7`6JsOSw>k7_~~lXS znHqx^vc};hq_Q_5q6}oNuPSZrWMh>LcX2079yoAS##0>V)y1+X}<)4R;+X?v#s3&;|va&G= zK>+{Lq(aL8g%X=s-%&#EL-`&x%iooYv!vqKrgblEE#E7bZ0o2^esq@PohtbrpZowH literal 0 HcmV?d00001 diff --git a/backend/routers/__pycache__/history.cpython-310.pyc b/backend/routers/__pycache__/history.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8699e51c14a5034ad2b7fe6a53dbbb653e4880cb GIT binary patch literal 1587 zcmZ8h&2Jk;6yKTsvK@cL4QbMr5x4R8Yu~&Xzj=P|_xnAkl}gFL^VQzx z;rq5>{HZtdPZ^sx@yfqon88S7#H3A#_Gv_8vu(y!+luYBou8SJ6T59U_pPW97u&_$ zx1&tB7A6#4nN?Vo)g~sZV^{AQ zZ1H=-8u-rQ#eLGT**RWkOL(8-gx6OMwv2TV>v`^WN^FI#eq!&Fb|cSz0oiBw2)l?o zI>us`*z^#;hlaKrimXlO;xWtJixRZd^< zPPnVA%IdM|rNUC``J_l03z z!@jbkFb*?S3VME$@Mtq+%8{9$4Wy!MD**fdKMp(>?;m*OD=@c7W*m@_H8MtIL`UY- zz78#1GsgDF)@O`%YJI;qcC9hj)KH`stU$-|y*4D`(mk z=MgUS9W+$Hq82rFXJVSp(`Jri_qrAR3y zlsJ#Y8Vmyb#Dh!}v0cZ$a_#2Ljkk9Lo)1L$1;TDEug~ds4SlY4(m}$Y z--Vf|HiMLLjLniN$qyqx3VJ*qikFaBRZpOA#R!a~(AX-M@A3e1rtF;_7hF-O zv>^jO^2KJF@Xb_!kc2C@@;nRqg z`m?Ij5|hg?bgyidiA5Wv^rT=~v`q9)c=)Qhq?~NnhunmwRlKSVX44RsHhP}a^3$eU zd^cB#1%fjnGdJyq9G|logn6j~ierBI4EzlDBqohh4Vm*SkpFrXNIwpPWSo9xi Kmmd{sYve!PF1j56 literal 0 HcmV?d00001 diff --git a/backend/routers/__pycache__/notify.cpython-310.pyc b/backend/routers/__pycache__/notify.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..142261f8717796664dd63a04464e1c56a34d4715 GIT binary patch literal 2419 zcmaJ@&2JM&6yF)|&iXTP0tw+$p`{9L!KRfe^?*>3K!vIbl%O6gRncZ=k}TQvnwd2u z(MC!LB|!>;s)$MvQiIy6P1ORulp-NG^w9rduEkDr;=nDmY2U2v@ZGiE+j%qZ&BvSH z`>p*9x5Ua7co*cc4MAi)IFnUc%(=? zvuH;BF*oidiixPLyGbuqOhs+OO?#PQ#%n3IL^g3-y=*ayG^SIN#)lA1T*AdR)^@6$ zCRqzjUBb*@scAHbXqshch9w4d+Cp2;8`G$`fwe7cq{$nI5t^lKv>m)vYeTa&twuJ| zj)vU{c6~NNH!;LIHfyx2@!s5cZ($vDD~MVtO}EkQ(Mp{(+o$Drtf<`Nu#6uo2aaE| zT&aKJh#;rS#KGf7PxzI9acLf6Wmcj>8lP4eACs9QCr=(fJZiHjCWmDDpcpIJeM|_L z18g$Hf_^#x^JaSez!@An!A@702xLps6lK387`QeZ`WQds*lb)cSY=0&QWOR8q|Di% zGb;7K8WQ7Xp)r7D3upB?`aPdASDL)B1f~NvR;Ow_CcNVye}IVdpr%l$ozg@GG!mHr zQ$#V1UBc&35S!K}aEKT5F!E6zAO;8lF>jEg8r6?z6R{v3#-`DP9wewi$$2e|9nz)| z^cc`%KG#D+Bh*K57z=f15gNa4K;jdXy|9()Gmoj=pJC*W$vGG zD5Hy5rbJ)`713`)mL#DpupGD7hMCqMm8=+u0D0UEYDx>W&r=g9L{tMjp%Kh6#h*dJ zoQ6d8X^Xlf^;xAZSSeUhv-K`&uo}+qlZf-}&=INCV})VgV+DKIDGe3!KETL!t+R!* zJ|7Wf%Vu!hW$p;Jw|=pEZJ3Yo!hmIu0OATf0-$JId%ip-6Z?l)iH(-|ft|3(V;vNp z+@X!{f}PO*Xcot|6qfV*UEj7`aUj1Q^4fFrE|@2$^DXd_WDs)1DBlJ~Z~ZJgp!xJv zTrJJt0HuV#32M=hrl{C9lz@VwsJH=?OW+5V%eN}0U7&JF8E;-2Z&ketoIDLmW~2QF zet**eqMg8HQXlZ?n2bY~eC`BeGUl?9jERbuGkKc|Z&%@_s233LQC+*0T3_~sjmx%| zQl%(U>;r`-(*z^+BWuLa;jOR#lB@AVJ8DNHR`&p(C;VLq>aD25)nQw`a%t&@+mG(e zRll2m`1?;wSASfY{JMJiPIdOe%KTNR_Y`Hq=1#;M6_wUH#9tSYuc++Qew(dL-Kt*r zqI&bLA_wk4T5~W^&*D3(wMQvcR5k(Cd{p#$_0NH!s-MS0Z2^FN1C^lmCrF5RTZo?N zF+(D@ho;ybAR41OH6nJvzz*>+z7SJ&9GYQN(-9|N;Dkh=hY4VSWB`mnP2hw?gA-C= zDog?cB*LU>g{dxWodw>9EkWj%AIx-rbogZV@~u0yU(Y>D3y<#2)~?-sc>lrj`T5%Q zpOLrx#vySR$>^zsc5%{MC50qXzJ%{ME*s4`!N=8u(!Li70Ummty)l>Ul};%|X_ zjz~k4T_<>oXa2UDE3Z^Rd8A)^U3L7X8?f(p;p-#gKxa{lD^R|O>2-FA>QHvPa<2*w z;yqA!vOU?3;`n)3HzABs9o9`y69wuI)Q&ev5{#9Bwzwilt{769gBB29+2M+IxS^B+ zTXbyMDNehVYY#JTEYC)RyrsU0P_znuGTUIZJhcLAz=CRpKPQ@OZFUP4X9=z3Y(`)gaNt{FkDIrK-CD2bWUV~KqgLV8jHt-WnvaA0AOGTAH literal 0 HcmV?d00001 diff --git a/backend/routers/channels.py b/backend/routers/channels.py new file mode 100644 index 0000000..1ba7f6d --- /dev/null +++ b/backend/routers/channels.py @@ -0,0 +1,82 @@ +from typing import List +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from backend.database import get_db +from backend import crud +from backend.schemas import Channel, ChannelCreate, ChannelUpdate, ChannelList + +router = APIRouter(prefix="/api/channels", tags=["channels"]) + +@router.get("", response_model=ChannelList) +async def list_channels( + skip: int = 0, + limit: int = 100, + db: AsyncSession = Depends(get_db) +): + """获取所有通道列表""" + channels = await crud.get_channels(db, skip=skip, limit=limit) + total = len(channels) + return {"channels": channels, "total": total} + +@router.get("/{channel_id}", response_model=Channel) +async def get_channel(channel_id: int, db: AsyncSession = Depends(get_db)): + """获取单个通道详情""" + channel = await crud.get_channel(db, channel_id) + if not channel: + raise HTTPException(status_code=404, detail="Channel not found") + return channel + +@router.post("", response_model=Channel, status_code=201) +async def create_channel( + channel: ChannelCreate, + db: AsyncSession = Depends(get_db) +): + """创建新通道""" + existing = await crud.get_channel_by_name(db, channel.name) + if existing: + raise HTTPException(status_code=400, detail="Channel name already exists") + + return await crud.create_channel(db, channel.model_dump()) + +@router.put("/{channel_id}", response_model=Channel) +async def update_channel( + channel_id: int, + channel: ChannelUpdate, + db: AsyncSession = Depends(get_db) +): + """更新通道""" + if channel.name: + existing = await crud.get_channel_by_name(db, channel.name) + if existing and existing.id != channel_id: + raise HTTPException(status_code=400, detail="Channel name already exists") + + updated = await crud.update_channel(db, channel_id, channel.model_dump(exclude_unset=True)) + if not updated: + raise HTTPException(status_code=404, detail="Channel not found") + return updated + +@router.delete("/{channel_id}") +async def delete_channel(channel_id: int, db: AsyncSession = Depends(get_db)): + """删除通道""" + success = await crud.delete_channel(db, channel_id) + if not success: + raise HTTPException(status_code=404, detail="Channel not found") + return {"message": "Channel deleted successfully"} + +@router.post("/{channel_id}/test") +async def test_channel(channel_id: int, db: AsyncSession = Depends(get_db)): + """测试通道配置""" + from backend.notify_service import NotifyService + + service = NotifyService() + result = await service.send_notification( + db, channel_id, "测试通知", "这是一条测试消息", "normal" + ) + + if result["status"] == "sent": + return {"success": True, "message": "Test notification sent successfully"} + else: + raise HTTPException( + status_code=400, + detail=f"Test failed: {result.get('error_msg', 'Unknown error')}" + ) diff --git a/backend/routers/history.py b/backend/routers/history.py new file mode 100644 index 0000000..2dc9d49 --- /dev/null +++ b/backend/routers/history.py @@ -0,0 +1,41 @@ +from typing import Optional +from fastapi import APIRouter, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession +from backend.database import get_db +from backend import crud +from backend.schemas import Notification + +router = APIRouter(prefix="/api/notifications", tags=["history"]) + +@router.get("") +async def list_notifications( + skip: int = Query(0, ge=0), + limit: int = Query(100, ge=1, le=1000), + channel_id: Optional[int] = None, + status: Optional[str] = Query(None, enum=["pending", "sent", "failed"]), + db: AsyncSession = Depends(get_db) +): + """获取通知历史记录""" + notifications = await crud.get_notifications(db, skip, limit, channel_id, status) + return { + "notifications": notifications, + "skip": skip, + "limit": limit + } + +@router.get("/{notification_id}") +async def get_notification(notification_id: int, db: AsyncSession = Depends(get_db)): + """获取单条通知详情""" + from sqlalchemy import select + from backend.models import Notification + + result = await db.execute( + select(Notification).where(Notification.id == notification_id) + ) + notification = result.scalar_one_or_none() + + if not notification: + from fastapi import HTTPException + raise HTTPException(status_code=404, detail="Notification not found") + + return notification diff --git a/backend/routers/notify.py b/backend/routers/notify.py new file mode 100644 index 0000000..742ca61 --- /dev/null +++ b/backend/routers/notify.py @@ -0,0 +1,82 @@ +from typing import Optional, List +from fastapi import APIRouter, Depends, Query, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from backend.database import get_db +from backend.schemas import NotifyRequest, NotifyResponse +from backend.notify_service import NotifyService + +router = APIRouter(prefix="/api", tags=["notify"]) + +@router.post("/notify", response_model=NotifyResponse) +async def send_notification( + request: NotifyRequest, + db: AsyncSession = Depends(get_db) +): + """发送通知到指定通道或按标签发送""" + if not request.channels and not request.tags: + raise HTTPException( + status_code=400, + detail="Either 'channels' or 'tags' must be provided" + ) + + service = NotifyService() + results = await service.send_to_channels( + db, + request.channels, + request.tags, + request.title, + request.body, + request.priority + ) + + total = len(results) + sent = sum(1 for r in results if r["status"] == "sent") + failed = sum(1 for r in results if r["status"] == "failed") + + return { + "success": sent == total, + "results": results, + "total": total, + "sent": sent, + "failed": failed + } + +@router.get("/notify") +async def send_notification_get( + channels: Optional[str] = Query(None, description="通道名称,多个用逗号分隔"), + tags: Optional[str] = Query(None, description="标签,多个用逗号分隔"), + title: Optional[str] = Query(None), + body: str = Query(..., description="消息内容"), + priority: str = Query("normal"), + db: AsyncSession = Depends(get_db) +): + """通过 GET 请求发送通知(方便脚本调用)""" + channel_list = channels.split(",") if channels else None + tag_list = tags.split(",") if tags else None + + if not channel_list and not tag_list: + raise HTTPException( + status_code=400, + detail="Either 'channels' or 'tags' must be provided" + ) + + service = NotifyService() + results = await service.send_to_channels( + db, + channel_list, + tag_list, + title, + body, + priority + ) + + total = len(results) + sent = sum(1 for r in results if r["status"] == "sent") + + return { + "success": sent == total, + "results": results, + "total": total, + "sent": sent, + "failed": total - sent + }