diff --git a/src/components/BindKaiqiuAccount.tsx b/src/components/BindKaiqiuAccount.tsx
index d234b2b..fc8e9fc 100644
--- a/src/components/BindKaiqiuAccount.tsx
+++ b/src/components/BindKaiqiuAccount.tsx
@@ -46,11 +46,6 @@ export const BindKaiqiuAccount = () => {
});
}, [modal]);
const navigate = useNavigate();
- if (isBindReq.data?.isBinded === undefined) {
- return (
-
- );
- };
if (isBindReq.data?.isBinded) {
return (
);
}
+ if (isBindReq.loading) {
+ return (
+
+ );
+ }
return (
<>
显示已结束的活动
setShowFinishedEvents(!showFinishedEvents)} unCheckedChildren="隐藏" checkedChildren="显示" />
- ) : null}
+ ) : (
+ }
+ >
+ 刷新
+
+ )}
dayjs.tz(e.startDate, 'Asia/Tokyo'), [e]);
+ const { isAuthExpired } = useAutoLogin();
const navigate = useNavigate();
const handleView = useCallback(() => {
navigate(`/event/${e.matchId}`);
@@ -64,10 +64,8 @@ export function EventCard(props: EventCardProps) {
const statistic = getStatisticProps();
setMessageFormat(statistic.format);
setStatisticType(statistic.type);
- // console.debug('format: %s', day.format(statistic.format), statistic);
}, [getStatisticProps])
const { isAuthenticated } = useLogto();
- const { messageSender } = useContext(WebScoketContext);
const headers = useAuthHeaders();
const isSubscried = useRequest(async () => {
return fetch(`/api/subscribe-event/${e.matchId}`, {
@@ -93,14 +91,12 @@ export function EventCard(props: EventCardProps) {
setSubloading(true);
await subReq.runAsync();
await isSubscried.runAsync();
- messageSender?.('SUB', { topic: getEventSubKey(e.matchId) });
setSubloading(false);
}, [e, subReq, isSubscried]);
const handleUnSub = useCallback(async () => {
setSubloading(true);
await unSubReq.runAsync();
await isSubscried.runAsync();
- messageSender?.('UNSUB', { topic: getEventSubKey(e.matchId) });
setSubloading(false);
}, [e, isSubscried, unSubReq]);
useEffect(() => {
@@ -113,8 +109,10 @@ export function EventCard(props: EventCardProps) {
}, [updateMessageFormat]);
const isSubBtnDisabled = useMemo(() => {
if (!isAuthenticated) return true;
+ if (isAuthExpired) 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;
}, [isAuthenticated, subLoading, isSubscried]);
return (
@@ -130,7 +128,7 @@ export function EventCard(props: EventCardProps) {
icon={isSubscried.data ? :}
onClick={isSubscried.data ? handleUnSub : handleSub}
>
- {isSubscried.data ? '取消提醒' : '提醒我'}
+ {isAuthenticated ? isSubscried.data ? '取消关注' : '关注人员变动' : '登录后关注'}
,
{
const navigation = useNavigation();
const loading = navigation.state === 'loading';
+ useFirebaseNotificationProcessor();
+ useAutoUpdateNotificationToken();
useEffect(() => {
initServiceWorker();
}, []);
return loading ? : (
-
+ <>
-
+ >
);
}
\ No newline at end of file
diff --git a/src/components/NotificationPermissionContorl.tsx b/src/components/NotificationPermissionContorl.tsx
index c391554..8147627 100644
--- a/src/components/NotificationPermissionContorl.tsx
+++ b/src/components/NotificationPermissionContorl.tsx
@@ -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 { useCallback, useState } from "react";
+import useAutoLogin from "@/hooks/useAutoLogin";
const NotificationPermissionControl = () => {
const {
@@ -11,6 +12,7 @@ const NotificationPermissionControl = () => {
handleEnableNotificationClick,
} = useNotificationControl();
const [open, setOpen] = useState(false);
+ const { isAuthExpired, autoSignIn } = useAutoLogin();
const showEnableNotification = useCallback(() => {
setOpen(true);
}, []);
@@ -20,7 +22,7 @@ const NotificationPermissionControl = () => {
{isSupported ? (
{
if (checked) {
@@ -29,14 +31,17 @@ const NotificationPermissionControl = () => {
handleDisableNotificationClick();
}
}}
+ unCheckedChildren={isAuthExpired ? '登录已过期' : ''}
/>
) : (
-
-
- 当前模式不支持通知功
-
- 查看开启方法
-
+
+
+
+ 当前模式不支持通知功
+
+ 查看开启方法
+
+
)}
{
<>
} onClick={() => setOpen(true)}>权限设置
(topic: T, data: WsWebSendTopicPayload[T]) => void;
-};
-
-export const WebScoketContext = createContext({});
\ No newline at end of file
diff --git a/src/hooks/useAuthHeaders.tsx b/src/hooks/useAuthHeaders.tsx
index e2e1b51..4bb0e68 100644
--- a/src/hooks/useAuthHeaders.tsx
+++ b/src/hooks/useAuthHeaders.tsx
@@ -10,21 +10,21 @@ export const useAuthHeaders = (): HeadersInit | undefined => {
const { autoSignIn } = useAutoLogin();
const app = App.useApp();
useEffect(() => {
- if (isAuthenticated) {
- getAccessToken(LOGTO_RESOURCE).then(token => {
- if (!token) {
- app.notification.warning({
- key: 'use-auth-headers-login-expired',
- title: '登陆已过期',
- actions: [
- autoSignIn() }>重新登陆
- ]
- })
+ if (!isAuthenticated) return;
+ getAccessToken(LOGTO_RESOURCE)
+ .then(token => {
+ if (token) {
+ setHeaders({ Authorization: `Bearer ${token}` })
return;
}
- setHeaders({ Authorization: `Bearer ${token}` })
+ app.notification.warning({
+ key: 'use-auth-headers-login-expired',
+ title: '登陆已过期',
+ actions: [
+ autoSignIn() }>重新登陆
+ ],
+ });
});
- }
}, [isAuthenticated]);
return headers;
}
\ No newline at end of file
diff --git a/src/hooks/useAutoUpdateNotificationToken.ts b/src/hooks/useAutoUpdateNotificationToken.ts
new file mode 100644
index 0000000..77d456b
--- /dev/null
+++ b/src/hooks/useAutoUpdateNotificationToken.ts
@@ -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(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]);
+}
\ No newline at end of file
diff --git a/src/hooks/useFirebaseNotificationProcessor.ts b/src/hooks/useFirebaseNotificationProcessor.ts
index 653c1ee..7cde682 100644
--- a/src/hooks/useFirebaseNotificationProcessor.ts
+++ b/src/hooks/useFirebaseNotificationProcessor.ts
@@ -1,8 +1,8 @@
import { useEffect } from "react";
import { onMessage } from "firebase/messaging";
import { App } from "antd";
-import type { NotificationData } from "../types";
import { getFirebaseApp, getFirebaseMessaging } from "../utils/firebase-frontend";
+import { fromMessagePayload, type TopicPayload } from "@/utils/common";
export const useFirebaseNotificationProcessor = () => {
const { notification } = App.useApp();
@@ -11,18 +11,23 @@ export const useFirebaseNotificationProcessor = () => {
const messaging = getFirebaseMessaging();
if (!app || !messaging) return;
const unsubscribe = onMessage(messaging, async (payload) => {
- const data = payload.data as NotificationData;
- notification.success({
- title: data.title,
- description: data.body,
- icon: data.icon,
- onClick: () => {
- if (!data.url) return;
- open(data.url, '_blank'); // 打开通知
- },
- });
+ const msg = fromMessagePayload(payload);
+ if (!msg) return;
+ switch (msg.topic) {
+ case "SERVER_PUSH": {
+ const data = msg.data as TopicPayload['SERVER_PUSH'];
+ notification.success({
+ title: data.title,
+ description: data.options?.body,
+ // icon: data.options?.icon ? ,
+ onClick: () => {
+ if (!data.options?.data?.url) return;
+ open(data.options?.data?.url, '_blank'); // 打开通知
+ },
+ });
+ }
+ }
});
-
return () => unsubscribe(); // 组件卸载时取消监听
}, []);
}
\ No newline at end of file
diff --git a/src/hooks/useHandlerServerMessage.tsx b/src/hooks/useHandlerServerMessage.tsx
deleted file mode 100644
index eda80e6..0000000
--- a/src/hooks/useHandlerServerMessage.tsx
+++ /dev/null
@@ -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]);
-};
\ No newline at end of file
diff --git a/src/hooks/useNotificationControl.ts b/src/hooks/useNotificationControl.ts
index c7d649e..fd2e189 100644
--- a/src/hooks/useNotificationControl.ts
+++ b/src/hooks/useNotificationControl.ts
@@ -1,49 +1,19 @@
-import { useCallback, useEffect, useMemo, useState } from "react";
-import { useAuthHeaders } from "./useAuthHeaders";
-import { useRequest } from "ahooks";
-import { isNotificationSupport } from "../utils/front";
-import { getFirebaseToken } from "../utils/firebase-frontend";
+import { useCallback, useEffect, useMemo } from "react";
+import { isNotificationSupport } from "@/utils/front";
import { App } from "antd";
+import { useNotificationToken } from "@/hooks/useNotificationToken";
+import { useNotificationTokenAPI } from "@/hooks/useNotificationTokenAPI";
const MSG_KEY = 'NOTIFICATION_CONTROL_MSG';
export const useNotificationControl = () => {
- const [token, setToken] = useState();
- const headers = useAuthHeaders();
- const updateTokenReq = useRequest(async () => {
- if (!token || !headers) return;
- return fetch('/api/notification-token', {
- headers,
- method: 'PUT',
- 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(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(() => {});
- }, []);
+ const [token] = useNotificationToken();
+ const {
+ loading,
+ isTokenSetedReq,
+ updateTokenReq,
+ unsetTokenReq,
+ } = useNotificationTokenAPI();
useEffect(() => {
if (!token) return;
isTokenSetedReq.runAsync(token);
@@ -64,19 +34,25 @@ export const useNotificationControl = () => {
}
if (permission === 'granted') {
message.open({ key: MSG_KEY, type: 'success', content: '已开启通知' });
- await updateTokenReq.runAsync();
+ await updateTokenReq.runAsync(token);
await isTokenSetedReq.runAsync(token);
return;
}
}, [updateTokenReq, isTokenSetedReq, token]);
const handleDisableNotificationClick = useCallback(async () => {
if (!token) return;
- await unsetTokenReq.runAsync();
+ await unsetTokenReq.runAsync(token);
await isTokenSetedReq.runAsync(token);
message.open({ key: MSG_KEY, type: 'success', content: '已取消通知' });
}, [unsetTokenReq, isTokenSetedReq, token]);
const isNotificationEnable = useMemo(() => {
return Boolean(isTokenSetedReq.data);
}, [isTokenSetedReq]);
- return { loading, isSupported, handleDisableNotificationClick, handleEnableNotificationClick, isNotificationEnable };
+ return {
+ loading,
+ isSupported,
+ isNotificationEnable,
+ handleDisableNotificationClick,
+ handleEnableNotificationClick,
+ };
};
\ No newline at end of file
diff --git a/src/hooks/useNotificationToken.ts b/src/hooks/useNotificationToken.ts
new file mode 100644
index 0000000..8b430a0
--- /dev/null
+++ b/src/hooks/useNotificationToken.ts
@@ -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();
+ useEffect(() => {
+ if (!isNotificationSupport()) return;
+ getFirebaseToken()
+ .then(token => {
+ setToken(token);
+ })
+ .catch(() => {});
+ }, []);
+ return [token];
+}
\ No newline at end of file
diff --git a/src/hooks/useNotificationTokenAPI.ts b/src/hooks/useNotificationTokenAPI.ts
new file mode 100644
index 0000000..70769ac
--- /dev/null
+++ b/src/hooks/useNotificationTokenAPI.ts
@@ -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(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,
+ }
+}
\ No newline at end of file
diff --git a/src/index.tsx b/src/index.tsx
index 18440b0..a67fdd5 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -1,22 +1,20 @@
-import { logger } from '@/utils/logger';
-import { verifyLogtoToken, xcxApi } from "@/utils/server";
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 utc from 'dayjs/plugin/utc';
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 { WebSocketService } from "@/services/WebsocketService";
-import type { JWTPayload } from "jose";
import { prisma } from "@/prisma/db";
import xprisma from "@/dao/xprisma";
import { EventWatchSchedule } from "@/schedules/EventWatchSchedule";
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(timezone);
@@ -30,7 +28,6 @@ const server = Bun.serve({
'/assets/*': async (req) => {
const pathname = new URL(req.url).pathname;
const filepath = `.${pathname}`;
- logger.debug('Read file: %s', filepath);
if (!/^\/assets/.test(pathname)) return new Response(null, { status: 404 });
const file = await Bun.file(filepath);
if (!await file.exists()) {
@@ -42,21 +39,15 @@ const server = Bun.serve({
const build = await Bun.build({
entrypoints: ["./src/sw.ts"],
target: "browser",
- // 如果你需要压缩,可以开启
minify: process.env.NODE_ENV === "production",
});
-
if (!build.success) {
return new Response("Build Error", { status: 500 });
}
-
- // 读取编译后的第一个输出文件(即 sw.js)
const blob = build.outputs[0];
-
return new Response(blob, {
headers: {
"Content-Type": "application/javascript",
- // 开发环境下禁用缓存,确保 SW 能及时更新
"Cache-Control": "no-cache",
"Service-Worker-Allowed": "/",
},
@@ -93,7 +84,13 @@ const server = Bun.serve({
const [hasOldToken] = await Promise.all([
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) {
return Response.json({
success: true,
@@ -373,7 +370,9 @@ const server = Bun.serve({
'/api/account/bind': {
async GET(req) {
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({
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;
- 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" && {
@@ -444,4 +414,4 @@ const server = Bun.serve({
const eventSchedule = new EventWatchSchedule();
eventSchedule.start();
-logger.info(`🚀 Server running at ${server.url}`, server);
+console.debug(`🚀 Server running at ${server.url}`);
diff --git a/src/page/UserCenter.tsx b/src/page/UserCenter.tsx
index d6b2b7b..fc37e27 100644
--- a/src/page/UserCenter.tsx
+++ b/src/page/UserCenter.tsx
@@ -10,6 +10,7 @@ import { useAppVersion } from "@/hooks/useAppVersion";
import { BindKaiqiuAccount } from "@/components/BindKaiqiuAccount";
import { ChangeBackground } from "@/components/ChangeBackground";
import PermissionControlPanel from "@/components/PermissionControlPanel";
+import { useAuthHeaders } from "@/hooks/useAuthHeaders";
enum modifyRoutes {
username = '/account/username',
@@ -40,7 +41,7 @@ export const UserCenter = () => {
const { signIn, isAuthenticated, signOut, getIdTokenClaims } = useLogto();
const { autoSignIn } = useAutoLogin();
const [user, setUser] = useState();
- const navigate = useNavigate();
+ useAuthHeaders();
const location = useLocation();
const handleSignIn = useCallback(() => {
signIn(AUTH_CALLBACK_URL);
diff --git a/src/schedules/EventWatchSchedule.ts b/src/schedules/EventWatchSchedule.ts
index 5dce3fa..ea9363f 100644
--- a/src/schedules/EventWatchSchedule.ts
+++ b/src/schedules/EventWatchSchedule.ts
@@ -1,11 +1,11 @@
import * as nodeSchedule from 'node-schedule';
-import { EventSubscribeService } from '../services/EventSubscribeService';
-import type { EventDetail, Player } from '../types';
-import { redis } from '../utils/server';
-import { diffArrays } from '../utils/common';
-import { sendNotification } from '../utils/firebase-admin';
-import { prisma } from '../prisma/db';
-import type { Schedule } from './schedule';
+import { EventSubscribeService } from '@/services/EventSubscribeService';
+import type { EventDetail, NotificationData, Player } from '@/types';
+import { redis } from '@/utils/server';
+import { diffArrays } from '@/utils/common';
+import { sendNotification } from '@/utils/firebase-admin';
+import { prisma } from '@/prisma/db';
+import type { Schedule } from '@/schedules/schedule';
import dayjs from 'dayjs';
import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
@@ -69,9 +69,19 @@ export class EventWatchSchedule implements Schedule {
const tokens = await prisma.notificationToken
.findMany({ where: { logto_uid: { in: uids }}, select: { token: true } })
.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() {
+ if (process.env.NODE_ENV !== 'product') {
+ return;
+ }
const job = nodeSchedule.scheduleJob('event-watch', {
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],
diff --git a/src/services/EventSubscribeService.ts b/src/services/EventSubscribeService.ts
index b6fb7dd..faae1d2 100644
--- a/src/services/EventSubscribeService.ts
+++ b/src/services/EventSubscribeService.ts
@@ -1,7 +1,8 @@
import { logger } from "@/utils/logger";
import { prisma } from "../prisma/db";
-import type { EventDetail } from "../types";
+import type { EventDetail, NotificationData } from "../types";
import { KaiqiuService } from "./KaiqiuService";
+import { broadcast } from "@/utils/firebase-admin";
export class EventSubscribeService {
public static async sub(user: string, event: string) {
@@ -12,6 +13,18 @@ export class EventSubscribeService {
logger.error('Subscribe event faild', { user, event, e });
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;
}
public static async unSub(user: string, event: string) {
diff --git a/src/services/KaiqiuService.ts b/src/services/KaiqiuService.ts
index b1c7c6f..a3e79eb 100644
--- a/src/services/KaiqiuService.ts
+++ b/src/services/KaiqiuService.ts
@@ -225,7 +225,7 @@ export class KaiqiuService {
public static async login(username: string, password: string) {
const loginPageRes = await fetch('https://kaiqiuwang.cc/home/do.php?ac=668g&&ref');
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());
const loginsubmit = $('#loginsubmit').val()?.toString() || '';
const formhash = $('#loginform > input[type=hidden]').val()?.toString() || '';
diff --git a/src/services/WebsocketService.ts b/src/services/WebsocketService.ts
deleted file mode 100644
index 2c8ac32..0000000
--- a/src/services/WebsocketService.ts
+++ /dev/null
@@ -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;
-
-export class WebSocketService {
- static #connections = new Set();
- static #userSubTopics = new Map>();
- static #userClients = new Map>();
-
- 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(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());
- }
- if (!topics.length) return;
- topics.forEach(topic => {
- this.#userSubTopics.get(user)?.add(topic);
- ws.subscribe(topic);
- });
- }
-
- static userNotificate(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(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) {
- 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;
- }
- }
-}
\ No newline at end of file
diff --git a/src/sw.ts b/src/sw.ts
index 4b45b13..ae35efb 100644
--- a/src/sw.ts
+++ b/src/sw.ts
@@ -5,6 +5,7 @@ import { getMessaging, onBackgroundMessage } from 'firebase/messaging/sw';
import { firebaseConfig } from "./utils/firebase";
import type { NotificationData } from './types';
import type { MessagePayload } from 'firebase/messaging';
+import { fromMessagePayload, type TopicPayload } from './utils/common';
declare const self: ServiceWorkerGlobalScope;
@@ -25,15 +26,21 @@ self.addEventListener('activate', (event) => {
// 核心:处理后台消息
onBackgroundMessage(messaging, (payload: MessagePayload) => {
- const data = payload.data as NotificationData;
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 = {
- body: data.body,
- icon: data.icon, // 你的图标
- data: { url: data.url } // 携带点击跳转的 URL
+ ...options,
+ // body: data.body,
+ // icon: data.icon, // 你的图标
+ // data: { url: data.url } // 携带点击跳转的 URL
};
self.registration.showNotification(notificationTitle, notificationOptions);
});
-// console.log('sw', self);
\ No newline at end of file
+console.log('sw', self);
\ No newline at end of file
diff --git a/src/types/index.ts b/src/types/index.ts
index 4564eaa..ebb3ecb 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -1,3 +1,4 @@
+import type { TopicPayload, Topics } from '@/utils/common';
import type { JWTPayload } from 'jose';
export * from './profile';
@@ -106,8 +107,6 @@ export type WsPaylaod = {
}
export type NotificationData = {
- title: string;
- body?: string;
- icon?: string;
- url?: string;
+ topic: Topics;
+ data: TopicPayload[Topics];
}
\ No newline at end of file
diff --git a/src/utils/common.ts b/src/utils/common.ts
index accd2df..2f56ed7 100644
--- a/src/utils/common.ts
+++ b/src/utils/common.ts
@@ -1,5 +1,6 @@
+import type { NotificationData } from "@/types";
+import type { MessagePayload } from "firebase/messaging";
import { chunk } from "lodash";
-import type { Player } from "../types";
export const KAIQIU_BASE_URL = `https://kaiqiuwang.cc`;
export const LOGTO_DOMAIN = 'https://logto.ksr.la';
@@ -88,15 +89,7 @@ export function calculate(winerScore: number, loserScore: number) {
}
}
-export type WsServerSendTopicPayload = {
- 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 };
+export type ServerSendTopicPayload = {
SERVER_PUSH: {
title: string;
options?: NotificationOptions & {
@@ -107,21 +100,12 @@ export type WsServerSendTopicPayload = {
};
}
-export type WsServerSendTopics = keyof WsServerSendTopicPayload;
+export type ServerSendTopics = keyof ServerSendTopicPayload;
-export type WsWebSendTopicPayload = {
- UNKONOW: undefined;
- SUB: { topic: string };
- UNSUB: { topic: string };
- MSG: { channel: string; name: string; avatar: string; message: string };
-}
+export type TopicPayload = ServerSendTopicPayload;
+export type Topics = keyof TopicPayload;
-export type WsWebSendTopics = keyof WsWebSendTopicPayload;
-
-export type WsTopicPayload = WsServerSendTopicPayload & WsWebSendTopicPayload;
-export type WsTopics = keyof WsTopicPayload;
-
-export function ensureTopicData(data: any): WsTopicPayload[T] | undefined {
+export function ensureTopicData(data: any): TopicPayload[T] | undefined {
return data;
}
@@ -130,42 +114,12 @@ export function getEventSubKey(eventId: string) {
}
export function toWebProcessMessage(
- topic: WsTopics,
- data: WsTopicPayload[WsTopics],
+ topic: Topics,
+ data: TopicPayload[Topics],
) {
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, compare: Set) {
const deletedSet = new Set(array);
@@ -181,4 +135,19 @@ export function diffArrays(array: Set, compare: Set) {
deletedList: deletedSet.values().toArray(),
newMemberList: newSet.values().toArray(),
}
+}
+
+export function fromMessagePayload(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;
+ }
}
\ No newline at end of file
diff --git a/src/utils/constants.ts b/src/utils/constants.ts
index cb72b93..a9a6b1f 100644
--- a/src/utils/constants.ts
+++ b/src/utils/constants.ts
@@ -3,4 +3,5 @@ export const CLUB_SELECTOR_KEY = 'CLUB_SELECTOR';
export const STORE_PAGE_LIST_KEY = 'events-page-keys';
export const MATCH_RESULT_MAP_KEY = 'match-result-map';
export const EVENT_WS_MESSAGE = 'EVENT_WS_MESSAGE';
+export const NOTIFICATION_TOKEN_KEY = 'NOTIFICATION_TOKEN';
export const VAPI_PUBLIC_KEY = 'BEdP0MXtxreY9jH5fXorkMLHVDOW2N3UgFcOzHUEjDf9HxhLQkjuzHq05kMEs0Lpvez4xpTiKmBYcWwwzJyuN2c';
\ No newline at end of file
diff --git a/src/utils/firebase-admin.ts b/src/utils/firebase-admin.ts
index 9d1a16e..b4cbe17 100644
--- a/src/utils/firebase-admin.ts
+++ b/src/utils/firebase-admin.ts
@@ -1,8 +1,11 @@
import admin from "firebase-admin";
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 type { NotificationData } from "../types";
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)
});
-export async function sendNotification(token: string, data: NotificationData, dryRun?: boolean) {
+export async function sendNotification(token: string, payload: NotificationData, dryRun?: boolean) {
if (!token) return;
- const messageId = await getMessaging().send({
+ const messaging = await getMessaging();
+ const messageid = await messaging.send({
token,
- data: data as any,
- }, dryRun);
- return messageId;
+ data: {
+ payload: JSON.stringify(payload),
+ },
+ }, 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 });
}
\ No newline at end of file
diff --git a/src/utils/logger.ts b/src/utils/logger.ts
index 9a1db42..4344699 100644
--- a/src/utils/logger.ts
+++ b/src/utils/logger.ts
@@ -5,6 +5,6 @@ export const logger = winston.createLogger({
format: winston.format.json(),
transports: [
new winston.transports.Console({ format: winston.format.json()}),
- new winston.transports.File({ filename: 'logs/combined.log' }),
+ // new winston.transports.File({ filename: 'logs/combined.log' }),
],
});