- Remove Redis-based caching logic from uidScoreStore and xcxApi. - Add force refresh support to uidScoreRequest in GroupingPrediction component. - Update server API /api/user/nowScores to accept force parameter. - Always show refresh button in GroupingPrediction regardless of data state. - Change default sort type in FavPlayersPage to SCORE_DOWN. - Clean up unused imports from server.ts. This change ensures user scores are always up-to-date by bypassing cache when needed, preventing issues with stale data during manual sync operations.
439 lines
16 KiB
TypeScript
439 lines
16 KiB
TypeScript
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}`;
|
||
// console.log('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: '通知已注册!' });
|
||
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 data = await KaiqiuService.findClub(key, page, Boolean(normalClub));
|
||
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) {
|
||
console.log(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/: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 });
|
||
console.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) {
|
||
console.debug('Parse message error', e, message.toString());
|
||
}
|
||
},
|
||
close(ws, code, reason) {
|
||
console.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();
|
||
|
||
console.log(`🚀 Server running at ${server.url}`);
|