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 { 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"; dayjs.extend(utc); dayjs.extend(timezone); const server = Bun.serve({ idleTimeout: 30, port: process.env.PORT || 3000, routes: { // Serve index.html for all unmatched routes. "/*": index, '/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()) { return new Response(null, { status: 404 }); } return new Response(file); }, "/sw.js": async () => { 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": "/", }, }); }, "/api/notification-token": { async GET(req) { const { sub } = await verifyLogtoToken(req.headers) ?? {}; const token = new URL(req.url).searchParams.get('token'); if (!sub || !token) return Response.json({ isSubscribed: false, }); const count = await prisma.notificationToken.count({ where: { logto_uid: sub, token }, }); return Response.json({ isSubscribed: count > 0, }); }, async DELETE(req) { const { sub } = await verifyLogtoToken(req.headers) ?? {}; const { token } = await req.json(); if (!sub || !token) return Response.json({ success: true }); await prisma.notificationToken.deleteMany({ where: { logto_uid: sub, token } }); return Response.json({ success: true }); }, async PUT(req) { const { sub } = await verifyLogtoToken(req.headers) ?? {}; const { token } = await req.json(); if (!sub || !token) return Response.json({ success: false, }); const where = { logto_uid: sub, token }; const [hasOldToken] = await Promise.all([ prisma.notificationToken.count({ where }).then(num => num > 0), ]); await sendNotification(token, { title: '通知已注册!', url: 'https://tt.ksr.la/user-center' }); if (hasOldToken) { return Response.json({ success: true, message: 'token not change', }); } else { await prisma.notificationToken.create({ data: { logto_uid: sub, token } }); } return Response.json({ success: true, hasOldToken }); } }, "/api/club/find": { async GET(req) { const searchParams = new URL(req.url).searchParams; const key = searchParams.get('key') ?? ''; const normalClub = searchParams.get('normalClub'); const page = Number(searchParams.get('page')); const province = searchParams.get('province') ?? ''; const city = searchParams.get('city') ?? ''; const data = await KaiqiuService.findClub(key, page, Boolean(normalClub), province, city); return Response.json(data); } }, "/api/club/:id": { async GET(req) { const id = req.params.id; const data = await KaiqiuService.getClubInfo(id); return Response.json(data); } }, "/api/club/:clubid/events": { async GET(req) { const searchParams = new URL(req.url).searchParams; const page = Number(searchParams.get('page')) ?? 1; const force = Boolean(searchParams.get('force')) ?? false; const data = await KaiqiuService.listClubEvents(req.params.clubid, page, force); return Response.json(data); } }, "/api/club/:id/calendar.ics": { async GET(req) { const id = req.params.id; const clubInfo = await KaiqiuService.getClubInfo(id); let allEvents: IEventInfo[] = []; let page = 1; let events = await KaiqiuService.listClubEvents(id, page, true); allEvents = allEvents.concat(...events.data); while (events.data.every(e => !e.isFinished)) { page += 1; events = await KaiqiuService.listClubEvents(id, page, true); allEvents = allEvents.concat(...events.data); } const configs: ics.EventAttributes[] = allEvents.filter(e => !e.isFinished).map(e => ({ ...(clubInfo?.geo ? { geo: { lat: clubInfo.geo.lat, lon: clubInfo.geo.lng, } } : {}), start: ics.convertTimestampToArray(dayjs.tz(e.startDate, 'Asia/Tokyo').toDate().getTime(), 'local') , duration: { hours: 6, minutes: 30 }, title: e.title.replace(clubInfo?.name || '', ''), // end: dayjs(event.startDate).add(6, 'h').add(30, 'minute').format('YYYY-MM-DD HH:mm'), // description: 'Annual 10-kilometer run in Boulder, Colorado', url: `https://tt.ksr.la/event/${e.matchId}`, ...(e.location ? { location: e.location } : {}), // geo: { lat: events.location.lat, lon: events.location.lng }, alarms: [ { action: 'display', summary: e.title, description: '距离比赛开始还有 2 小时', trigger: { before: true, hours: 2 } }, { action: 'audio', trigger: { before: true, hours: 2 } }, ], // categories: ['10k races', 'Memorial Day Weekend', 'Boulder CO'], // status: 'CONFIRMED', // busyStatus: 'BUSY', // organizer: { name: 'Admin', email: 'Race@BolderBOULDER.com' }, // attendees: [ // { name: 'Adam Gibbons', email: 'adam@example.com', rsvp: true, partstat: 'ACCEPTED', role: 'REQ-PARTICIPANT' }, // { name: 'Brittany Seaton', email: 'brittany@example2.org', dir: 'https://linkedin.com/in/brittanyseaton', role: 'OPT-PARTICIPANT' } // ] })); const data: string = await new Promise(resolve => ics.createEvents(configs, { calName: clubInfo?.name ? `${clubInfo.name}` : '', }, (err, data) => { if (err) { logger.log('error', 'Error creating ICS file:', err); resolve(''); } resolve(data); })); return new Response(data, { headers: { 'Content-Type': 'text/calendar; charset=utf-8', } }); } }, "/api/subscribe-event": { async GET(req) { const { sub = '' } = await verifyLogtoToken(req.headers); if (!sub) return Response.json([]); const events = await EventSubscribeService.getEvents(sub); return Response.json(events); } }, "/api/subscribe-event/:matchId": { async GET(req) { const id = req.params.matchId; // 获取比赛ID const { sub = '' } = await verifyLogtoToken(req.headers); if (!sub) return Response.json({ ok: false, message: 'Not login.' }); return Response.json({ isSub: await EventSubscribeService.isSub(sub, id), }); }, async PUT(req) { const id = req.params.matchId; // 获取比赛ID const { sub = '' } = await verifyLogtoToken(req.headers); if (!sub) return Response.json({ ok: false, message: 'Not login.' }); return Response.json({ ok: await EventSubscribeService.sub(sub, id), }); }, async DELETE(req) { const id = req.params.matchId; // 获取比赛ID const { sub = '' } = await verifyLogtoToken(req.headers); if (!sub) return Response.json({ ok: false, message: 'Not login.' }); return Response.json({ ok: await EventSubscribeService.unSub(sub, id), }); }, }, "/api/match-summary/:matchId": { async GET(req) { const data = await KaiqiuService.getMatchSummary(req.params.matchId); return Response.json(data); } }, "/api/match/:matchId": { async GET(req) { const data = await KaiqiuService.getMatchDetail(req.params.matchId); return Response.json(data); } }, "/api/match/:matchId/:itemId": { async GET(req) { const { matchId, itemId } = req.params; const data = await xcxApi.getMemberDetail(matchId, itemId); return Response.json(data); } }, "/api/user/find": { async GET(req) { const searchParams = new URL(req.url).searchParams; const key = searchParams.get('key') ?? ''; const page = Number(searchParams.get('page')); const users = await xcxApi.findUser(key, page); return Response.json(users); } }, "/api/user/nowScores": { async POST(req) { const { uids } = await req.json(); const force = Boolean(new URL(req.url).searchParams.get('force') === 'true'); const uidScore = await getUidScore(uids, force); return Response.json(uidScore); } }, "/api/user/:uid": { async GET(req) { const uid = req.params.uid; if (uid === '0') return Response.json(null); const profile = await xcxApi.getAdvProfile(uid, true); return Response.json(profile); }, }, "/api/user/:uid/games": { async GET(req) { const uid = req.params.uid; if (uid === '0') return Response.json([]); const search = new URL(req.url).searchParams; const page = Number(search.get('page')) ?? 0; const resp = await xcxApi.getGameList(uid, page); return Response.json(resp); }, }, "/api/user/:uid/tags": { async GET(req) { const uid = req.params.uid; if (uid == '0') return Response.json([]); const profile = await xcxApi.getPlayerTags(uid); return Response.json(profile); }, }, "/api/fav": { async GET(req) { const payload = await verifyLogtoToken(req.headers); const list = await listFavPlayers(`${payload.sub}`); return Response.json(list.filter(Boolean)); } }, "/api/fav/:uid": { async GET(req) { const payload = await verifyLogtoToken(req.headers); const isFav = await checkIsUserFav(`${payload.sub}`, req.params.uid); return Response.json({ isFav }); }, async PUT(req) { const payload = await verifyLogtoToken(req.headers); await favPlayer(`${payload.sub}`, req.params.uid); return Response.json({ ok: 'ok' }); }, async DELETE(req) { const payload = await verifyLogtoToken(req.headers); await unFavPlayer(`${payload.sub}`, req.params.uid); return Response.json({ ok: 'ok' , uid: req.params.uid }); } }, '/api/battle/:eventId': { async PUT(req) { const { data } = await req.json(); const eventId = req.params.eventId; const code = await BattleService.publishBattle(eventId, data); return Response.json({ code }); }, async GET(req) { const code = new URL(req.url).searchParams.get('code'); const eventId = req.params.eventId; if (!code) return Response.json({}); const data = await BattleService.getBattle(eventId, code); return Response.json(data); } }, '/api/account/location': { async GET(req) { const { lat = 0, lng = 0 } = await req.json(); const locations = await xprisma.userLocation.findNearby({ lng, lat }, 500000); return Response.json(locations[0]); }, async PUT(req) { const { sub } = await verifyLogtoToken(req.headers); const { id, lat, lng, ...data } = await req.json(); const record = await prisma.userLocation.update({ where: { id, logto_uid: sub }, data: data, }).catch(() => null); if (record !== null && lat && lng) { await xprisma.userLocation.updateLocation(record.id, { lat, lng }); } return Response.json([]); }, async POST(req) { const { sub } = await verifyLogtoToken(req.headers); const { lat, lng, name } = await req.json(); if (!sub || !lat || !lng || !name) { return Response.json({ success: false, message: '参数错误', }); } const kaiqiu_uid = await prisma.userBind.findFirst({ where: { logto_uid: sub }, select: { logto_uid: true } }).then((r) => r?.logto_uid) ?? ''; const response = await xprisma.userLocation.createCustom(sub, name, { lat, lng }, kaiqiu_uid) .then(() => ({ success: true, message: '创建成功', })) .catch(() => ({ success: false, message: '创建失败', })); return Response.json(response); }, async DELETE(req) { const { sub } = await verifyLogtoToken(req.headers); const { id } = await req.json(); await prisma.userLocation.delete({ where: { id, logto_uid: sub }}); return Response.json({ success: true }); }, }, '/api/account/bind': { async GET(req) { const { sub } = await verifyLogtoToken(req.headers); if (!sub) return Response.json(false); const record = await prisma.userBind.findFirst({ where: { logto_uid: sub }, }); return Response.json({ isBinded: record !== null, uid: record?.kaiqiu_uid, }); }, async PUT(req) { const { sub } = await verifyLogtoToken(req.headers); if (!sub) return Response.json({ message: 'Not login' }, { status: 401 }); const { username, password } = await req.json(); const result = await KaiqiuService.login(username, password); if (!result.success || !result.uid) return Response.json({ message: '账号或密码错误', success: false, }); const { uid } = result; await prisma.userBind.create({ data: { logto_uid: sub, kaiqiu_uid: uid }, }).catch(); return Response.json({ message: '绑定成功', success: true, }); } }, '/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" && { // Enable browser hot reloading in development hmr: true, // Echo console logs from the browser to the server console: true, }, }); const eventSchedule = new EventWatchSchedule(); eventSchedule.start(); logger.info(`🚀 Server running at ${server.url}`, server);