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.
This commit is contained in:
kyuuseiryuu 2026-03-18 01:18:34 +09:00
parent 09f3ecaca6
commit c70aeda412
14 changed files with 363 additions and 49 deletions

View File

@ -0,0 +1,8 @@
import { test, expect } from 'bun:test';
import { KaiqiuService } from '../src/services/KaiqiuService';
test('login success', async () => {
const { html, ...result } = await KaiqiuService.login('', '');
console.debug(result);
expect(result.cookies).toBeDefined();
});

View File

@ -0,0 +1,7 @@
-- CreateTable
CREATE TABLE `UserBind` (
`logto_uid` VARCHAR(191) NOT NULL,
`kaiqiu_uid` VARCHAR(191) NOT NULL,
UNIQUE INDEX `UserBind_logto_uid_key`(`logto_uid`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

View File

@ -24,3 +24,8 @@ model EventSubs {
event_id String event_id String
@@id([logto_uid, event_id]) @@id([logto_uid, event_id])
} }
model UserBind {
logto_uid String @unique
kaiqiu_uid String
}

View File

@ -0,0 +1,86 @@
import { useRequest } from "ahooks";
import { useAuthHeaders } from "../hooks/useAuthHeaders";
import { Alert, App, Button, Drawer, Flex, Form, Input, Spin, Typography } from "antd";
import { LinkOutlined } from "@ant-design/icons";
import { useCallback, useState } from "react";
export const BindKaiqiuAccount = () => {
const headers = useAuthHeaders();
const isBindReq = useRequest<{
isBinded: boolean;
uid?: string;
}, []>(async () => {
return fetch('/api/account/bind', { headers }).then(res => res.json());
}, { manual: false, debounceWait: 300 });
const [open, setOpen] = useState(false);
const [form] = Form.useForm<{ password: string, username: string }>();
const { modal, message } = App.useApp();
const bindRequest = useRequest(async (data) => {
const result = await fetch('/api/account/bind', {
method: 'PUT',
body: JSON.stringify(data),
headers,
}).then(res => res.json());
return result;
}, { manual: true });
const handleBind = useCallback(async () => {
const data = await form.validateFields().catch(() => Promise.reject());
const result = await bindRequest.runAsync(data);
if (!result.success) {
message.error(result.message);
return;
}
await isBindReq.runAsync();
message.success('绑定成功');
setOpen(false);
}, [form, headers, isBindReq, message]);
const handleForgotPass = useCallback(() => {
modal.info({
title: '忘记密码',
content: (
<div>
- - - -
</div>
),
});
}, [modal]);
if (isBindReq.data?.isBinded === undefined) return null;
if (isBindReq.data?.isBinded) {
return (
<Typography.Text type="secondary">UID: {isBindReq.data?.uid ?? '-'}</Typography.Text>
);
}
return (
<>
<Button
icon={<LinkOutlined />}
onClick={() => setOpen(true)}
>
</Button>
<Drawer
title="绑定开球网账号"
placement="bottom"
size={500}
open={open}
onClose={() => setOpen(false)}
>
<Flex gap={12} vertical>
<Alert type='success' description="后台不会存储账号密码,仅用于登录开球网获取数据。绑定后修改密码不影响绑定结果" />
<Spin spinning={bindRequest.loading}>
<Form form={form}>
<Form.Item rules={[{ required: true, message: '' }]} label="开球网用户名" name={'username'}>
<Input />
</Form.Item>
<Form.Item rules={[{ required: true, message: '' }]} label="开球网密码" name={'password'}>
<Input.Password />
</Form.Item>
</Form>
</Spin>
<Button block type="primary" loading={bindRequest.loading} onClick={handleBind}></Button>
<Button block type="link" onClick={handleForgotPass}></Button>
</Flex>
</Drawer>
</>
);
}

View File

@ -59,7 +59,7 @@ export function EventCard(props: EventCardProps) {
const statistic = getStatisticProps(); const statistic = getStatisticProps();
setMessageFormat(statistic.format); setMessageFormat(statistic.format);
setStatisticType(statistic.type); setStatisticType(statistic.type);
console.debug('format: %s', day.format(statistic.format), statistic); // console.debug('format: %s', day.format(statistic.format), statistic);
}, [getStatisticProps]) }, [getStatisticProps])
useEffect(() => { useEffect(() => {
const timeout = day.toDate().getTime() - Date.now(); const timeout = day.toDate().getTime() - Date.now();

View File

@ -0,0 +1,23 @@
import { useLogto } from "@logto/react";
import { useRef } from "react"
import { useRequest } from "ahooks";
import { LOGTO_RESOURCE } from "../../utils/constants";
export const useAuthSocket = () => {
const wsRef = useRef<WebSocket>(null);
const { isAuthenticated, getAccessToken } = useLogto();
const initWs = useRequest(async () => {
if (!isAuthenticated) return;
if (wsRef.current) {
if (wsRef.current.readyState === WebSocket.OPEN) {
return wsRef.current;
}
}
const token = await getAccessToken(LOGTO_RESOURCE);
const url = `${window.origin}/ws?token=${token}`.replace(/^http/, 'ws');
const ws = new WebSocket(url);
wsRef.current = ws;
return ws;
}, { manual: false, refreshDeps: [isAuthenticated, getAccessToken], debounceWait: 300 });
return initWs;
}

View File

@ -1,4 +1,3 @@
import { serve } from "bun";
import { getMatchInfo, verifyLogtoToken, xcxApi } from "./utils/server"; import { getMatchInfo, verifyLogtoToken, xcxApi } from "./utils/server";
import ics from 'ics'; import ics from 'ics';
import index from "./index.html"; import index from "./index.html";
@ -9,14 +8,16 @@ import { KaiqiuService } from "./services/KaiqiuService";
import dayjs from "dayjs"; import dayjs from "dayjs";
import utc from 'dayjs/plugin/utc'; import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone'; import timezone from 'dayjs/plugin/timezone';
import type { IEventInfo } from "./types"; import type { IEventInfo, WsPaylaod } from "./types";
import { EventSubscribeService } from "./services/EventSubscribeService"; import { EventSubscribeService } from "./services/EventSubscribeService";
import { WebSocketService } from "./services/WebsocketService"; import { WebSocketService } from "./services/WebsocketService";
import type { JWTPayload } from "jose";
import { prisma } from "./prisma/db";
dayjs.extend(utc); dayjs.extend(utc);
dayjs.extend(timezone); dayjs.extend(timezone);
const server = serve({ const server = Bun.serve({
port: process.env.PORT || 3000, port: process.env.PORT || 3000,
routes: { routes: {
// Serve index.html for all unmatched routes. // Serve index.html for all unmatched routes.
@ -226,18 +227,52 @@ const server = serve({
return Response.json(data); 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': { '/ws': {
async GET(req, server) { async GET(req, server) {
const user = await verifyLogtoToken(req.headers).catch(() => undefined); const token = new URL(req.url).searchParams.get('token');
if (!user?.sub) return new Response("Unauthorized", { if (!token) return new Response('Not valid token', { status: 401 });
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 },
}); });
server.upgrade(req, { data: JSON.stringify(user) as any });
return new Response('Upgraded');
} }
} }
}, },
websocket: { websocket: {
data: {} as WsPaylaod,
open(ws) { open(ws) {
WebSocketService.addConnection(ws); WebSocketService.addConnection(ws);
}, },
@ -245,6 +280,7 @@ const server = serve({
WebSocketService.processMessage(ws, message); WebSocketService.processMessage(ws, message);
}, },
close(ws, code, reason) { close(ws, code, reason) {
console.debug('close ws', code, reason)
WebSocketService.removeConnection(ws); WebSocketService.removeConnection(ws);
}, },
}, },

View File

@ -7,6 +7,7 @@ import { AUTH_CALLBACK_URL, USER_CENTER_URL } from "../utils/front";
import useAutoLogin from "../hooks/useAutoLogin"; import useAutoLogin from "../hooks/useAutoLogin";
import { LOGTO_DOMAIN } from "../utils/common"; import { LOGTO_DOMAIN } from "../utils/common";
import { useAppVersion } from "../hooks/useAppVersion"; import { useAppVersion } from "../hooks/useAppVersion";
import { BindKaiqiuAccount } from "../components/BindKaiqiuAccount";
enum modifyRoutes { enum modifyRoutes {
username = '/account/username', username = '/account/username',
@ -81,6 +82,7 @@ export const UserCenter = () => {
<Flex> <Flex>
<Typography.Text>{user?.username ?? user?.name ?? '未设置'}</Typography.Text> <Typography.Text>{user?.username ?? user?.name ?? '未设置'}</Typography.Text>
</Flex> </Flex>
<BindKaiqiuAccount />
<Divider></Divider> <Divider></Divider>
<Button block icon={<EditOutlined />} onClick={() => handleModifyInfo(modifyRoutes.username)}>({user?.username ?? '未设置'})</Button> <Button block icon={<EditOutlined />} onClick={() => handleModifyInfo(modifyRoutes.username)}>({user?.username ?? '未设置'})</Button>
<Button block icon={<MailOutlined />} onClick={() => handleModifyInfo(modifyRoutes.email)}> E-Mail</Button> <Button block icon={<MailOutlined />} onClick={() => handleModifyInfo(modifyRoutes.email)}> E-Mail</Button>

View File

@ -159,4 +159,82 @@ export class KaiqiuService {
} }
} }
public static async login(username: string, password: string) {
const loginPageRes = await fetch('https://kaiqiuwang.cc/home/do.php?ac=668g&&ref');
const cookies = loginPageRes.headers.getSetCookie().map(c => c.split(';')[0]).join('; ');
console.debug('init cookies: ', cookies);
let $ = cheerio.load(await loginPageRes.text());
const loginsubmit = $('#loginsubmit').val()?.toString() || '';
const formhash = $('#loginform > input[type=hidden]').val()?.toString() || '';
const cookietime = $('#cookietime').val()?.toString() || '';
const refer = "https://kaiqiuwang.cc/home/do.php?ac=668g&&ref";
const formData = new FormData();
formData.append('loginsubmit', loginsubmit);
formData.append('cookietime', cookietime);
formData.append('formhash', formhash);
formData.append('username', username);
formData.append('password', password);
formData.append('refer', refer);
const loginRes = await fetch("https://kaiqiuwang.cc/home/do.php?ac=668g&&ref", {
"headers": {
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
"accept-language": "zh-CN,zh;q=0.9",
"cache-control": "no-cache",
"content-type": "application/x-www-form-urlencoded",
"pragma": "no-cache",
"priority": "u=0, i",
"sec-ch-ua": "\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Brave\";v=\"146\"",
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": "\"Windows\"",
"sec-fetch-dest": "document",
"sec-fetch-mode": "navigate",
"sec-fetch-site": "same-origin",
"sec-fetch-user": "?1",
"sec-gpc": "1",
"upgrade-insecure-requests": "1",
"cookie": cookies,
"Referer": "https://kaiqiuwang.cc/home/do.php?ac=668g"
},
"body": new URLSearchParams(Object.fromEntries(formData.entries())),
"method": "POST"
});
const PHPSESSID = cookies.split('; ').filter(cookie => cookie.startsWith('PHPSESSID'))[0];
const authCookies = [
PHPSESSID,
loginRes.headers
.getSetCookie()
.map(c => c.split(';')[0])
.filter(cookie => !cookie?.includes('uchome_auth=deleted'))
.join('; ')
].join('; ');
console.debug('auth cookies', authCookies);
const networkPageReq = await fetch("https://kaiqiuwang.cc/home/network.php", {
"headers": {
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
"accept-language": "zh-CN,zh;q=0.9",
"cache-control": "no-cache",
"pragma": "no-cache",
"priority": "u=0, i",
"sec-ch-ua": "\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Brave\";v=\"146\"",
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": "\"Windows\"",
"sec-fetch-dest": "document",
"sec-fetch-mode": "navigate",
"sec-fetch-site": "none",
"sec-fetch-user": "?1",
"sec-gpc": "1",
"upgrade-insecure-requests": "1",
"cookie": authCookies,
},
"method": "GET",
"redirect": 'follow',
});
const html = await networkPageReq.text();
$ = cheerio.load(html);
const success = $('#header_content > div.user_info > div.login_ext').text().includes('欢迎');
const href = $('#header_content > div.user_info > div.login_ext > p > a.loginName').attr('href') || '';
const uid = /\b(?<uid>\d+)\.html/.exec(href)?.groups?.uid;
return { uid, success, cookies: authCookies };
}
} }

View File

@ -1,52 +1,111 @@
import type { JWTPayload } from "jose";
import { fromCustomMessage, toCustomMessage, WSTopic } from "../utils/common"; import { fromCustomMessage, toCustomMessage, WSTopic } from "../utils/common";
import { EventSubscribeService } from "./EventSubscribeService"; import { EventSubscribeService } from "./EventSubscribeService";
import type { WsPaylaod } from "../types";
const publicTopics = [ const publicTopics = [
WSTopic.ONLINE_MEMBER_CHANGE, WSTopic.MEMBER_CHANGE,
]; ];
export class WebSocketService { type BunServerWebSocket = Bun.ServerWebSocket<WsPaylaod>;
static #connections = new Set<Bun.ServerWebSocket>();
static #userSubKeys = new Map<string, Set<string>>();
static async addConnection(ws: Bun.ServerWebSocket) { export class WebSocketService {
const user = JSON.parse(ws.data ?? '{}') as Required<JWTPayload>; static #connections = new Set<BunServerWebSocket>();
const subEvets = await EventSubscribeService.getEvents(user.sub).then(e => e.map(v => `event:${e}`)); static #userSubTopics = new Map<string, Set<string>>();
static #userClients = new Map<string, Set<BunServerWebSocket>>();
static async addConnection(ws: BunServerWebSocket) {
this.#connections.add(ws); this.#connections.add(ws);
const user = ws.data.user;
let isNewMember = false;
if (!this.#userClients.has(user.sub)) {
this.#userClients.set(user.sub, new Set());
isNewMember = true;
}
this.#userClients.get(user.sub)?.add(ws);
await this.#initSubscribe(ws);
const message = toCustomMessage(WSTopic.MEMBER_CHANGE, this.#userClients.size);
if (isNewMember) {
this.broadcast(WSTopic.MEMBER_CHANGE, message);
} else {
ws.send(message);
}
this.userClientsBroadcast(
ws.data.user.sub,
WSTopic.MY_CLIENT_ONLINE,
toCustomMessage(WSTopic.MY_CLIENT_ONLINE, this.#userClients.get(user.sub)?.size),
);
}
static async #initSubscribe(ws: BunServerWebSocket) {
const user = ws.data.user
const subEvets = await EventSubscribeService.getEvents(user.sub).then(e => e.map(v => `event:${v}`));
this.userSub(ws, user.sub, publicTopics); this.userSub(ws, user.sub, publicTopics);
this.userSub(ws, user.sub, subEvets); this.userSub(ws, user.sub, subEvets);
const message = toCustomMessage(WSTopic.ONLINE_MEMBER_CHANGE, this.#connections.size);
ws.send(message);
ws.publish(WSTopic.ONLINE_MEMBER_CHANGE, message);
} }
static removeConnection(ws: Bun.ServerWebSocket) {
const user = JSON.parse(ws.data ?? '{}') as Required<JWTPayload>; static publish(ws: BunServerWebSocket, topic: string, message: string, withSelf?: boolean) {
this.userUnSub(ws, user.sub, [...this.#userSubKeys.get(user.sub) ?? []]) ws.publish(topic, message);
this.userUnSub(ws, user.sub, publicTopics); if (withSelf) {
this.#connections.delete(ws); ws.send(message);
ws.publish(WSTopic.ONLINE_MEMBER_CHANGE, toCustomMessage(WSTopic.ONLINE_MEMBER_CHANGE, this.#connections.size));
}
static userSub(ws: Bun.ServerWebSocket, user: string, keys: string[]) {
if (!this.#userSubKeys.has(user)) {
this.#userSubKeys.set(user, new Set<string>());
} }
console.debug('User %s subscribe keys: %s', user, keys.join(',')); }
keys.forEach(key => {
this.#userSubKeys.get(user)?.add(key); static async userClientsBroadcast(user: string, topic: string, message: string) {
ws.subscribe(key); this.#userClients.get(user)?.forEach(ws => ws.send(message));
}
static removeConnection(ws: BunServerWebSocket) {
this.#connections.delete(ws);
console.debug('Someone disconnected. User: ', ws.data.user.sub);
this.#userClients.get(ws.data.user.sub)?.delete(ws);
if (this.#userClients.get(ws.data.user.sub)?.size === 0) {
this.#userClients.delete(ws.data.user.sub);
this.publish(ws, WSTopic.MEMBER_CHANGE, toCustomMessage(WSTopic.MEMBER_CHANGE, this.#userClients.size));
}
this.userClientsBroadcast(
ws.data.user.sub,
WSTopic.MY_CLIENT_ONLINE,
toCustomMessage(WSTopic.MY_CLIENT_ONLINE, this.#userClients.get(ws.data.user.sub)?.size),
);
}
static userSub(ws: BunServerWebSocket, user: string, topics: string[]) {
if (!this.#userSubTopics.has(user)) {
this.#userSubTopics.set(user, new Set<string>());
}
if (!topics.length) return;
console.debug('User %s subscribe keys: %s', user, topics.join(','));
topics.forEach(topic => {
this.#userSubTopics.get(user)?.add(topic);
ws.subscribe(topic);
}); });
} }
static userUnSub(ws: Bun.ServerWebSocket, user: string, keys: string[]) {
console.debug('User %s subscribe keys: %s', user, keys.join(',')); static broadcast(topic: string, message: string) {
keys.forEach(key => { this.#connections.values().forEach(con => {
this.#userSubKeys.get(user)?.delete(key); if (con.isSubscribed(topic)) {
ws.unsubscribe(key); con.send(message);
}
}); });
} }
static processMessage(ws: Bun.ServerWebSocket, message: string | Buffer<ArrayBuffer>) {
static processMessage(ws: BunServerWebSocket, message: string | Buffer<ArrayBuffer>) {
const { clientTopic: action, data } = fromCustomMessage(message.toString()); const { clientTopic: action, data } = fromCustomMessage(message.toString());
if (!action) return; if (!action) return;
console.debug('Recieve: %s, %s', action, JSON.stringify(data)); console.debug('Recieve action: %s', action, message);
switch (action) {
case WSTopic.SUB:
ws.subscribe(data.topic);
break;
case WSTopic.UNSUB:
ws.unsubscribe(data.topic);
break;
case WSTopic.SEND:
ws.publish(data.topic, data.message);
break;
default:
this.broadcast("Test", 'This is a broadcast message. Everyone should recieved.');
break;
}
} }
} }

View File

@ -1,3 +1,5 @@
import type { JWTPayload } from 'jose';
export * from './profile'; export * from './profile';
export * from './tag'; export * from './tag';
export interface Config { export interface Config {
@ -77,3 +79,7 @@ export interface ClubDetail {
img: string; img: string;
geo: { lng: number; lat: number; } | null; geo: { lng: number; lat: number; } | null;
} }
export type WsPaylaod = {
user: Required<JWTPayload>;
}

View File

@ -87,13 +87,17 @@ export function calculate(winerScore: number, loserScore: number) {
} }
} }
export function toCustomMessage(clientTopic: string, data: any) { export function toCustomMessage(clientTopic: string | number, data: any) {
return JSON.stringify({ topic: clientTopic, data }); return JSON.stringify({ topic: clientTopic, data });
} }
export enum WSTopic { export enum WSTopic {
UNKONOW = 'UNKONOW', UNKONOW = 'UNKONW',
ONLINE_MEMBER_CHANGE = 'ONLINE_MEMBER_CHANGE', MY_CLIENT_ONLINE = 'CLIENT_ONLINE',
MEMBER_CHANGE = 'MEMBER_CHNAGE',
SUB = "SUB",
UNSUB = "UNSUB",
SEND = "PUBLISH",
} }
export function fromCustomMessage(message: string): { export function fromCustomMessage(message: string): {

View File

@ -91,7 +91,7 @@ export const extractBearerTokenFromHeaders = (authorization: string | null) => {
} }
if (!authorization.startsWith('Bearer')) { if (!authorization.startsWith('Bearer')) {
return null; return authorization;
} }
return authorization.slice(7); // The length of 'Bearer ' is 7 return authorization.slice(7); // The length of 'Bearer ' is 7
@ -99,9 +99,9 @@ export const extractBearerTokenFromHeaders = (authorization: string | null) => {
const jwks = createRemoteJWKSet(new URL(`${LOGTO_DOMAIN}/oidc/jwks`)); const jwks = createRemoteJWKSet(new URL(`${LOGTO_DOMAIN}/oidc/jwks`));
export const verifyLogtoToken = async (headers: Headers) => { export const verifyLogtoToken = async (headers: Headers | string) => {
const auth = headers.get('Authorization'); const auth = typeof headers === 'string' ? headers : headers.get('Authorization');
const token = extractBearerTokenFromHeaders(auth) const token = extractBearerTokenFromHeaders(auth);
// console.debug('Authorization', { auth, token }); // console.debug('Authorization', { auth, token });
if (!token) return {}; if (!token) return {};
const { payload } = await jwtVerify( const { payload } = await jwtVerify(