- Add `UserBind` model to Prisma schema for linking Logto and Kaiqiu user IDs. - Implement `/api/account/bind` endpoint (GET/PUT) to handle Kaiqiu account binding and retrieval. - Refactor `KaiqiuService` to include a `login` method that performs browser automation-like login on kaiqiuwang.cc using `cheerio` and `fetch`. - Update `UserCenter` page to include a new `BindKaiqiuAccount` component. - Restructure `WebSocketService`: - Change from a simple global connection map to a per-user client tracking system (`#userClients`). - Update topic naming conventions (e.g., `ONLINE_MEMBER_CHANGE` -> `MEMBER_CHANGE`). - Add client-side broadcast capabilities for user-specific events like `MY_CLIENT_ONLINE`. - Add support for dynamic subscription handling (SUB/UNSUB) via WebSocket messages. - Update `verifyLogtoToken` to accept either `Headers` or a raw token string for flexibility in WebSocket auth. - Minor fixes: typo corrections in `WSTopic` enum and commented out debug logs. BREAKING CHANGE: WebSocket payload structure has changed. The `ws.data` property now contains a `WsPaylaod` object with a `user` field (previously it was a JSON string of the JWT payload). The `WSTopic` names have been updated (e.g., `ONLINE_MEMBER_CHANGE` is now `MEMBER_CHANGE`), requiring updates to any client code subscribing to these topics.
298 lines
11 KiB
TypeScript
298 lines
11 KiB
TypeScript
import { getMatchInfo, 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";
|
|
|
|
dayjs.extend(utc);
|
|
dayjs.extend(timezone);
|
|
|
|
const server = Bun.serve({
|
|
port: process.env.PORT || 3000,
|
|
routes: {
|
|
// Serve index.html for all unmatched routes.
|
|
"/*": index,
|
|
"/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 page = Number(new URL(req.url).searchParams.get('page')) ?? 1;
|
|
const data = await KaiqiuService.listClubEvents(req.params.clubid, page);
|
|
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);
|
|
allEvents = allEvents.concat(...events.data);
|
|
while (events.data.every(e => !e.isFinished)) {
|
|
page += 1;
|
|
events = await KaiqiuService.listClubEvents(id, page);
|
|
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 getMatchInfo(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 uidScore = await getUidScore(uids);
|
|
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);
|
|
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/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) {
|
|
WebSocketService.processMessage(ws, message);
|
|
},
|
|
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,
|
|
},
|
|
});
|
|
|
|
console.log(`🚀 Server running at ${server.url}`);
|