diff --git a/backend/__pycache__/main.cpython-310.pyc b/backend/__pycache__/main.cpython-310.pyc new file mode 100644 index 0000000..5749167 Binary files /dev/null and b/backend/__pycache__/main.cpython-310.pyc differ diff --git a/backend/__pycache__/schemas.cpython-310.pyc b/backend/__pycache__/schemas.cpython-310.pyc new file mode 100644 index 0000000..efe030e Binary files /dev/null and b/backend/__pycache__/schemas.cpython-310.pyc differ 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 0000000..f17b391 Binary files /dev/null and b/backend/routers/__pycache__/__init__.cpython-310.pyc differ diff --git a/backend/routers/__pycache__/channels.cpython-310.pyc b/backend/routers/__pycache__/channels.cpython-310.pyc new file mode 100644 index 0000000..020f778 Binary files /dev/null and b/backend/routers/__pycache__/channels.cpython-310.pyc differ diff --git a/backend/routers/__pycache__/history.cpython-310.pyc b/backend/routers/__pycache__/history.cpython-310.pyc new file mode 100644 index 0000000..8699e51 Binary files /dev/null and b/backend/routers/__pycache__/history.cpython-310.pyc differ diff --git a/backend/routers/__pycache__/notify.cpython-310.pyc b/backend/routers/__pycache__/notify.cpython-310.pyc new file mode 100644 index 0000000..142261f Binary files /dev/null and b/backend/routers/__pycache__/notify.cpython-310.pyc differ 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 + }