Skip to content

Commit 56b48b2

Browse files
committed
feat: add notices feature with API, database model, and UI integration
1 parent a745c59 commit 56b48b2

10 files changed

Lines changed: 304 additions & 44 deletions

File tree

api/routers.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from domain.config import api as config
77
from domain.email import api as email
88
from domain.offline_downloads import api as offline_downloads
9+
from domain.notices import api as notices
910
from domain.plugins import api as plugins
1011
from domain.processors import api as processors
1112
from domain.share import api as share
@@ -41,6 +42,7 @@ def include_routers(app: FastAPI):
4142
app.include_router(webdav_api.router)
4243
app.include_router(s3_api.router)
4344
app.include_router(offline_downloads.router)
45+
app.include_router(notices.router)
4446
app.include_router(email.router)
4547
app.include_router(audit.router)
4648
app.include_router(permission.router)

domain/notices/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .service import NoticeService, notice_sync_service
2+
3+
__all__ = ["NoticeService", "notice_sync_service"]

domain/notices/api.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
from typing import Annotated
2+
3+
from fastapi import APIRouter, Depends, Query
4+
5+
from api.response import success
6+
from domain.auth import User, get_current_active_user
7+
8+
from .service import NoticeService
9+
10+
router = APIRouter(prefix="/api/notices", tags=["notices"])
11+
12+
13+
@router.get("")
14+
async def list_notices(
15+
current_user: Annotated[User, Depends(get_current_active_user)],
16+
page: int = Query(1, ge=1),
17+
):
18+
data = await NoticeService.list_notices(page=page)
19+
return data.model_dump()
20+
21+
22+
@router.get("/popup")
23+
async def get_popup_notice(
24+
current_user: Annotated[User, Depends(get_current_active_user)],
25+
):
26+
item = await NoticeService.get_popup_notice()
27+
return success(item.model_dump() if item else None)
28+
29+
30+
@router.post("/{notice_id}/dismiss")
31+
async def dismiss_popup_notice(
32+
notice_id: int,
33+
current_user: Annotated[User, Depends(get_current_active_user)],
34+
):
35+
await NoticeService.dismiss_popup(notice_id)
36+
return success()

