refactor(push): unify notification payload structure and handling

Refactor the notification handling logic to use a unified `topic` based payload structure. This change replaces the flat `NotificationData` interface with a typed `topic` and `data` separation, enabling stricter type checking for different notification types (e.g., `SERVER_PUSH`).

Key changes:
- Updated `NotificationData` type to include `topic` and `data` fields, referencing `TopicPayload`.
- Modified `sw.js` to parse incoming `MessagePayload` using `fromMessagePayload`, ensuring the topic is `SERVER_PUSH` before extracting `title` and `options`.
- Renamed internal types in `utils/common.ts` to `ServerSendTopicPayload` and `Topics` for clarity.
- Updated `firebase-admin.ts` to wrap data in a `payload` JSON string before sending to Firebase Cloud Messaging, and added logic to clean up unregistered tokens.
- Added backward compatibility support for older clients by sending a secondary legacy-formatted message when a `SERVER_PUSH` topic is detected.
- Moved `NOTIFICATION_TOKEN_KEY` constant to `utils/constants.ts` and updated related hooks to use it for consistency.
- Updated `logger.ts` to comment out file logging during development/testing to reduce disk I/O.

This refactor improves type safety, reduces code duplication in payload handling, and ensures better compatibility between the web client and the service worker.
This commit is contained in:
kyuuseiryuu 2026-03-27 01:05:01 +09:00
parent 0f3b981b0f
commit defd95c218
26 changed files with 324 additions and 454 deletions

View File

