my-kaiqiuwang/src/index.tsx
2026-03-26 17:40:44 +09:00

448 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<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" && {
// 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);