domain/notices/service.py

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import asyncio
2+
import logging
3+
from datetime import datetime, timezone
4+
from typing import Any
5+
6+
import httpx
7+
8+
from domain.config import VERSION
9+
from models.database import Notice
10+
11+
from .types import NoticeItem, NoticeListResponse
12+
13+
logger = logging.getLogger(__name__)
14+
15+
REMOTE_NOTICES_URL = "https://foxel.cc/api/notices"
16+
SYNC_INTERVAL_SECONDS = 60 * 60 * 24
17+
PAGE_SIZE = 20
18+
19+
20+
def _normalize_version(version: str) -> str:
21+
return (version or "").strip().removeprefix("v").removeprefix("V")
22+
23+
24+
def _parse_remote_time(value: Any) -> datetime:
25+
if isinstance(value, (int, float)):
26+
timestamp = float(value)
27+
if timestamp > 10_000_000_000:
28+
timestamp = timestamp / 1000
29+
return datetime.fromtimestamp(timestamp, timezone.utc)
30+
if isinstance(value, str):
31+
text = value.strip()
32+
if not text:
33+
return datetime.now(timezone.utc)
34+
try:
35+
if text.isdigit():
36+
return _parse_remote_time(int(text))
37+
return datetime.fromisoformat(text.replace("Z", "+00:00"))
38+
except ValueError:
39+
return datetime.now(timezone.utc)
40+
return datetime.now(timezone.utc)
41+
42+
43+
class NoticeService:
44+
@classmethod
45+
async def list_notices(cls, page: int = 1, page_size: int = PAGE_SIZE) -> NoticeListResponse:
46+
page = max(1, page)
47+
page_size = max(1, min(page_size, 100))
48+
query = Notice.all().order_by("-created_at", "-id")
49+
total = await query.count()
50+
notices = await query.offset((page - 1) * page_size).limit(page_size)
51+
return NoticeListResponse(
52+
items=[cls._to_item(item) for item in notices],
53+
page=page,
54+
pageSize=page_size,
55+
total=total,
56+
)
57+
58+
@classmethod
59+
async def get_popup_notice(cls) -> NoticeItem | None:
60+
notice = await Notice.filter(is_popup=True, popup_dismissed=False).order_by("-created_at", "-id").first()
61+
if not notice:
62+
return None
63+
return cls._to_item(notice)
64+
65+
@classmethod
66+
async def dismiss_popup(cls, notice_id: int) -> None:
67+
await Notice.filter(id=notice_id).update(popup_dismissed=True, is_popup=False)
68+
69+
@classmethod
70+
async def sync_remote_notices(cls) -> None:
71+
items = await cls._fetch_remote_notices()
72+
if not items:
73+
return
74+
75+
popup_remote_ids: list[int] = []
76+
for raw in items:
77+
remote_id = raw.get("id")
78+
if remote_id is None:
79+
continue
80+
try:
81+
remote_id = int(remote_id)
82+
except (TypeError, ValueError):
83+
continue
84+
85+
is_popup = bool(raw.get("isPopup"))
86+
if is_popup:
87+
popup_remote_ids.append(remote_id)
88+
89+
notice = await Notice.get_or_none(remote_id=remote_id)
90+
popup_dismissed = notice.popup_dismissed if notice else False
91+
await Notice.update_or_create(
92+
remote_id=remote_id,
93+
defaults={
94+
"title": str(raw.get("title") or "")[:255],
95+
"content_md": str(raw.get("contentMd") or ""),
96+
"is_popup": is_popup and not popup_dismissed,
97+
"created_at": _parse_remote_time(raw.get("createdAt")),
98+
},
99+
)
100+
101+
await cls._keep_only_latest_popup(popup_remote_ids)
102+
103+
@classmethod
104+
async def _keep_only_latest_popup(cls, popup_remote_ids: list[int]) -> None:
105+
latest = await Notice.filter(remote_id__in=popup_remote_ids, popup_dismissed=False).order_by(
106+
"-created_at", "-id"
107+
).first()
108+
if not latest:
109+
return
110+
await Notice.filter(is_popup=True).exclude(id=latest.id).update(is_popup=False)
111+
112+
@classmethod
113+
async def _fetch_remote_notices(cls) -> list[dict[str, Any]]:
114+
results: list[dict[str, Any]] = []
115+
page = 1
116+
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
117+
while True:
118+
resp = await client.get(
119+
REMOTE_NOTICES_URL,
120+
params={"version": _normalize_version(VERSION), "page": page},
121+
)
122+
resp.raise_for_status()
123+
data = resp.json()
124+
items = data.get("items") if isinstance(data, dict) else None
125+
if not isinstance(items, list):
126+
break
127+
results.extend(item for item in items if isinstance(item, dict))
128+
129+
total = data.get("total", len(results)) if isinstance(data, dict) else len(results)
130+
page_size = data.get("pageSize") or data.get("page_size") or len(items)
131+
if not items or len(results) >= int(total or 0) or page_size <= 0:
132+
break
133+
page += 1
134+
return results
135+
136+
@staticmethod
137+
def _to_item(notice: Notice) -> NoticeItem:
138+
return NoticeItem(
139+
id=notice.id,
140+
title=notice.title,
141+
contentMd=notice.content_md or "",
142+
isPopup=notice.is_popup and not notice.popup_dismissed,
143+
createdAt=int(notice.created_at.timestamp() * 1000),
144+
)
145+
146+
147+
class NoticeSyncService:
148+
def __init__(self):
149+
self._worker: asyncio.Task | None = None
150+
self._stop_event = asyncio.Event()
151+
152+
async def start(self) -> None:
153+
if self._worker and not self._worker.done():
154+
return
155+
self._stop_event.clear()
156+
self._worker = asyncio.create_task(self._run_loop())
157+
158+
async def stop(self) -> None:
159+
if not self._worker:
160+
return
161+
self._stop_event.set()
162+
await self._worker
163+
self._worker = None
164+
165+
async def _run_loop(self) -> None:
166+
while not self._stop_event.is_set():
167+
try:
168+
await NoticeService.sync_remote_notices()
169+
except Exception:
170+
logger.exception("Failed to sync notices")
171+
try:
172+
await asyncio.wait_for(self._stop_event.wait(), timeout=SYNC_INTERVAL_SECONDS)
173+
except asyncio.TimeoutError:
174+
pass
175+
176+
177+
notice_sync_service = NoticeSyncService()

domain/notices/types.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from pydantic import BaseModel
2+
3+
4+
class NoticeItem(BaseModel):
5+
id: int
6+
title: str
7+
contentMd: str
8+
isPopup: bool
9+
createdAt: int
10+
11+
12+
class NoticeListResponse(BaseModel):
13+
items: list[NoticeItem]
14+
page: int
15+
pageSize: int
16+
total: int

main.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from dotenv import load_dotenv
2424
from domain.tasks import task_queue_service, task_scheduler
2525
from domain.role.service import RoleService
26+
from domain.notices import notice_sync_service
2627

2728
load_dotenv()
2829

@@ -77,6 +78,7 @@ async def lifespan(app: FastAPI):
7778
from domain.plugins import init_plugins
7879
await init_plugins(app)
7980
await task_scheduler.start()
81+
await notice_sync_service.start()
8082

8183
# 在所有路由加载完成后,挂载静态文件服务(放在最后以避免覆盖 API 路由)
8284
app.mount("/", SPAStaticFiles(directory="web/dist", html=True, check_dir=False), name="static")
@@ -85,6 +87,7 @@ async def lifespan(app: FastAPI):
8587
try:
8688
yield
8789
finally:
90+
await notice_sync_service.stop()
8891
await task_scheduler.stop()
8992
await task_queue_service.stop_worker()
9093
await close_db()

models/database.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,20 @@ class Meta:
247247
unique_together = (("user", "path"),)
248248

249249

