From defd95c218da61c71e417c33ad8555be33f6f7a5 Mon Sep 17 00:00:00 2001 From: kyuuseiryuu Date: Fri, 27 Mar 2026 01:05:01 +0900 Subject: [PATCH] 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. --- src/components/BindKaiqiuAccount.tsx | 10 +- src/components/ClubEventList.tsx | 19 ++- src/components/EventCard.tsx | 16 +- src/components/Layout/AppBarLayout.tsx | 9 +- .../NotificationPermissionContorl.tsx | 21 ++- src/components/PermissionControlPanel.tsx | 1 + src/context/WebsocketContext.tsx | 8 - src/hooks/useAuthHeaders.tsx | 24 +-- src/hooks/useAutoUpdateNotificationToken.ts | 35 ++++ src/hooks/useFirebaseNotificationProcessor.ts | 29 ++-- src/hooks/useHandlerServerMessage.tsx | 58 ------- src/hooks/useNotificationControl.ts | 64 +++----- src/hooks/useNotificationToken.ts | 16 ++ src/hooks/useNotificationTokenAPI.ts | 44 +++++ src/index.tsx | 68 +++----- src/page/UserCenter.tsx | 3 +- src/schedules/EventWatchSchedule.ts | 26 ++- src/services/EventSubscribeService.ts | 15 +- src/services/KaiqiuService.ts | 2 +- src/services/WebsocketService.ts | 154 ------------------ src/sw.ts | 19 ++- src/types/index.ts | 7 +- src/utils/common.ts | 79 +++------ src/utils/constants.ts | 1 + src/utils/firebase-admin.ts | 48 +++++- src/utils/logger.ts | 2 +- 26 files changed, 324 insertions(+), 454 deletions(-) delete mode 100644 src/context/WebsocketContext.tsx create mode 100644 src/hooks/useAutoUpdateNotificationToken.ts delete mode 100644 src/hooks/useHandlerServerMessage.tsx create mode 100644 src/hooks/useNotificationToken.ts create mode 100644 src/hooks/useNotificationTokenAPI.ts delete mode 100644 src/services/WebsocketService.ts 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} + ) : ( + + )} , - + + + + 当前模式不支持通知功 + + + + )} { <> (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: [ - - ] - }) + 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: [ + + ], + }); }); - } }, [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' }), ], });