my-kaiqiuwang/src/index.tsx
kyuuseiryuu c70aeda412 feat(auth): add Kaiqiu account binding and update WebSocket architecture
- 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.
2026-03-18 01:18:34 +09:00

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}`);