250+
class Notice(Model):
251+
id = fields.IntField(pk=True)
252+
remote_id = fields.IntField(unique=True, index=True)
253+
title = fields.CharField(max_length=255)
254+
content_md = fields.TextField(null=True)
255+
is_popup = fields.BooleanField(default=False)
256+
popup_dismissed = fields.BooleanField(default=False)
257+
created_at = fields.DatetimeField()
258+
updated_at = fields.DatetimeField(auto_now=True)
259+
260+
class Meta:
261+
table = "notices"
262+
263+
250264
class Plugin(Model):
251265
id = fields.IntField(pk=True)
252266
key = fields.CharField(max_length=100, unique=True) # 插件唯一标识

web/src/api/notices.ts

Lines changed: 9 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import request from './client';
2+
13
export interface NoticeItem {
24
id: number;
35
title: string;
@@ -14,42 +16,17 @@ export interface GetNoticesResponse {
1416
}
1517

1618
export interface GetNoticesParams {
17-
version: string;
1819
page?: number;
1920
}
2021

21-
const FOXEL_CORE_BASE = 'https://foxel.cc';
22-
23-
function normalizeVersion(version: string) {
24-
return (version || '').trim().replace(/^v/i, '');
25-
}
26-
27-
function extractErrorMessage(data: any) {
28-
if (!data) return '';
29-
if (typeof data === 'string') return data;
30-
if (typeof data.detail === 'string') return data.detail;
31-
if (typeof data.code === 'string') return data.code;
32-
if (typeof data.message === 'string') return data.message;
33-
if (typeof data.msg === 'string') return data.msg;
34-
return '';
35-
}
36-
3722
export const noticesApi = {
3823
list: async (params: GetNoticesParams): Promise<GetNoticesResponse> => {
39-
const url = new URL('/api/notices', FOXEL_CORE_BASE);
40-
url.searchParams.set('version', normalizeVersion(params.version));
41-
url.searchParams.set('page', String(params.page ?? 1));
42-
43-
const resp = await fetch(url.href);
44-
if (!resp.ok) {
45-
let msg = resp.statusText || `Request failed: ${resp.status}`;
46-
try {
47-
const data = await resp.json();
48-
msg = extractErrorMessage(data) || msg;
49-
} catch { void 0; }
50-
throw new Error(msg);
51-
}
52-
return await resp.json();
24+
return await request<GetNoticesResponse>(`/notices?page=${params.page ?? 1}`);
25+
},
26+
getPopup: async (): Promise<NoticeItem | null> => {
27+
return await request<NoticeItem | null>('/notices/popup');
28+
},
29+
dismiss: async (id: number): Promise<void> => {
30+
await request(`/notices/${id}/dismiss`, { method: 'POST' });
5331
},
5432
};
55-

web/src/components/NoticesModal.tsx

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@ import { useI18n } from '../i18n';
77

88
export interface NoticesModalProps {
99
open: boolean;
10-
version: string;
1110
onClose: () => void;
11+
initialNotice?: NoticeItem | null;
1212
}
1313

14-
const NoticesModal = memo(function NoticesModal({ open, version, onClose }: NoticesModalProps) {
14+
const NoticesModal = memo(function NoticesModal({ open, onClose, initialNotice }: NoticesModalProps) {
1515
const { token } = theme.useToken();
1616
const { t } = useI18n();
1717
const [items, setItems] = useState<NoticeItem[]>([]);
@@ -28,12 +28,15 @@ const NoticesModal = memo(function NoticesModal({ open, version, onClose }: Noti
2828
if (mode === 'replace') setLoading(true);
2929
else setLoadingMore(true);
3030
try {
31-
const resp = await noticesApi.list({ version, page: targetPage });
31+
const resp = await noticesApi.list({ page: targetPage });
3232
setPage(resp.page ?? targetPage);
3333
setTotal(resp.total ?? 0);
34-
setItems(prev => mode === 'replace' ? resp.items : [...prev, ...resp.items]);
34+
const nextItems = mode === 'replace' && initialNotice && !resp.items.some(item => item.id === initialNotice.id)
35+
? [initialNotice, ...resp.items]
36+
: resp.items;
37+
setItems(prev => mode === 'replace' ? nextItems : [...prev, ...resp.items]);
3538
if (mode === 'replace') {
36-
setSelectedId(resp.items[0]?.id ?? null);
39+
setSelectedId(initialNotice?.id ?? resp.items[0]?.id ?? null);
3740
} else {
3841
setSelectedId(prev => prev ?? resp.items[0]?.id ?? null);
3942
}
@@ -55,7 +58,7 @@ const NoticesModal = memo(function NoticesModal({ open, version, onClose }: Noti
5558
setSelectedId(null);
5659
loadPage(1, 'replace');
5760
// eslint-disable-next-line react-hooks/exhaustive-deps
58-
}, [open, version]);
61+
}, [open, initialNotice?.id]);
5962

6063
const formatTime = (ts: number) => {
6164
try {
@@ -181,4 +184,3 @@ const NoticesModal = memo(function NoticesModal({ open, version, onClose }: Noti
181184
});
182185

183186
export default NoticesModal;
184-

0 commit comments

Comments
 (0)