@ -46,11 +46,6 @@ export const BindKaiqiuAccount = () => {
}); });
}, [modal]); }, [modal]);
const navigate = useNavigate(); const navigate = useNavigate();
if (isBindReq.data?.isBinded === undefined) {
return (
<Skeleton.Input active />
);
};
if (isBindReq.data?.isBinded) { if (isBindReq.data?.isBinded) {
return ( return (
<Button type="text" onClick={() => navigate(`/profile/${isBindReq.data?.uid}`)}> <Button type="text" onClick={() => navigate(`/profile/${isBindReq.data?.uid}`)}>
@ -63,6 +58,11 @@ export const BindKaiqiuAccount = () => {
</Button> </Button>
); );
} }
if (isBindReq.loading) {
return (
<Skeleton.Input active />
);
}
return ( return (
<> <>
<Button <Button

View File

@ -69,6 +69,10 @@ export const ClubEvenList = (props: Props) => {
<Divider> <Divider>
{paginationControlVisible ? ( {paginationControlVisible ? (
<Space> <Space>
<Typography.Text type="secondary"></Typography.Text>
<Switch checked={showFinishedEvents} onChange={() => setShowFinishedEvents(!showFinishedEvents)} unCheckedChildren="隐藏" checkedChildren="显示" />
</Space>
) : (
<Button <Button
loading={requestEvents.loading} loading={requestEvents.loading}
type="link" type="link"
@ -77,10 +81,7 @@ export const ClubEvenList = (props: Props) => {
> >
</Button> </Button>
<Typography.Text type="secondary"></Typography.Text> )}
<Switch checked={showFinishedEvents} onChange={() => setShowFinishedEvents(!showFinishedEvents)} unCheckedChildren="隐藏" checkedChildren="显示" />
</Space>
) : null}
</Divider> </Divider>
<Flex wrap vertical gap={12} justify="center" align="center"> <Flex wrap vertical gap={12} justify="center" align="center">
<Button <Button

View File

@ -4,14 +4,13 @@ import dayjs from "dayjs";
import utc from 'dayjs/plugin/utc'; import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone'; import timezone from 'dayjs/plugin/timezone';
import { BellFilled, BellOutlined, EyeOutlined } from "@ant-design/icons"; import { BellFilled, BellOutlined, EyeOutlined } from "@ant-design/icons";
import { useCallback, useContext, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import type { TimerType } from "antd/lib/statistic/Timer"; import type { TimerType } from "antd/lib/statistic/Timer";
import { useLogto } from "@logto/react"; import { useLogto } from "@logto/react";
import { useAuthHeaders } from "@/hooks/useAuthHeaders"; import { useAuthHeaders } from "@/hooks/useAuthHeaders";
import { useRequest } from "ahooks"; import { useRequest } from "ahooks";
import { WebScoketContext } from "@/context/WebsocketContext"; import useAutoLogin from "@/hooks/useAutoLogin";
import { getEventSubKey } from "@/utils/common";
dayjs.extend(utc); dayjs.extend(utc);
dayjs.extend(timezone); dayjs.extend(timezone);
@ -23,6 +22,7 @@ interface EventCardProps {
export function EventCard(props: EventCardProps) { export function EventCard(props: EventCardProps) {
const { eventInfo: e } = props; const { eventInfo: e } = props;
const day = useMemo(() => dayjs.tz(e.startDate, 'Asia/Tokyo'), [e]); const day = useMemo(() => dayjs.tz(e.startDate, 'Asia/Tokyo'), [e]);
const { isAuthExpired } = useAutoLogin();
const navigate = useNavigate(); const navigate = useNavigate();
const handleView = useCallback(() => { const handleView = useCallback(() => {
navigate(`/event/${e.matchId}`); navigate(`/event/${e.matchId}`);
@ -64,10 +64,8 @@ export function EventCard(props: EventCardProps) {
const statistic = getStatisticProps(); const statistic = getStatisticProps();
setMessageFormat(statistic.format); setMessageFormat(statistic.format);
setStatisticType(statistic.type); setStatisticType(statistic.type);
// console.debug('format: %s', day.format(statistic.format), statistic);
}, [getStatisticProps]) }, [getStatisticProps])
const { isAuthenticated } = useLogto(); const { isAuthenticated } = useLogto();
const { messageSender } = useContext(WebScoketContext);
const headers = useAuthHeaders(); const headers = useAuthHeaders();
const isSubscried = useRequest(async () => { const isSubscried = useRequest(async () => {
return fetch(`/api/subscribe-event/${e.matchId}`, { return fetch(`/api/subscribe-event/${e.matchId}`, {
@ -93,14 +91,12 @@ export function EventCard(props: EventCardProps) {
setSubloading(true); setSubloading(true);
await subReq.runAsync(); await subReq.runAsync();
await isSubscried.runAsync(); await isSubscried.runAsync();
messageSender?.('SUB', { topic: getEventSubKey(e.matchId) });
setSubloading(false); setSubloading(false);
}, [e, subReq, isSubscried]); }, [e, subReq, isSubscried]);
const handleUnSub = useCallback(async () => { const handleUnSub = useCallback(async () => {
setSubloading(true); setSubloading(true);
await unSubReq.runAsync(); await unSubReq.runAsync();
await isSubscried.runAsync(); await isSubscried.runAsync();
messageSender?.('UNSUB', { topic: getEventSubKey(e.matchId) });
setSubloading(false); setSubloading(false);
}, [e, isSubscried, unSubReq]); }, [e, isSubscried, unSubReq]);
useEffect(() => { useEffect(() => {
@ -113,8 +109,10 @@ export function EventCard(props: EventCardProps) {
}, [updateMessageFormat]); }, [updateMessageFormat]);
const isSubBtnDisabled = useMemo(() => { const isSubBtnDisabled = useMemo(() => {
if (!isAuthenticated) return true; if (!isAuthenticated) return true;
if (isAuthExpired) return true;
if (subLoading) return true; if (subLoading) return true;
if (e.isFinished && !isSubscried.data) return true; if (e.isProcessing) return true;
if (e.isFinished) return true;
return false; return false;
}, [isAuthenticated, subLoading, isSubscried]); }, [isAuthenticated, subLoading, isSubscried]);
return ( return (
@ -130,7 +128,7 @@ export function EventCard(props: EventCardProps) {
icon={isSubscried.data ? <BellFilled style={{ color: 'yellow' }} /> :<BellOutlined />} icon={isSubscried.data ? <BellFilled style={{ color: 'yellow' }} /> :<BellOutlined />}
onClick={isSubscried.data ? handleUnSub : handleSub} onClick={isSubscried.data ? handleUnSub : handleSub}
> >
{isSubscried.data ? '取消提醒' : '提醒我'} {isAuthenticated ? isSubscried.data ? '取消关注' : '关注人员变动' : '登录后关注'}
</Button>, </Button>,
<Button <Button
type="link" type="link"

View File

@ -2,10 +2,11 @@ import { Outlet, useNavigation } from "react-router";
import { HydrateFallback } from "@/components/HydrateFallback"; import { HydrateFallback } from "@/components/HydrateFallback";
import { AppBar } from "@/components/AppBar"; import { AppBar } from "@/components/AppBar";
import styled from "styled-components"; import styled from "styled-components";
import { WebScoketContext } from "@/context/WebsocketContext";
import { Alert } from "antd"; import { Alert } from "antd";
import { useEffect } from "react"; import { useEffect } from "react";
import { initServiceWorker } from "@/utils/firebase-frontend"; import { initServiceWorker } from "@/utils/firebase-frontend";
import { useFirebaseNotificationProcessor } from "@/hooks/useFirebaseNotificationProcessor";
import { useAutoUpdateNotificationToken } from "@/hooks/useAutoUpdateNotificationToken";
const StyledContainer = styled.div` const StyledContainer = styled.div`
padding-bottom: 90px; padding-bottom: 90px;
@ -14,15 +15,17 @@ const StyledContainer = styled.div`
export const AppBarLayout = () => { export const AppBarLayout = () => {
const navigation = useNavigation(); const navigation = useNavigation();
const loading = navigation.state === 'loading'; const loading = navigation.state === 'loading';
useFirebaseNotificationProcessor();
useAutoUpdateNotificationToken();
useEffect(() => { useEffect(() => {
initServiceWorker(); initServiceWorker();
}, []); }, []);
return loading ? <HydrateFallback /> : (<StyledContainer> return loading ? <HydrateFallback /> : (<StyledContainer>
<WebScoketContext.Provider value={{}}> <>
<Alert.ErrorBoundary> <Alert.ErrorBoundary>
<Outlet /> <Outlet />
</Alert.ErrorBoundary> </Alert.ErrorBoundary>
<AppBar /> <AppBar />
</WebScoketContext.Provider> </>
</StyledContainer>); </StyledContainer>);
} }

View File

@ -1,6 +1,7 @@
import { Button, Drawer, Flex, Form, Switch, Typography } from "antd"; import { Button, Drawer, Flex, Form, Skeleton, Switch, Typography } from "antd";
import { useNotificationControl } from "@/hooks/useNotificationControl"; import { useNotificationControl } from "@/hooks/useNotificationControl";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import useAutoLogin from "@/hooks/useAutoLogin";
const NotificationPermissionControl = () => { const NotificationPermissionControl = () => {
const { const {
@ -11,6 +12,7 @@ const NotificationPermissionControl = () => {
handleEnableNotificationClick, handleEnableNotificationClick,
} = useNotificationControl(); } = useNotificationControl();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const { isAuthExpired, autoSignIn } = useAutoLogin();
const showEnableNotification = useCallback(() => { const showEnableNotification = useCallback(() => {
setOpen(true); setOpen(true);
}, []); }, []);
@ -20,7 +22,7 @@ const NotificationPermissionControl = () => {
{isSupported ? ( {isSupported ? (
<Switch <Switch
loading={loading} loading={loading}
disabled={!isSupported} disabled={!isSupported || isAuthExpired}
value={isNotificationEnable} value={isNotificationEnable}
onChange={checked => { onChange={checked => {
if (checked) { if (checked) {
@ -29,14 +31,17 @@ const NotificationPermissionControl = () => {
handleDisableNotificationClick(); handleDisableNotificationClick();
} }
}} }}
unCheckedChildren={isAuthExpired ? '登录已过期' : ''}
/> />
) : ( ) : (
<Skeleton loading={loading}>
<Flex align="center" justify="center" wrap> <Flex align="center" justify="center" wrap>
<Typography.Text type='secondary'> <Typography.Text type='secondary'>
</Typography.Text> </Typography.Text>
<Button type="link" onClick={showEnableNotification}></Button> <Button type="link" onClick={showEnableNotification}></Button>
</Flex> </Flex>
</Skeleton>
)} )}
</Form.Item> </Form.Item>
<Drawer <Drawer

View File

@ -9,6 +9,7 @@ const PermissionControlPanel = () => {
<> <>
<Button block icon={<NotificationOutlined />} onClick={() => setOpen(true)}></Button> <Button block icon={<NotificationOutlined />} onClick={() => setOpen(true)}></Button>
<Drawer <Drawer
forceRender
open={open} open={open}
placement="bottom" placement="bottom"
title={'权限设置'} title={'权限设置'}

View File

@ -1,8 +0,0 @@
import { createContext } from "react";
import type { WsWebSendTopicPayload, WsWebSendTopics } from "@/utils/common";
interface MessageSender {
messageSender?: <T extends WsWebSendTopics>(topic: T, data: WsWebSendTopicPayload[T]) => void;
};
export const WebScoketContext = createContext<MessageSender>({});

View File

@ -10,21 +10,21 @@ export const useAuthHeaders = (): HeadersInit | undefined => {
const { autoSignIn } = useAutoLogin(); const { autoSignIn } = useAutoLogin();
const app = App.useApp(); const app = App.useApp();
useEffect(() => { useEffect(() => {
if (isAuthenticated) { if (!isAuthenticated) return;
getAccessToken(LOGTO_RESOURCE).then(token => { getAccessToken(LOGTO_RESOURCE)
if (!token) { .then(token => {
if (token) {
setHeaders({ Authorization: `Bearer ${token}` })
return;
}
app.notification.warning({ app.notification.warning({
key: 'use-auth-headers-login-expired', key: 'use-auth-headers-login-expired',
title: '登陆已过期', title: '登陆已过期',
actions: [ actions: [
<Button onClick={() => autoSignIn() }></Button> <Button key='auto-signin' type="primary" onClick={() => autoSignIn() }></Button>
] ],
}) });
return;
}
setHeaders({ Authorization: `Bearer ${token}` })
}); });
}
}, [isAuthenticated]); }, [isAuthenticated]);
return headers; return headers;
} }

View File

@ -0,0 +1,35 @@
import { useEffect, useState } from "react";
import { useNotificationToken } from "@/hooks/useNotificationToken";
import { useNotificationTokenAPI } from "@/hooks/useNotificationTokenAPI";
import { NOTIFICATION_TOKEN_KEY } from "@/utils/constants";
export const useAutoUpdateNotificationToken = () => {
const [token] = useNotificationToken();
const {
isTokenSetedReq,
updateTokenReq,
unsetTokenReq,
} = useNotificationTokenAPI();
const [isNotificationEnable, setIsNotificationEnable] = useState<boolean>(false);
useEffect(() => {
if (!token) return;
isTokenSetedReq.runAsync(token).then(res => {
setIsNotificationEnable(res);
});
}, [token]);
useEffect(() => {
if (!token) return;
if (!isNotificationEnable) return;
const oldToken = localStorage.getItem(NOTIFICATION_TOKEN_KEY);
if (!oldToken) return;
console.debug('Try to auto update token, token changed: %s', oldToken !== token);
if (oldToken === token) return;
unsetTokenReq
.runAsync(oldToken)
.then(() => updateTokenReq.runAsync(token))
.then(() => {
}).then(() => {
console.debug('Auto udpate token', token);
});
}, [updateTokenReq, unsetTokenReq, isNotificationEnable, token]);
}

View File

@ -1,8 +1,8 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { onMessage } from "firebase/messaging"; import { onMessage } from "firebase/messaging";
import { App } from "antd"; import { App } from "antd";
import type { NotificationData } from "../types";
import { getFirebaseApp, getFirebaseMessaging } from "../utils/firebase-frontend"; import { getFirebaseApp, getFirebaseMessaging } from "../utils/firebase-frontend";
import { fromMessagePayload, type TopicPayload } from "@/utils/common";
export const useFirebaseNotificationProcessor = () => { export const useFirebaseNotificationProcessor = () => {
const { notification } = App.useApp(); const { notification } = App.useApp();
@ -11,18 +11,23 @@ export const useFirebaseNotificationProcessor = () => {
const messaging = getFirebaseMessaging(); const messaging = getFirebaseMessaging();
if (!app || !messaging) return; if (!app || !messaging) return;
const unsubscribe = onMessage(messaging, async (payload) => { const unsubscribe = onMessage(messaging, async (payload) => {
const data = payload.data as NotificationData; const msg = fromMessagePayload(payload);
if (!msg) return;
switch (msg.topic) {
case "SERVER_PUSH": {
const data = msg.data as TopicPayload['SERVER_PUSH'];
notification.success({ notification.success({
title: data.title, title: data.title,
description: data.body, description: data.options?.body,
icon: data.icon, // icon: data.options?.icon ? ,
onClick: () => { onClick: () => {
if (!data.url) return; if (!data.options?.data?.url) return;
open(data.url, '_blank'); // 打开通知 open(data.options?.data?.url, '_blank'); // 打开通知
}, },
}); });
}
}
}); });
return () => unsubscribe(); // 组件卸载时取消监听 return () => unsubscribe(); // 组件卸载时取消监听
}, []); }, []);
} }

View File

@ -1,58 +0,0 @@
import { useCallback, useEffect } from "react";
import { ensureTopicData, fromServerMessage } from "@/utils/common";
import { EVENT_WS_MESSAGE } from "@/utils/constants";
import { App } from "antd";
export const useServerMessageHandler = () => {
const { notification } = App.useApp();
const processCustomEvent = useCallback(async (msg: string) => {
const { topic, data } = fromServerMessage(msg);
console.debug('Handle ws message, topic: %s', topic, data);
switch (topic) {
case "MEMBER_CHANGE": {
break;
}
case "MY_CLIENT_ONLINE": {
break;
}
case "DEBUG_MSG":
break;
case "EVENT_MEMBER_CHANGE":
case "MSG": {
const msgData = ensureTopicData<'MSG'>(data);
const hasPermission = await Notification.requestPermission();
if (hasPermission === 'granted') {
const options = {
body: `有新的消息`,
icon: "https://example.com/icon.png", // 通知图标
badge: "https://example.com/badge.png", // 移动端状态栏图标
tag: `MSG`, // 相同 tag 的通知会覆盖,防止刷屏
renotify: true // 覆盖旧通知时是否再次振动/提醒
};
new Notification("新消息提醒", options);
return;
}
notification.info({
key: 'MSG',
title: 'You have a new message',
description: `from: ${msgData?.name}`
});
break;
}
case "SERVER_PUSH": {
const { title, options = {} } = ensureTopicData<'SERVER_PUSH'>(data) ?? {};
break;
}
default:
break;
}
}, []);
useEffect(() => {
const handler = (e: Event) => {
const { message } = (e as CustomEvent).detail;
processCustomEvent(message);
};
window.addEventListener(EVENT_WS_MESSAGE, handler);
return () => window.removeEventListener(EVENT_WS_MESSAGE, handler);
}, [processCustomEvent]);
};

View File

@ -1,49 +1,19 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo } from "react";
import { useAuthHeaders } from "./useAuthHeaders"; import { isNotificationSupport } from "@/utils/front";
import { useRequest } from "ahooks";
import { isNotificationSupport } from "../utils/front";
import { getFirebaseToken } from "../utils/firebase-frontend";
import { App } from "antd"; import { App } from "antd";
import { useNotificationToken } from "@/hooks/useNotificationToken";
import { useNotificationTokenAPI } from "@/hooks/useNotificationTokenAPI";
const MSG_KEY = 'NOTIFICATION_CONTROL_MSG'; const MSG_KEY = 'NOTIFICATION_CONTROL_MSG';
export const useNotificationControl = () => { export const useNotificationControl = () => {
const [token, setToken] = useState<string>(); const [token] = useNotificationToken();
const headers = useAuthHeaders(); const {
const updateTokenReq = useRequest(async () => { loading,
if (!token || !headers) return; isTokenSetedReq,
return fetch('/api/notification-token', { updateTokenReq,
headers, unsetTokenReq,
method: 'PUT', } = useNotificationTokenAPI();
body: JSON.stringify({ token }),
});
}, { manual: true, refreshDeps: [headers, token] });
const unsetTokenReq = useRequest(async () => {
if (!token || !headers) return;
return fetch('/api/notification-token', {
headers,
method: 'DELETE',
body: JSON.stringify({ token }),
});
}, { manual: true, refreshDeps: [headers, token] });
const isTokenSetedReq = useRequest<Boolean, [string]>(async (token) => {
if (!token || !headers) return false;
return fetch(`/api/notification-token?token=${token}`, {
headers,
method: 'GET',
})
.then(res => res.json())
.then(data => data.isSubscribed);
}, { manual: true, refreshDeps: [headers, token]});
const loading = useMemo(() => {
return updateTokenReq.loading || unsetTokenReq.loading || isTokenSetedReq.loading;
}, [updateTokenReq, unsetTokenReq, isTokenSetedReq]);
useEffect(() => {
if (!isNotificationSupport()) return;
getFirebaseToken()
.then(token => setToken(token))
.catch(() => {});
}, []);
useEffect(() => { useEffect(() => {
if (!token) return; if (!token) return;
isTokenSetedReq.runAsync(token); isTokenSetedReq.runAsync(token);
@ -64,19 +34,25 @@ export const useNotificationControl = () => {
} }
if (permission === 'granted') { if (permission === 'granted') {
message.open({ key: MSG_KEY, type: 'success', content: '已开启通知' }); message.open({ key: MSG_KEY, type: 'success', content: '已开启通知' });
await updateTokenReq.runAsync(); await updateTokenReq.runAsync(token);
await isTokenSetedReq.runAsync(token); await isTokenSetedReq.runAsync(token);
return; return;
} }
}, [updateTokenReq, isTokenSetedReq, token]); }, [updateTokenReq, isTokenSetedReq, token]);
const handleDisableNotificationClick = useCallback(async () => { const handleDisableNotificationClick = useCallback(async () => {
if (!token) return; if (!token) return;
await unsetTokenReq.runAsync(); await unsetTokenReq.runAsync(token);
await isTokenSetedReq.runAsync(token); await isTokenSetedReq.runAsync(token);
message.open({ key: MSG_KEY, type: 'success', content: '已取消通知' }); message.open({ key: MSG_KEY, type: 'success', content: '已取消通知' });
}, [unsetTokenReq, isTokenSetedReq, token]); }, [unsetTokenReq, isTokenSetedReq, token]);
const isNotificationEnable = useMemo(() => { const isNotificationEnable = useMemo(() => {
return Boolean(isTokenSetedReq.data); return Boolean(isTokenSetedReq.data);
}, [isTokenSetedReq]); }, [isTokenSetedReq]);
return { loading, isSupported, handleDisableNotificationClick, handleEnableNotificationClick, isNotificationEnable }; return {
loading,
isSupported,
isNotificationEnable,
handleDisableNotificationClick,
handleEnableNotificationClick,
};
}; };

View File

@ -0,0 +1,16 @@
import { getFirebaseToken } from "@/utils/firebase-frontend";
import { isNotificationSupport } from "@/utils/front";
import { useEffect, useState } from "react";
export const useNotificationToken = () => {
const [token, setToken] = useState<string>();
useEffect(() => {
if (!isNotificationSupport()) return;
getFirebaseToken()
.then(token => {
setToken(token);
})
.catch(() => {});
}, []);
return [token];
}

View File

@ -0,0 +1,44 @@
import { useRequest } from "ahooks";
import { useAuthHeaders } from "./useAuthHeaders";
import { useMemo } from "react";
import { NOTIFICATION_TOKEN_KEY } from "@/utils/constants";
export const useNotificationTokenAPI = () => {
const headers = useAuthHeaders();
const updateTokenReq = useRequest(async (token: string) => {
if (!token || !headers) return;
return fetch('/api/notification-token', {
headers,
method: 'PUT',
body: JSON.stringify({ token }),
}).then(() => {
localStorage.setItem(NOTIFICATION_TOKEN_KEY, token);
});
}, { manual: true, refreshDeps: [headers] });
const unsetTokenReq = useRequest(async (token: string) => {
if (!token || !headers) return;
return fetch('/api/notification-token', {
headers,
method: 'DELETE',
body: JSON.stringify({ token }),
});
}, { manual: true, refreshDeps: [headers] });
const isTokenSetedReq = useRequest<boolean, [string]>(async (token) => {
if (!token || !headers) return false;
return fetch(`/api/notification-token?token=${token}`, {
headers,
method: 'GET',
})
.then(res => res.json())
.then(data => data.isSubscribed);
}, { manual: true, refreshDeps: [headers]});
const loading = useMemo(() => {
return updateTokenReq.loading || unsetTokenReq.loading || isTokenSetedReq.loading;
}, [updateTokenReq, unsetTokenReq, isTokenSetedReq]);
return {
loading,
isTokenSetedReq,
updateTokenReq,
unsetTokenReq,
}
}

View File

@ -1,22 +1,20 @@
import { logger } from '@/utils/logger';
import { verifyLogtoToken, xcxApi } from "@/utils/server";
import ics from 'ics'; import ics from 'ics';
import index from "@/index.html";
import { getUidScore } from "@/services/uidScoreStore";
import { checkIsUserFav, favPlayer, listFavPlayers, unFavPlayer } from "@/services/favPlayerService";
import { BattleService } from "@/services/BattleService";
import { KaiqiuService } from "@/services/KaiqiuService";
import dayjs from "dayjs"; import dayjs from "dayjs";
import utc from 'dayjs/plugin/utc'; import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone'; import timezone from 'dayjs/plugin/timezone';
import type { IEventInfo, WsPaylaod } from "@/types"; import { logger } from '@/utils/logger';
import type { IEventInfo, NotificationData } from "@/types";
import { EventSubscribeService } from "@/services/EventSubscribeService"; import { EventSubscribeService } from "@/services/EventSubscribeService";
import { WebSocketService } from "@/services/WebsocketService";
import type { JWTPayload } from "jose";
import { prisma } from "@/prisma/db"; import { prisma } from "@/prisma/db";
import xprisma from "@/dao/xprisma"; import xprisma from "@/dao/xprisma";
import { EventWatchSchedule } from "@/schedules/EventWatchSchedule"; import { EventWatchSchedule } from "@/schedules/EventWatchSchedule";
import { sendNotification } from "@/utils/firebase-admin"; import { sendNotification } from "@/utils/firebase-admin";
import { verifyLogtoToken, xcxApi } from "@/utils/server";
import { getUidScore } from "@/services/uidScoreStore";
import { checkIsUserFav, favPlayer, listFavPlayers, unFavPlayer } from "@/services/favPlayerService";
import { BattleService } from "@/services/BattleService";
import { KaiqiuService } from "@/services/KaiqiuService";
import index from "@/index.html";
dayjs.extend(utc); dayjs.extend(utc);
dayjs.extend(timezone); dayjs.extend(timezone);
@ -30,7 +28,6 @@ const server = Bun.serve({
'/assets/*': async (req) => { '/assets/*': async (req) => {
const pathname = new URL(req.url).pathname; const pathname = new URL(req.url).pathname;
const filepath = `.${pathname}`; const filepath = `.${pathname}`;
logger.debug('Read file: %s', filepath);
if (!/^\/assets/.test(pathname)) return new Response(null, { status: 404 }); if (!/^\/assets/.test(pathname)) return new Response(null, { status: 404 });
const file = await Bun.file(filepath); const file = await Bun.file(filepath);
if (!await file.exists()) { if (!await file.exists()) {
@ -42,21 +39,15 @@ const server = Bun.serve({
const build = await Bun.build({ const build = await Bun.build({
entrypoints: ["./src/sw.ts"], entrypoints: ["./src/sw.ts"],
target: "browser", target: "browser",
// 如果你需要压缩,可以开启
minify: process.env.NODE_ENV === "production", minify: process.env.NODE_ENV === "production",
}); });
if (!build.success) { if (!build.success) {
return new Response("Build Error", { status: 500 }); return new Response("Build Error", { status: 500 });
} }
// 读取编译后的第一个输出文件(即 sw.js
const blob = build.outputs[0]; const blob = build.outputs[0];
return new Response(blob, { return new Response(blob, {
headers: { headers: {
"Content-Type": "application/javascript", "Content-Type": "application/javascript",
// 开发环境下禁用缓存,确保 SW 能及时更新
"Cache-Control": "no-cache", "Cache-Control": "no-cache",
"Service-Worker-Allowed": "/", "Service-Worker-Allowed": "/",
}, },
@ -93,7 +84,13 @@ const server = Bun.serve({
const [hasOldToken] = await Promise.all([ const [hasOldToken] = await Promise.all([
prisma.notificationToken.count({ where }).then(num => num > 0), prisma.notificationToken.count({ where }).then(num => num > 0),
]); ]);
await sendNotification(token, { title: '通知已注册!', url: 'https://tt.ksr.la/user-center' }); const notification: NotificationData = { topic: 'SERVER_PUSH', data: {
title: '通知已注册!',
options: {
data: { url: 'https://tt.ksr.la/user-center' },
}
} };
await sendNotification(token, notification);
if (hasOldToken) { if (hasOldToken) {
return Response.json({ return Response.json({
success: true, success: true,
@ -373,7 +370,9 @@ const server = Bun.serve({
'/api/account/bind': { '/api/account/bind': {
async GET(req) { async GET(req) {
const { sub } = await verifyLogtoToken(req.headers); const { sub } = await verifyLogtoToken(req.headers);
if (!sub) return Response.json(false); if (!sub) return Response.json({
isBinded: false,
});
const record = await prisma.userBind.findFirst({ const record = await prisma.userBind.findFirst({
where: { logto_uid: sub }, where: { logto_uid: sub },
}); });
@ -401,35 +400,6 @@ const server = Bun.serve({
}); });
} }
}, },
'/ws': {
async GET(req, server) {
const token = new URL(req.url).searchParams.get('token');
if (!token) return new Response('Not valid token', { status: 401 });
const user = await verifyLogtoToken(token) as Required<JWTPayload>;
if (!user.sub) return new Response('Not valid token', { status: 401 });
logger.debug('ws connect', user.sub);
server.upgrade(req, {
data: { user },
});
}
},
},
websocket: {
data: {} as WsPaylaod,
open(ws) {
WebSocketService.addConnection(ws);
},
message(ws, message) {
try {
WebSocketService.processMessage(ws, message);
} catch(e) {
logger.error('Parse message error', e, message);
}
},
close(ws, code, reason) {
logger.debug('close ws', code, reason)
WebSocketService.removeConnection(ws);
},
}, },
development: process.env.NODE_ENV !== "production" && { development: process.env.NODE_ENV !== "production" && {
@ -444,4 +414,4 @@ const server = Bun.serve({
const eventSchedule = new EventWatchSchedule(); const eventSchedule = new EventWatchSchedule();
eventSchedule.start(); eventSchedule.start();
logger.info(`🚀 Server running at ${server.url}`, server); console.debug(`🚀 Server running at ${server.url}`);

View File

@ -10,6 +10,7 @@ import { useAppVersion } from "@/hooks/useAppVersion";
import { BindKaiqiuAccount } from "@/components/BindKaiqiuAccount"; import { BindKaiqiuAccount } from "@/components/BindKaiqiuAccount";
import { ChangeBackground } from "@/components/ChangeBackground"; import { ChangeBackground } from "@/components/ChangeBackground";
import PermissionControlPanel from "@/components/PermissionControlPanel"; import PermissionControlPanel from "@/components/PermissionControlPanel";
import { useAuthHeaders } from "@/hooks/useAuthHeaders";
enum modifyRoutes { enum modifyRoutes {
username = '/account/username', username = '/account/username',
@ -40,7 +41,7 @@ export const UserCenter = () => {
const { signIn, isAuthenticated, signOut, getIdTokenClaims } = useLogto(); const { signIn, isAuthenticated, signOut, getIdTokenClaims } = useLogto();
const { autoSignIn } = useAutoLogin(); const { autoSignIn } = useAutoLogin();
const [user, setUser] = useState<IdTokenClaims>(); const [user, setUser] = useState<IdTokenClaims>();
const navigate = useNavigate(); useAuthHeaders();
const location = useLocation(); const location = useLocation();
const handleSignIn = useCallback(() => { const handleSignIn = useCallback(() => {
signIn(AUTH_CALLBACK_URL); signIn(AUTH_CALLBACK_URL);

View File

@ -1,11 +1,11 @@
import * as nodeSchedule from 'node-schedule'; import * as nodeSchedule from 'node-schedule';
import { EventSubscribeService } from '../services/EventSubscribeService'; import { EventSubscribeService } from '@/services/EventSubscribeService';
import type { EventDetail, Player } from '../types'; import type { EventDetail, NotificationData, Player } from '@/types';
import { redis } from '../utils/server'; import { redis } from '@/utils/server';
import { diffArrays } from '../utils/common'; import { diffArrays } from '@/utils/common';
import { sendNotification } from '../utils/firebase-admin'; import { sendNotification } from '@/utils/firebase-admin';
import { prisma } from '../prisma/db'; import { prisma } from '@/prisma/db';
import type { Schedule } from './schedule'; import type { Schedule } from '@/schedules/schedule';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import utc from "dayjs/plugin/utc"; import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone"; import timezone from "dayjs/plugin/timezone";
@ -69,9 +69,19 @@ export class EventWatchSchedule implements Schedule {
const tokens = await prisma.notificationToken const tokens = await prisma.notificationToken
.findMany({ where: { logto_uid: { in: uids }}, select: { token: true } }) .findMany({ where: { logto_uid: { in: uids }}, select: { token: true } })
.then(v => v.map(e => e.token)); .then(v => v.map(e => e.token));
await Promise.all(tokens.map(token => sendNotification(token, { title, body, url }))); const payload: NotificationData = {
topic: 'SERVER_PUSH',
data: {
title,
options: { body, data: { url }},
},
};
await Promise.all(tokens.map(token => sendNotification(token, payload)));
} }
start() { start() {
if (process.env.NODE_ENV !== 'product') {
return;
}
const job = nodeSchedule.scheduleJob('event-watch', { const job = nodeSchedule.scheduleJob('event-watch', {
minute: [0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55], minute: [0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55],
hour: [7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22], hour: [7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22],

View File

@ -1,7 +1,8 @@
import { logger } from "@/utils/logger"; import { logger } from "@/utils/logger";
import { prisma } from "../prisma/db"; import { prisma } from "../prisma/db";
import type { EventDetail } from "../types"; import type { EventDetail, NotificationData } from "../types";
import { KaiqiuService } from "./KaiqiuService"; import { KaiqiuService } from "./KaiqiuService";
import { broadcast } from "@/utils/firebase-admin";
export class EventSubscribeService { export class EventSubscribeService {
public static async sub(user: string, event: string) { public static async sub(user: string, event: string) {
@ -12,6 +13,18 @@ export class EventSubscribeService {
logger.error('Subscribe event faild', { user, event, e }); logger.error('Subscribe event faild', { user, event, e });
return false; return false;
}); });
const { title } = await KaiqiuService.getMatchSummary(event);
const payload = {
topic: 'SERVER_PUSH',
data: {
title: '订阅成功',
options: {
body: title,
data: { url: `https://tt.ksr.la/event/${event}` },
},
}
} as NotificationData;
broadcast(user, payload);
return success; return success;
} }
public static async unSub(user: string, event: string) { public static async unSub(user: string, event: string) {

View File

@ -225,7 +225,7 @@ export class KaiqiuService {
public static async login(username: string, password: string) { public static async login(username: string, password: string) {
const loginPageRes = await fetch('https://kaiqiuwang.cc/home/do.php?ac=668g&&ref'); const loginPageRes = await fetch('https://kaiqiuwang.cc/home/do.php?ac=668g&&ref');
const cookies = loginPageRes.headers.getSetCookie().map(c => c.split(';')[0]).join('; '); const cookies = loginPageRes.headers.getSetCookie().map(c => c.split(';')[0]).join('; ');
logger.debug('Bind user', { cookies, username, password }); logger.debug('Bind user', { username });
let $ = cheerio.load(await loginPageRes.text()); let $ = cheerio.load(await loginPageRes.text());
const loginsubmit = $('#loginsubmit').val()?.toString() || ''; const loginsubmit = $('#loginsubmit').val()?.toString() || '';
const formhash = $('#loginform > input[type=hidden]').val()?.toString() || ''; const formhash = $('#loginform > input[type=hidden]').val()?.toString() || '';

View File

@ -1,154 +0,0 @@
import { ensureTopicData, fromCustomMessage, getEventSubKey, toWebProcessMessage, type WsServerSendTopicPayload, type WsServerSendTopics, type WsTopicPayload } from "../utils/common";
import { EventSubscribeService } from "./EventSubscribeService";
import type { WsPaylaod } from "../types";
const publicTopics: WsServerSendTopics[] = [
'MEMBER_CHANGE',
'MY_CLIENT_ONLINE',
'DEBUG_MSG',
'EVENT_MEMBER_CHANGE',
"MSG",
];
type BunServerWebSocket = Bun.ServerWebSocket<WsPaylaod>;
export class WebSocketService {
static #connections = new Set<BunServerWebSocket>();
static #userSubTopics = new Map<string, Set<string>>();
static #userClients = new Map<string, Set<BunServerWebSocket>>();
static async addConnection(ws: BunServerWebSocket) {
this.#connections.add(ws);
const user = ws.data.user;
let isNewMember = false;
if (!this.#userClients.has(user.sub)) {
this.#userClients.set(user.sub, new Set());
isNewMember = true;
}
this.#userClients.get(user.sub)?.add(ws);
await this.#initSubscribe(ws);
if (isNewMember) {
this.broadcast('MEMBER_CHANGE', this.#userClients.size ?? 0);
}
this.userClientsBroadcast(
ws.data.user.sub,
'MY_CLIENT_ONLINE',
this.#userClients.get(user.sub)?.size ?? 0,
);
this.userClientsBroadcast(
ws.data.user.sub,
'DEBUG_MSG',
`SubscribeKeys:\n${[...(this.#userSubTopics.get(ws.data.user.sub) ?? [])].join('|')}`,
);
}
static async #initSubscribe(ws: BunServerWebSocket) {
const user = ws.data.user;
const subEvets = await EventSubscribeService.getEvents(user.sub).then(e => e.map(v => getEventSubKey(v)));
// this.userSub(ws, user.sub, publicTopics);
this.userSub(ws, user.sub, subEvets);
// this.userSub(ws, user.sub, [`MSG:${user.sub}`]);
}
static publish(ws: BunServerWebSocket, topic: string, message: string, withSelf?: boolean) {
ws.publish(topic, message);
if (withSelf) {
ws.send(message);
}
}
static async userClientsBroadcast<T extends WsServerSendTopics>(user: string, topic: T, data: WsServerSendTopicPayload[T]) {
this.#userClients.get(user)?.forEach(ws => {
if (!ws.isSubscribed(topic)) return;
ws.send(JSON.stringify({ topic, data }));
});
}
static removeConnection(ws: BunServerWebSocket) {
this.#connections.delete(ws);
console.debug('Someone disconnected. User: ', ws.data.user.sub);
this.#userClients.get(ws.data.user.sub)?.delete(ws);
if (this.#userClients.get(ws.data.user.sub)?.size === 0) {
this.#userClients.delete(ws.data.user.sub);
this.publish(ws, 'MEMBER_CHANGE', toWebProcessMessage('MEMBER_CHANGE', this.#userClients.size));
}
this.userClientsBroadcast(
ws.data.user.sub,
'MY_CLIENT_ONLINE',
this.#userClients.get(ws.data.user.sub)?.size ?? 0,
);
}
static userSub(ws: BunServerWebSocket, user: string, topics: string[]) {
if (!this.#userSubTopics.has(user)) {
this.#userSubTopics.set(user, new Set<string>());
}
if (!topics.length) return;
topics.forEach(topic => {
this.#userSubTopics.get(user)?.add(topic);
ws.subscribe(topic);
});
}
static userNotificate<T extends WsServerSendTopics>(user: string, topic: T, data: WsTopicPayload[T]) {
const connections = this.#userClients.get(user);
if (!connections?.size) return;
connections.forEach(con => {
if (con.isSubscribed(topic)) {
con.sendText(toWebProcessMessage(topic, data));
}
});
}
static broadcast<T extends WsServerSendTopics>(topic: T, data: WsTopicPayload[T], checkTopic?: T | string) {
console.debug('broadcast', { topic, data });
this.#connections.values().forEach((con, i) => {
const isSubscribed = con.isSubscribed(topic || checkTopic);
console.debug(`CheckTopicSubscribed`, {
[topic]: con.isSubscribed(topic),
...checkTopic ? { [checkTopic]: con.isSubscribed(checkTopic) } : {},
});
if (isSubscribed) {
console.debug('Send broadcast to [%s]: %s', i + 1, con.data.user.sub, { topic, data });
con.send(toWebProcessMessage(topic, data));
}
});
}
static processMessage(ws: BunServerWebSocket, message: string | Buffer<ArrayBuffer>) {
const { clientTopic: action, data } = fromCustomMessage(message.toString());
if (!action) return;
console.debug('Recieve action: %s', action, message);
switch (action) {
case 'SUB': {
const topic = ensureTopicData<'SUB'>(data)?.topic ?? '';
ws.subscribe(topic);
if (!this.#userSubTopics.get(ws.data.user.sub)) {
this.#userSubTopics.set(ws.data.user.sub, new Set());
}
this.#userSubTopics.get(ws.data.user.sub)?.add(topic);
console.debug('Subs', this.#userSubTopics.get(ws.data.user.sub));
this.userClientsBroadcast(ws.data.user.sub, 'DEBUG_MSG', `Some client subscribed to: ${topic}`);
break;
}
case 'UNSUB': {
const topic = ensureTopicData<'UNSUB'>(data)?.topic ?? '';
ws.unsubscribe(topic);
if (!this.#userSubTopics.get(ws.data.user.sub)) {
this.#userSubTopics.set(ws.data.user.sub, new Set());
}
this.#userSubTopics.get(ws.data.user.sub)?.delete(topic);
console.debug('Subs', this.#userSubTopics.get(ws.data.user.sub));
break;
}
case 'MSG': {
const msgData = ensureTopicData<'MSG'>(data);
if (!msgData) return;
this.broadcast('MSG', msgData, `MSG:${msgData.channel}`);
break;
}
default:
break;
}
}
}

View File

@ -5,6 +5,7 @@ import { getMessaging, onBackgroundMessage } from 'firebase/messaging/sw';
import { firebaseConfig } from "./utils/firebase"; import { firebaseConfig } from "./utils/firebase";
import type { NotificationData } from './types'; import type { NotificationData } from './types';
import type { MessagePayload } from 'firebase/messaging'; import type { MessagePayload } from 'firebase/messaging';
import { fromMessagePayload, type TopicPayload } from './utils/common';
declare const self: ServiceWorkerGlobalScope; declare const self: ServiceWorkerGlobalScope;
@ -25,15 +26,21 @@ self.addEventListener('activate', (event) => {
// 核心:处理后台消息 // 核心:处理后台消息
onBackgroundMessage(messaging, (payload: MessagePayload) => { onBackgroundMessage(messaging, (payload: MessagePayload) => {
const data = payload.data as NotificationData;
console.debug('[sw.js] 收到后台消息: ', JSON.stringify(payload)); console.debug('[sw.js] 收到后台消息: ', JSON.stringify(payload));
const notificationTitle = data.title; const msgPayload = fromMessagePayload<'SERVER_PUSH'>(payload);
if (!msgPayload) return;
const { topic, data } = msgPayload;
if (topic !== 'SERVER_PUSH') return;
const topicPayload = data as TopicPayload['SERVER_PUSH'];
const notificationTitle = topicPayload.title;
const options = topicPayload.options;
const notificationOptions = { const notificationOptions = {
body: data.body, ...options,
icon: data.icon, // 你的图标 // body: data.body,
data: { url: data.url } // 携带点击跳转的 URL // icon: data.icon, // 你的图标
// data: { url: data.url } // 携带点击跳转的 URL
}; };
self.registration.showNotification(notificationTitle, notificationOptions); self.registration.showNotification(notificationTitle, notificationOptions);
}); });
// console.log('sw', self); console.log('sw', self);

View File

@ -1,3 +1,4 @@
import type { TopicPayload, Topics } from '@/utils/common';
import type { JWTPayload } from 'jose'; import type { JWTPayload } from 'jose';
export * from './profile'; export * from './profile';
@ -106,8 +107,6 @@ export type WsPaylaod = {
} }
export type NotificationData = { export type NotificationData = {
title: string; topic: Topics;
body?: string; data: TopicPayload[Topics];
icon?: string;
url?: string;
} }

View File

@ -1,5 +1,6 @@
import type { NotificationData } from "@/types";
import type { MessagePayload } from "firebase/messaging";
import { chunk } from "lodash"; import { chunk } from "lodash";
import type { Player } from "../types";
export const KAIQIU_BASE_URL = `https://kaiqiuwang.cc`; export const KAIQIU_BASE_URL = `https://kaiqiuwang.cc`;
export const LOGTO_DOMAIN = 'https://logto.ksr.la'; export const LOGTO_DOMAIN = 'https://logto.ksr.la';
@ -88,15 +89,7 @@ export function calculate(winerScore: number, loserScore: number) {
} }
} }
export type WsServerSendTopicPayload = { export type ServerSendTopicPayload = {
MEMBER_CHANGE: number;
MY_CLIENT_ONLINE: number;
DEBUG_MSG: string;
EVENT_MEMBER_CHANGE: {
event: string;
memberNum: number;
};
MSG: { channel: string; name: string; avatar: string; message: string };
SERVER_PUSH: { SERVER_PUSH: {
title: string; title: string;
options?: NotificationOptions & { options?: NotificationOptions & {
@ -107,21 +100,12 @@ export type WsServerSendTopicPayload = {
}; };
} }
export type WsServerSendTopics = keyof WsServerSendTopicPayload; export type ServerSendTopics = keyof ServerSendTopicPayload;
export type WsWebSendTopicPayload = { export type TopicPayload = ServerSendTopicPayload;
UNKONOW: undefined; export type Topics = keyof TopicPayload;
SUB: { topic: string };
UNSUB: { topic: string };
MSG: { channel: string; name: string; avatar: string; message: string };
}
export type WsWebSendTopics = keyof WsWebSendTopicPayload; export function ensureTopicData<T extends Topics>(data: any): TopicPayload[T] | undefined {
export type WsTopicPayload = WsServerSendTopicPayload & WsWebSendTopicPayload;
export type WsTopics = keyof WsTopicPayload;
export function ensureTopicData<T extends WsTopics>(data: any): WsTopicPayload[T] | undefined {
return data; return data;
} }
@ -130,42 +114,12 @@ export function getEventSubKey(eventId: string) {
} }
export function toWebProcessMessage( export function toWebProcessMessage(
topic: WsTopics, topic: Topics,
data: WsTopicPayload[WsTopics], data: TopicPayload[Topics],
) { ) {
return JSON.stringify({ topic, data }); return JSON.stringify({ topic, data });
} }
export function fromCustomMessage(message: string): {
clientTopic: WsWebSendTopics;
data?: WsWebSendTopicPayload[WsWebSendTopics];
} {
try {
const { topic: clientTopic, data } = JSON.parse(message);
return { clientTopic, data };
} catch(e) {
return {
clientTopic: 'UNKONOW',
data: undefined,
};
}
}
export function fromServerMessage(message: string): {
topic: WsServerSendTopics;
data?: WsServerSendTopicPayload[WsServerSendTopics];
} {
try {
const { topic, data } = JSON.parse(message);
return { topic, data };
} catch(e) {
return {
topic: 'DEBUG_MSG',
data: `${e}`,
};
}
}
export function diffArrays(array: Set<string>, compare: Set<string>) { export function diffArrays(array: Set<string>, compare: Set<string>) {
const deletedSet = new Set(array); const deletedSet = new Set(array);
@ -182,3 +136,18 @@ export function diffArrays(array: Set<string>, compare: Set<string>) {
newMemberList: newSet.values().toArray(), newMemberList: newSet.values().toArray(),
} }
} }
export function fromMessagePayload<T extends Topics>(payload: MessagePayload) {
console.debug('收到消息: ', payload);
if ((!payload.data)) return null;
try {
const { topic, data }: NotificationData = JSON.parse(payload.data?.payload ?? '{}');
return {
topic,
data: data as TopicPayload[T],
};
} catch(e) {
console.error('Not a valid payload');
return null;
}
}

View File

@ -3,4 +3,5 @@ export const CLUB_SELECTOR_KEY = 'CLUB_SELECTOR';
export const STORE_PAGE_LIST_KEY = 'events-page-keys'; export const STORE_PAGE_LIST_KEY = 'events-page-keys';
export const MATCH_RESULT_MAP_KEY = 'match-result-map'; export const MATCH_RESULT_MAP_KEY = 'match-result-map';
export const EVENT_WS_MESSAGE = 'EVENT_WS_MESSAGE'; export const EVENT_WS_MESSAGE = 'EVENT_WS_MESSAGE';
export const NOTIFICATION_TOKEN_KEY = 'NOTIFICATION_TOKEN';
export const VAPI_PUBLIC_KEY = 'BEdP0MXtxreY9jH5fXorkMLHVDOW2N3UgFcOzHUEjDf9HxhLQkjuzHq05kMEs0Lpvez4xpTiKmBYcWwwzJyuN2c'; export const VAPI_PUBLIC_KEY = 'BEdP0MXtxreY9jH5fXorkMLHVDOW2N3UgFcOzHUEjDf9HxhLQkjuzHq05kMEs0Lpvez4xpTiKmBYcWwwzJyuN2c';

View File

@ -1,8 +1,11 @@
import admin from "firebase-admin"; import admin from "firebase-admin";
import { getMessaging } from "firebase-admin/messaging"; import { getMessaging } from "firebase-admin/messaging";
import type { NotificationData } from "@/types";
import type { TopicPayload } from "@/utils/common";
import { logger } from "@/utils/logger";
import { prisma } from "@/prisma/db";
import path from 'path'; import path from 'path';
import type { NotificationData } from "../types";
var serviceAccount = require(path.resolve(__dirname, '..', 'private', 'my-kaiqiuwang-firebase-adminsdk-fbsvc-8712e14c10.json')); var serviceAccount = require(path.resolve(__dirname, '..', 'private', 'my-kaiqiuwang-firebase-adminsdk-fbsvc-8712e14c10.json'));
@ -10,11 +13,44 @@ admin.initializeApp({
credential: admin.credential.cert(serviceAccount) credential: admin.credential.cert(serviceAccount)
}); });
export async function sendNotification(token: string, data: NotificationData, dryRun?: boolean) { export async function sendNotification(token: string, payload: NotificationData, dryRun?: boolean) {
if (!token) return; if (!token) return;
const messageId = await getMessaging().send({ const messaging = await getMessaging();
const messageid = await messaging.send({
token, token,
data: data as any, data: {
}, dryRun); payload: JSON.stringify(payload),
return messageId; },
}, dryRun).catch(async error => {
if (error.code === 'messaging/registration-token-not-registered') {
const result = await prisma.notificationToken.deleteMany({
where: { token },
});
logger.error('Delete notification token', { token, result });
}
});
logger.debug('sendNotification', { payload, dryRun, messageid });
if (!messageid) return;
if (payload.topic === 'SERVER_PUSH') {
// 旧版本通知
const { title, options } = payload.data as TopicPayload['SERVER_PUSH'];
const data = {
title,
body: options?.body ?? '',
url: options?.data?.url ?? '',
};
logger.debug('sendNotification fix old push', { data });
messaging.send({ token, data }).catch(error => {
logger.error('Send notification error', { error, token });
});
}
}
export async function broadcast(logto_uid: string, payload: NotificationData, dryRun?: boolean) {
const tokens = await prisma.notificationToken.findMany({
where: { logto_uid },
select: { token: true },
}).then(values => values.map(e => e.token));
await Promise.all(tokens.map(token => sendNotification(token, payload, dryRun)));
logger.debug('broadcast notification', { logto_uid });
} }

View File

@ -5,6 +5,6 @@ export const logger = winston.createLogger({
format: winston.format.json(), format: winston.format.json(),
transports: [ transports: [
new winston.transports.Console({ format: winston.format.json()}), new winston.transports.Console({ format: winston.format.json()}),
new winston.transports.File({ filename: 'logs/combined.log' }), // new winston.transports.File({ filename: 'logs/combined.log' }),
], ],
}); });