This commit is contained in:
2026-02-05 12:05:12 +08:00
parent f971aa4e69
commit 164bb5c341
16 changed files with 1395 additions and 28 deletions

View File

@@ -14,3 +14,12 @@ mysqld --basedir=/usr \
--socket=/home/aecw/re-AstarCupWeb/database/mysql.sock \ --socket=/home/aecw/re-AstarCupWeb/database/mysql.sock \
--pid-file=/home/aecw/re-AstarCupWeb/database/mysql.pid & --pid-file=/home/aecw/re-AstarCupWeb/database/mysql.pid &
`` ``
# env
``
OSU_CLIENT_ID =
OSU_CLIENT_SECRET =
OSU_REDIRECT_URI =
DATABASE_URL=""
DATABASE_CLIENT_URL=""
``

View File

@@ -0,0 +1,88 @@
import { NextRequest, NextResponse } from 'next/server';
import { getOsuToken, getMyOsuInfo } from '@/app/lib/osuAuth';
import { CreateUser } from '@/app/lib/UserOperation';
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const code = searchParams.get('code');
const error = searchParams.get('error');
const errorDescription = searchParams.get('error_description');
// 检查错误
if (error) {
console.error('OAuth error:', error, errorDescription);
return NextResponse.redirect(new URL('/debug?error=' + encodeURIComponent(errorDescription || error), request.url));
}
// 检查授权码
if (!code) {
console.error('No authorization code provided');
return NextResponse.redirect(new URL('/debug?error=No authorization code provided', request.url));
}
console.log('Received OAuth code:', code);
try {
// 获取访问令牌
const tokenData = await getOsuToken(code);
console.log('Token obtained successfully');
// 获取用户信息
const userInfo = await getMyOsuInfo(tokenData.access_token);
console.log('User info obtained:', userInfo.username, userInfo.id);
// 创建更新用户
const userSession = await CreateUser({
osuid: userInfo.id,
username: userInfo.username,
avatar_url: userInfo.avatar_url,
cover_url: userInfo.cover?.url,
country_code: userInfo.country_code,
pp: userInfo.statistics?.pp || 0,
global_rank: userInfo.statistics?.global_rank || null,
country_rank: userInfo.statistics?.country_rank || null,
});
console.log('User created/updated successfully:', userSession.username);
const redirectUrl = new URL('/debug', request.url);
redirectUrl.searchParams.set('success', 'true');
redirectUrl.searchParams.set('username', userSession.username);
redirectUrl.searchParams.set('osuid', userSession.osuid.toString());
redirectUrl.searchParams.set('userId', userSession.id.toString());
const response = NextResponse.redirect(redirectUrl);
const userCookieData = {
id: userSession.id,
osuid: userSession.osuid,
username: userSession.username,
avatar_url: userSession.avatar_url || '',
country_code: userSession.country_code,
};
// 设置 cookie 7 天
response.cookies.set('user_session', JSON.stringify(userCookieData), {
httpOnly: process.env.NODE_ENV === 'production',
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 604800, // 7 天
path: '/',
});
return response;
} catch (apiError) {
console.error('API error during OAuth flow:', apiError);
return NextResponse.redirect(
new URL('/debug?error=' + encodeURIComponent('Failed to process OAuth: ' + (apiError instanceof Error ? apiError.message : String(apiError))), request.url)
);
}
} catch (error) {
console.error('Unexpected error in OAuth callback:', error);
return NextResponse.redirect(
new URL('/debug?error=' + encodeURIComponent('Unexpected error: ' + (error instanceof Error ? error.message : String(error))), request.url)
);
}
}

View File

@@ -0,0 +1,15 @@
import { NextResponse } from 'next/server';
import { getOsuAuthUrl } from '@/app/lib/osuAuth';
export async function GET() {
try {
const authUrl = getOsuAuthUrl();
return NextResponse.redirect(authUrl);
} catch (error) {
console.error('Error generating OAuth URL:', error);
return NextResponse.redirect(
new URL('/debug?error=' + encodeURIComponent('Failed to initialize OAuth: ' + (error instanceof Error ? error.message : String(error))),
new URL('/', process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'))
);
}
}

76
app/api/user/me/route.ts Normal file
View File

@@ -0,0 +1,76 @@
import { NextRequest, NextResponse } from 'next/server';
import { getUserByOsuId } from '@/app/lib/UserOperation';
export async function GET(request: NextRequest) {
try {
// 从 cookie 中获取用户会话
const userSessionCookie = request.cookies.get('user_session');
console.log('Cookie received:', userSessionCookie ? 'yes' : 'no');
if (userSessionCookie) {
console.log('Cookie value length:', userSessionCookie.value.length);
console.log('Cookie value first 100 chars:', userSessionCookie.value.substring(0, 100));
}
if (!userSessionCookie || !userSessionCookie.value) {
console.log('No user session cookie found');
return NextResponse.json(
{ error: 'Not authenticated' },
{ status: 401 }
);
}
try {
const userData = JSON.parse(userSessionCookie.value);
console.log('Parsed user data:', userData);
// 从数据库获取完整的用户信息
const user = await getUserByOsuId(userData.osuid);
if (!user) {
console.log('User not found in database for osuid:', userData.osuid);
// 如果数据库中没有用户,清除 cookie
const response = NextResponse.json(
{ error: 'User not found in database' },
{ status: 404 }
);
response.cookies.delete('user_session');
return response;
}
console.log('User found:', user.username);
return NextResponse.json(user);
} catch (parseError) {
console.error('Error parsing user session cookie:', parseError);
console.error('Raw cookie value:', userSessionCookie.value);
// 清除无效的 cookie
const response = NextResponse.json(
{ error: 'Invalid session' },
{ status: 400 }
);
response.cookies.delete('user_session');
return response;
}
} catch (error) {
console.error('Error getting current user:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
export async function DELETE(request: NextRequest) {
try {
// 清除用户会话 cookie
const response = NextResponse.json({ success: true });
response.cookies.delete('user_session');
return response;
} catch (error) {
console.error('Error logging out:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from 'next/server';
import { getUserByOsuId, getUserByUsername } from '@/app/lib/UserOperation';
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const type = searchParams.get('type'); // 'osuid' or 'username'
const value = searchParams.get('value');
if (!type || !value) {
return NextResponse.json(
{ error: 'Missing type or value parameter' },
{ status: 400 }
);
}
let user;
if (type === 'osuid') {
const osuid = parseInt(value);
if (isNaN(osuid)) {
return NextResponse.json(
{ error: 'Invalid osuid' },
{ status: 400 }
);
}
user = await getUserByOsuId(osuid);
} else if (type === 'username') {
user = await getUserByUsername(value);
} else {
return NextResponse.json(
{ error: 'Invalid type parameter. Use "osuid" or "username"' },
{ status: 400 }
);
}
if (!user) {
return NextResponse.json(
{ error: 'User not found' },
{ status: 404 }
);
}
return NextResponse.json(user);
} catch (error) {
console.error('Error searching user:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from 'next/server';
import { updateProfile } from '@/app/lib/UserOperation';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { osuid, ...updateData } = body;
if (!osuid) {
return NextResponse.json(
{ error: 'Missing osuid parameter' },
{ status: 400 }
);
}
const updatedUser = await updateProfile(osuid, updateData);
return NextResponse.json(updatedUser);
} catch (error) {
console.error('Error updating user:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@@ -1,19 +0,0 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/app/lib/PrismaClient';
// GET: 获取配置
export async function GET() {
try {
const config = await prisma.tournamentConfig.findUnique({
where: { id: 1 },
});
return NextResponse.json(config);
} catch (error) {
console.error('获取比赛配置失败:', error);
return NextResponse.json(
{ error: '获取配置失败' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,473 @@
'use client';
import Image from 'next/image';
import { useState, useEffect } from 'react';
import { Season } from '@/app/generated/prisma/enums';
interface UserInfo {
id: number;
osuid: number;
username: string;
avatar_url?: string;
cover_url?: string;
country_code: string;
pp: number;
global_rank: number;
country_rank: number;
seasonal: Season;
userState: string;
approved: boolean;
seed: number;
}
export default function RegisterUserCard() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [userInfo, setUserInfo] = useState<UserInfo | null>(null);
const [searchType, setSearchType] = useState<'osuid' | 'username'>('osuid');
const [searchValue, setSearchValue] = useState('');
const [updateData, setUpdateData] = useState({
username: '',
avatar_url: '',
cover_url: '',
country_code: '',
pp: 0,
global_rank: 0,
country_rank: 0,
});
// 检查URL参数处理OAuth回调结果并检查当前登录状态
useEffect(() => {
const checkCurrentUser = async () => {
console.log('Checking current user session...');
try {
const response = await fetch('/api/user/me', {
credentials: 'include', // 确保发送 cookie
});
console.log('API response status:', response.status);
if (response.ok) {
const user = await response.json();
console.log('User found:', user.username);
setUserInfo({
id: user.id,
osuid: user.osuid,
username: user.username,
avatar_url: user.avatar_url,
cover_url: user.cover_url,
country_code: user.country_code,
pp: user.pp,
global_rank: user.global_rank,
country_rank: user.country_rank,
seasonal: user.seasonal,
userState: user.userState,
approved: user.approved === 1,
seed: user.seed,
});
setUpdateData({
username: user.username,
avatar_url: user.avatar_url || '',
cover_url: user.cover_url || '',
country_code: user.country_code,
pp: user.pp,
global_rank: user.global_rank,
country_rank: user.country_rank,
});
} else {
console.log('User not authenticated or API error:', response.status);
}
} catch (err) {
// 忽略错误,用户可能未登录
console.log('Error checking user session:', err);
}
// 检查URL参数处理OAuth回调结果
const params = new URLSearchParams(window.location.search);
const successParam = params.get('success');
const usernameParam = params.get('username');
const osuidParam = params.get('osuid');
const errorParam = params.get('error');
if (successParam === 'true' && usernameParam && osuidParam) {
setSuccess(`用户 ${usernameParam} (osuid: ${osuidParam}) 注册/登录成功!`);
// 清空URL参数
const newUrl = window.location.pathname;
window.history.replaceState({}, '', newUrl);
}
if (errorParam) {
setError(decodeURIComponent(errorParam));
// 清空URL参数
const newUrl = window.location.pathname;
window.history.replaceState({}, '', newUrl);
}
};
checkCurrentUser();
}, []);
const handleOsuLogin = () => {
window.location.href = '/api/auth/getAuthUrl';
};
const handleSearchUser = async () => {
if (!searchValue.trim()) {
setError('请输入搜索值');
return;
}
setLoading(true);
setError(null);
setSuccess(null);
try {
const response = await fetch(`/api/user/search?type=${searchType}&value=${encodeURIComponent(searchValue)}`);
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || `搜索失败: ${response.status}`);
}
const user = await response.json();
if (user) {
setUserInfo({
id: user.id,
osuid: user.osuid,
username: user.username,
avatar_url: user.avatar_url,
cover_url: user.cover_url,
country_code: user.country_code,
pp: user.pp,
global_rank: user.global_rank,
country_rank: user.country_rank,
seasonal: user.seasonal,
userState: user.userState,
approved: user.approved === 1,
seed: user.seed,
});
setUpdateData({
username: user.username,
avatar_url: user.avatar_url || '',
cover_url: user.cover_url || '',
country_code: user.country_code,
pp: user.pp,
global_rank: user.global_rank,
country_rank: user.country_rank,
});
setSuccess(`找到用户: ${user.username} (ID: ${user.id})`);
} else {
setError('未找到用户');
setUserInfo(null);
}
} catch (err) {
setError('搜索用户失败: ' + (err instanceof Error ? err.message : String(err)));
setUserInfo(null);
} finally {
setLoading(false);
}
};
const handleUpdateProfile = async () => {
if (!userInfo) {
setError('没有用户信息可更新');
return;
}
setLoading(true);
setError(null);
try {
const updatePayload: {
username?: string;
avatar_url?: string;
cover_url?: string;
country_code?: string;
pp?: number;
global_rank?: number;
country_rank?: number;
} = {};
// 只添加有变化的字段
if (updateData.username !== userInfo.username) updatePayload.username = updateData.username;
if (updateData.avatar_url !== userInfo.avatar_url) updatePayload.avatar_url = updateData.avatar_url;
if (updateData.cover_url !== userInfo.cover_url) updatePayload.cover_url = updateData.cover_url;
if (updateData.country_code !== userInfo.country_code) updatePayload.country_code = updateData.country_code;
if (updateData.pp !== userInfo.pp) updatePayload.pp = updateData.pp;
if (updateData.global_rank !== userInfo.global_rank) updatePayload.global_rank = updateData.global_rank;
if (updateData.country_rank !== userInfo.country_rank) updatePayload.country_rank = updateData.country_rank;
// 如果没有变化,直接返回
if (Object.keys(updatePayload).length === 0) {
setSuccess('没有需要更新的信息');
setLoading(false);
return;
}
const response = await fetch('/api/user/update', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
osuid: userInfo.osuid,
...updatePayload,
}),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || `更新失败: ${response.status}`);
}
const updatedUser = await response.json();
setUserInfo({
id: updatedUser.id,
osuid: updatedUser.osuid,
username: updatedUser.username,
avatar_url: updatedUser.avatar_url,
cover_url: updatedUser.cover_url,
country_code: updatedUser.country_code,
pp: updatedUser.pp,
global_rank: updatedUser.global_rank,
country_rank: updatedUser.country_rank,
seasonal: updatedUser.seasonal,
userState: updatedUser.userState,
approved: updatedUser.approved === 1,
seed: updatedUser.seed,
});
setSuccess('用户信息更新成功!');
} catch (err) {
setError('更新用户信息失败: ' + (err instanceof Error ? err.message : String(err)));
} finally {
setLoading(false);
}
};
const handleLogout = async () => {
try {
// 调用 API 清除 cookie
const response = await fetch('/api/user/me', {
method: 'DELETE',
});
if (response.ok) {
setUserInfo(null);
setUpdateData({
username: '',
avatar_url: '',
cover_url: '',
country_code: '',
pp: 0,
global_rank: 0,
country_rank: 0,
});
setSuccess('已退出登录');
setError(null);
} else {
setError('退出登录失败');
}
} catch (err) {
setError('退出登录失败: ' + (err instanceof Error ? err.message : String(err)));
}
};
return (
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
<h2 className="text-2xl font-bold mb-4"></h2>
{error && (
<div className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded">
{error}
</div>
)}
{success && (
<div className="mb-4 p-3 bg-green-100 border border-green-400 text-green-700 rounded">
{success}
</div>
)}
<div className="mb-6">
<h3 className="text-lg font-semibold mb-2">osu! OAuth </h3>
<p className="text-gray-600 mb-4">使 osu! </p>
<button
onClick={handleOsuLogin}
disabled={loading}
className="px-4 py-2 bg-pink-600 text-white rounded hover:bg-pink-700 disabled:opacity-50"
>
{loading ? '处理中...' : '使用 osu! 账号登录'}
</button>
</div>
<div className="mb-6">
<h3 className="text-lg font-semibold mb-2"></h3>
<div className="flex flex-col md:flex-row gap-2 mb-4">
<div className="flex items-center gap-2">
<label className="flex items-center">
<input
type="radio"
checked={searchType === 'osuid'}
onChange={() => setSearchType('osuid')}
className="mr-2"
/>
osuid
</label>
<label className="flex items-center">
<input
type="radio"
checked={searchType === 'username'}
onChange={() => setSearchType('username')}
className="mr-2"
/>
</label>
</div>
<input
type="text"
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
placeholder={searchType === 'osuid' ? '输入 osuid' : '输入用户名'}
className="flex-grow px-3 py-2 border rounded"
/>
<button
onClick={handleSearchUser}
disabled={loading}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
>
{loading ? '搜索中...' : '搜索用户'}
</button>
</div>
</div>
{userInfo && (
<div className="border-t pt-6">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold"></h3>
<button
onClick={handleLogout}
className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700"
>
退
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<div>
<p><strong>ID:</strong> {userInfo.id}</p>
<p><strong>osuid:</strong> {userInfo.osuid}</p>
<p><strong>:</strong> {userInfo.username}</p>
<p><strong>:</strong> {userInfo.country_code}</p>
<p><strong>PP:</strong> {userInfo.pp.toFixed(2)}</p>
</div>
<div>
<p><strong>:</strong> {userInfo.global_rank}</p>
<p><strong>:</strong> {userInfo.country_rank}</p>
<p><strong>:</strong> {userInfo.seasonal}</p>
<p><strong>:</strong> {userInfo.userState}</p>
<p><strong>:</strong> {userInfo.approved ? '是' : '否'}</p>
<p><strong>:</strong> {userInfo.seed}</p>
</div>
</div>
{userInfo.avatar_url && (
<div className="mb-4">
<p className="font-semibold mb-2">:</p>
<Image src={userInfo.avatar_url}
alt="用户头像"
className="w-24 h-24 rounded-full"
width={24}
height={24}
/>
</div>
)}
<div className="mt-6">
<h4 className="text-md font-semibold mb-3"></h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label className="block text-sm font-medium mb-1"></label>
<input
type="text"
value={updateData.username}
onChange={(e) => setUpdateData({...updateData, username: e.target.value})}
className="w-full px-3 py-2 border rounded"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1"></label>
<input
type="text"
value={updateData.country_code}
onChange={(e) => setUpdateData({...updateData, country_code: e.target.value})}
className="w-full px-3 py-2 border rounded"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">PP</label>
<input
type="number"
value={updateData.pp}
onChange={(e) => setUpdateData({...updateData, pp: parseFloat(e.target.value) || 0})}
className="w-full px-3 py-2 border rounded"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1"></label>
<input
type="number"
value={updateData.global_rank}
onChange={(e) => setUpdateData({...updateData, global_rank: parseInt(e.target.value) || 0})}
className="w-full px-3 py-2 border rounded"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1"></label>
<input
type="number"
value={updateData.country_rank}
onChange={(e) => setUpdateData({...updateData, country_rank: parseInt(e.target.value) || 0})}
className="w-full px-3 py-2 border rounded"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">URL</label>
<input
type="text"
value={updateData.avatar_url}
onChange={(e) => setUpdateData({...updateData, avatar_url: e.target.value})}
className="w-full px-3 py-2 border rounded"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">URL</label>
<input
type="text"
value={updateData.cover_url}
onChange={(e) => setUpdateData({...updateData, cover_url: e.target.value})}
className="w-full px-3 py-2 border rounded"
/>
</div>
</div>
<button
onClick={handleUpdateProfile}
disabled={loading}
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50"
>
{loading ? '更新中...' : '更新用户信息'}
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,12 +1,14 @@
import TournamentConfigCard from '@/app/debug/components/TournamentConfigCard'; import TournamentConfigCard from '@/app/debug/components/TournamentConfigCard';
import RegisterUserCard from '@/app/debug/components/RegisterUserCard';
export default function DebugPage() { export default function DebugPage() {
return ( return (
<div className="container mx-auto p-6"> <div className="container mx-auto p-6">
<h1 className="text-3xl font-bold mb-6"></h1> <h1 className="text-3xl font-bold mb-6"></h1>
<RegisterUserCard />
<TournamentConfigCard /> <TournamentConfigCard />
</div> </div>
); );
} }

View File

@@ -14,7 +14,7 @@ import { PrismaClient } from '@/app/generated/prisma/client'
import { PrismaPg } from "@prisma/adapter-pg"; import { PrismaPg } from "@prisma/adapter-pg";
const connectionString = process.env.DATABASE_URL; const connectionString = process.env.DATABASE_CLIENT_URL;
const adapter = new PrismaPg({ connectionString }); const adapter = new PrismaPg({ connectionString });
export const prisma = new PrismaClient({ export const prisma = new PrismaClient({

View File

@@ -1,5 +1,6 @@
import { UserState } from "@/app/generated/prisma/enums"; import { UserState,Season } from "@/app/generated/prisma/enums";
import { MultiplayerSoloRoom, Team } from "@/app/generated/prisma/client"; import { MultiplayerSoloRoom, Team } from "@/app/generated/prisma/client";
import { prisma } from "./PrismaClient";
export interface UserSession { export interface UserSession {
id: number; id: number;
@@ -14,7 +15,7 @@ export interface UserSession {
country_rank: number; country_rank: number;
approved: number; approved: number;
seed: number; seed: number;
seasonal: number; seasonal: Season;
userGroups: number[]; userGroups: number[];
SoloRedPlayer?: MultiplayerSoloRoom[]; SoloRedPlayer?: MultiplayerSoloRoom[];
SoloBluePlayer?: MultiplayerSoloRoom[]; SoloBluePlayer?: MultiplayerSoloRoom[];
@@ -23,6 +24,323 @@ export interface UserSession {
updatedAt: Date; updatedAt: Date;
} }
export async function CreateUser(user:UserSession) { export async function CreateUser(userData: {
osuid: number;
username: string;
avatar_url?: string;
cover_url?: string;
country_code: string;
pp: number;
global_rank: number | null;
country_rank: number | null;
}): Promise<UserSession> {
try {
// 获取当前配置
const config = await prisma.tournamentConfig.findUnique({
where: { id: 1 },
});
const currentSeasonal = config?.current_seasonal || Season.S1;
const user = await prisma.user.upsert({
where: { osuid: userData.osuid },
update: {
username: userData.username,
avatar_url: userData.avatar_url,
cover_url: userData.cover_url,
country_code: userData.country_code,
pp: userData.pp,
global_rank: userData.global_rank || 0,
country_rank: userData.country_rank || 0,
updatedAt: new Date(),
},
create: {
osuid: userData.osuid,
username: userData.username,
avatar_url: userData.avatar_url,
cover_url: userData.cover_url,
country_code: userData.country_code,
pp: userData.pp,
global_rank: userData.global_rank || 0,
country_rank: userData.country_rank || 0,
userState: UserState.ACTIVE,
approved: false,
seed: 0,
seasonal: currentSeasonal,
},
});
return {
id: user.id,
userState: user.userState,
osuid: user.osuid,
username: user.username,
avatar_url: user.avatar_url || undefined,
cover_url: user.cover_url || undefined,
country_code: user.country_code,
pp: user.pp,
global_rank: user.global_rank,
country_rank: user.country_rank,
approved: user.approved ? 1 : 0,
seed: user.seed,
seasonal: user.seasonal,
userGroups: [],
createdAt: user.createdAt,
updatedAt: user.updatedAt,
};
} catch (error) {
console.error('Error creating/updating user:', error);
throw error;
}
}
export async function getUserByOsuId(osuid: number): Promise<UserSession | null> {
try {
const user = await prisma.user.findUnique({
where: { osuid },
include: {
userGroups: true,
SoloRedPlayer: true,
SoloBluePlayer: true,
teams: true,
},
});
if (!user) {
return null;
}
return {
id: user.id,
userState: user.userState,
osuid: user.osuid,
username: user.username,
avatar_url: user.avatar_url || undefined,
cover_url: user.cover_url || undefined,
country_code: user.country_code,
pp: user.pp,
global_rank: user.global_rank,
country_rank: user.country_rank,
approved: user.approved ? 1 : 0,
seed: user.seed,
seasonal: user.seasonal,
userGroups: user.userGroups.map(group => group.id),
SoloRedPlayer: user.SoloRedPlayer,
SoloBluePlayer: user.SoloBluePlayer,
teams: user.teams,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
};
} catch (error) {
console.error('Error getting user by osuid:', error);
throw error;
}
}
export async function getUserByUsername(username: string): Promise<UserSession | null> {
try {
const user = await prisma.user.findUnique({
where: { username },
include: {
userGroups: true,
SoloRedPlayer: true,
SoloBluePlayer: true,
teams: true,
},
});
if (!user) {
return null;
}
return {
id: user.id,
userState: user.userState,
osuid: user.osuid,
username: user.username,
avatar_url: user.avatar_url || undefined,
cover_url: user.cover_url || undefined,
country_code: user.country_code,
pp: user.pp,
global_rank: user.global_rank,
country_rank: user.country_rank,
approved: user.approved ? 1 : 0,
seed: user.seed,
seasonal: user.seasonal,
userGroups: user.userGroups.map(group => group.id),
SoloRedPlayer: user.SoloRedPlayer,
SoloBluePlayer: user.SoloBluePlayer,
teams: user.teams,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
};
} catch (error) {
console.error('Error getting user by username:', error);
throw error;
}
}
export async function updateProfile(osuid: number, updateData: {
username?: string;
avatar_url?: string;
cover_url?: string;
country_code?: string;
pp?: number;
global_rank?: number;
country_rank?: number;
userState?: UserState;
approved?: boolean;
seed?: number;
seasonal?: Season;
}): Promise<UserSession> {
try {
const user = await prisma.user.update({
where: { osuid },
data: {
...updateData,
updatedAt: new Date(),
},
include: {
userGroups: true,
SoloRedPlayer: true,
SoloBluePlayer: true,
teams: true,
},
});
return {
id: user.id,
userState: user.userState,
osuid: user.osuid,
username: user.username,
avatar_url: user.avatar_url || undefined,
cover_url: user.cover_url || undefined,
country_code: user.country_code,
pp: user.pp,
global_rank: user.global_rank,
country_rank: user.country_rank,
approved: user.approved ? 1 : 0,
seed: user.seed,
seasonal: user.seasonal,
userGroups: user.userGroups.map(group => group.id),
SoloRedPlayer: user.SoloRedPlayer,
SoloBluePlayer: user.SoloBluePlayer,
teams: user.teams,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
};
} catch (error) {
console.error('Error updating user profile:', error);
throw error;
}
}
export async function addUserToGroup(userId: number, groupId: number): Promise<UserSession> {
try {
const user = await prisma.user.update({
where: { id: userId },
data: {
userGroups: {
connect: { id: groupId },
},
updatedAt: new Date(),
},
include: {
userGroups: true,
SoloRedPlayer: true,
SoloBluePlayer: true,
teams: true,
},
});
return {
id: user.id,
userState: user.userState,
osuid: user.osuid,
username: user.username,
avatar_url: user.avatar_url || undefined,
cover_url: user.cover_url || undefined,
country_code: user.country_code,
pp: user.pp,
global_rank: user.global_rank,
country_rank: user.country_rank,
approved: user.approved ? 1 : 0,
seed: user.seed,
seasonal: user.seasonal,
userGroups: user.userGroups.map(group => group.id),
SoloRedPlayer: user.SoloRedPlayer,
SoloBluePlayer: user.SoloBluePlayer,
teams: user.teams,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
};
} catch (error) {
console.error('Error adding user to group:', error);
throw error;
}
}
export async function removeUserFromGroup(userId: number, groupId: number): Promise<UserSession> {
try {
const user = await prisma.user.update({
where: { id: userId },
data: {
userGroups: {
disconnect: { id: groupId },
},
updatedAt: new Date(),
},
include: {
userGroups: true,
SoloRedPlayer: true,
SoloBluePlayer: true,
teams: true,
},
});
return {
id: user.id,
userState: user.userState,
osuid: user.osuid,
username: user.username,
avatar_url: user.avatar_url || undefined,
cover_url: user.cover_url || undefined,
country_code: user.country_code,
pp: user.pp,
global_rank: user.global_rank,
country_rank: user.country_rank,
approved: user.approved ? 1 : 0,
seed: user.seed,
seasonal: user.seasonal,
userGroups: user.userGroups.map(group => group.id),
SoloRedPlayer: user.SoloRedPlayer,
SoloBluePlayer: user.SoloBluePlayer,
teams: user.teams,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
};
} catch (error) {
console.error('Error removing user from group:', error);
throw error;
}
}
export async function getUserGroups(userId: number): Promise<number[]> {
try {
const user = await prisma.user.findUnique({
where: { id: userId },
include: {
userGroups: true,
},
});
if (!user) {
return [];
}
return user.userGroups.map(group => group.id);
} catch (error) {
console.error('Error getting user groups:', error);
throw error;
}
} }

View File

@@ -1,6 +1,7 @@
// 这些环境变量只在服务器端可用
const OSU_CLIENT_ID = process.env.OSU_CLIENT_ID || ''; const OSU_CLIENT_ID = process.env.OSU_CLIENT_ID || '';
const OSU_CLIENT_SECRET = process.env.OSU_CLIENT_SECRET || ''; const OSU_CLIENT_SECRET = process.env.OSU_CLIENT_SECRET || '';
const OSU_REDIRECT_URI = process.env.OSU_REDIRECT_URI || 'http://localhost:3000/auth/osu/callback'; const OSU_REDIRECT_URI = process.env.OSU_REDIRECT_URI || 'http://localhost:3000/api/auth/callback/osu';
export function getOsuAuthUrl() { export function getOsuAuthUrl() {
if (!OSU_CLIENT_ID || !OSU_CLIENT_SECRET) { if (!OSU_CLIENT_ID || !OSU_CLIENT_SECRET) {

View File

@@ -1,7 +1,25 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'a.ppy.sh',
pathname: '/**',
},
{
protocol: 'https',
hostname: 'assets.ppy.sh',
pathname: '/**',
},
{
protocol: 'https',
hostname: 'osu.ppy.sh',
pathname: '/**',
},
],
},
}; };
export default nextConfig; export default nextConfig;

View File

@@ -0,0 +1,307 @@
-- CreateEnum
CREATE TYPE "BeatmapSelectMod" AS ENUM ('NM', 'HD', 'HR', 'DT', 'LZ', 'TB');
-- CreateEnum
CREATE TYPE "MultiplayerRoomType" AS ENUM ('SOLO', 'TEAM_VS');
-- CreateEnum
CREATE TYPE "TeamColor" AS ENUM ('blue_team', 'red_team');
-- CreateEnum
CREATE TYPE "TeamState" AS ENUM ('ACTIVE', 'UNAPPROVED', 'APPROVED', 'BANNED');
-- CreateEnum
CREATE TYPE "UserState" AS ENUM ('ACTIVE', 'REGISTERED', 'ABANDONED', 'BANNED');
-- CreateEnum
CREATE TYPE "UserGroupType" AS ENUM ('HOST', 'ADMIN', 'POOLER', 'STREAMER', 'TESTER', 'GFX', 'SHEETER', 'COMMENTATOR', 'MAPPER', 'REFEREE', 'PLAYER');
-- CreateEnum
CREATE TYPE "Season" AS ENUM ('S1', 'S2');
-- CreateEnum
CREATE TYPE "Category" AS ENUM ('QUA', 'RO16', 'QF', 'SF', 'F', 'GF');
-- CreateTable
CREATE TABLE "Beatmap" (
"id" SERIAL NOT NULL,
"beatmap_id" INTEGER NOT NULL,
"beatmapset_id" INTEGER NOT NULL,
"title" TEXT NOT NULL,
"title_unicode" TEXT NOT NULL,
"artist" TEXT NOT NULL,
"artist_unicode" TEXT NOT NULL,
"creator" TEXT NOT NULL,
"cover_url" TEXT NOT NULL,
"version" TEXT NOT NULL,
"ar" DOUBLE PRECISION NOT NULL,
"od" DOUBLE PRECISION NOT NULL,
"cs" DOUBLE PRECISION NOT NULL,
"hp" DOUBLE PRECISION NOT NULL,
"bpm" DOUBLE PRECISION NOT NULL,
"length" INTEGER NOT NULL,
"max_combo" INTEGER NOT NULL,
"star_rating" DOUBLE PRECISION NOT NULL,
"mod" JSONB NOT NULL DEFAULT '[]',
"selectMod" "BeatmapSelectMod" NOT NULL DEFAULT 'NM',
"selectModSlot" INTEGER NOT NULL DEFAULT 1,
"selectByosuId" INTEGER NOT NULL,
"selectNote" TEXT NOT NULL DEFAULT '',
"approved" BOOLEAN NOT NULL DEFAULT false,
"needTest" BOOLEAN NOT NULL DEFAULT false,
"isCustom" BOOLEAN NOT NULL DEFAULT false,
"isOriginal" BOOLEAN NOT NULL DEFAULT false,
"season" INTEGER NOT NULL DEFAULT 1,
"category" "Category" NOT NULL DEFAULT 'QUA',
"enabled" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Beatmap_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BeatmapComment" (
"id" SERIAL NOT NULL,
"beatmap_id" INTEGER NOT NULL,
"comment" TEXT NOT NULL DEFAULT '',
"osuid" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BeatmapComment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Message" (
"id" SERIAL NOT NULL,
"title" TEXT NOT NULL,
"content" TEXT NOT NULL,
"sender_id" INTEGER NOT NULL,
"receiver_id" INTEGER NOT NULL,
"room_id" INTEGER,
"isRead" BOOLEAN NOT NULL DEFAULT false,
"isDeleted" BOOLEAN NOT NULL DEFAULT false,
"isSystemMessage" BOOLEAN NOT NULL DEFAULT false,
"isAccepted" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Message_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "MultiplayerRoom" (
"room_id" SERIAL NOT NULL,
"type" "MultiplayerRoomType" NOT NULL DEFAULT 'SOLO',
"season" "Season" NOT NULL DEFAULT 'S1',
"category" "Category" NOT NULL DEFAULT 'QUA',
"multiplayerSoloRoomRoom_id" INTEGER,
"multiplayerTeamvsRoomRoom_id" INTEGER,
"score_red" INTEGER NOT NULL DEFAULT 0,
"score_blue" INTEGER NOT NULL DEFAULT 0,
"start_time" TIMESTAMP(3) NOT NULL,
"end_time" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "MultiplayerRoom_pkey" PRIMARY KEY ("room_id")
);
-- CreateTable
CREATE TABLE "MultiplayerSoloRoom" (
"room_id" SERIAL NOT NULL,
"player_red_id" INTEGER NOT NULL,
"player_blue_id" INTEGER NOT NULL,
CONSTRAINT "MultiplayerSoloRoom_pkey" PRIMARY KEY ("room_id")
);
-- CreateTable
CREATE TABLE "MultiplayerTeamvsRoom" (
"room_id" SERIAL NOT NULL,
"team_red_id" INTEGER NOT NULL,
"team_blue_id" INTEGER NOT NULL,
CONSTRAINT "MultiplayerTeamvsRoom_pkey" PRIMARY KEY ("room_id")
);
-- CreateTable
CREATE TABLE "Score" (
"id" SERIAL NOT NULL,
"user_id" INTEGER NOT NULL,
"username" TEXT NOT NULL,
"beatmap_id" INTEGER NOT NULL,
"total_score" INTEGER NOT NULL DEFAULT 0,
"accuracy" DOUBLE PRECISION NOT NULL DEFAULT 0,
"max_combo" INTEGER NOT NULL DEFAULT 0,
"mods" JSONB NOT NULL DEFAULT '[]',
"rank" INTEGER NOT NULL DEFAULT 0,
"pp" DOUBLE PRECISION NOT NULL DEFAULT 0,
"passed" BOOLEAN NOT NULL DEFAULT true,
"statistics" JSONB NOT NULL DEFAULT '[]',
"ended_at" TIMESTAMP(3) NOT NULL,
"saved_at" TIMESTAMP(3) NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Score_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Team" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"banner_url" TEXT NOT NULL,
"teamColor" "TeamColor" NOT NULL,
"teamState" "TeamState" NOT NULL DEFAULT 'ACTIVE',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Team_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "User" (
"id" SERIAL NOT NULL,
"userState" "UserState" NOT NULL DEFAULT 'ACTIVE',
"osuid" INTEGER NOT NULL,
"username" TEXT NOT NULL,
"avatar_url" TEXT,
"cover_url" TEXT,
"country_code" TEXT NOT NULL,
"pp" DOUBLE PRECISION NOT NULL DEFAULT 0,
"global_rank" INTEGER NOT NULL DEFAULT 0,
"country_rank" INTEGER NOT NULL DEFAULT 0,
"approved" BOOLEAN NOT NULL DEFAULT false,
"seed" INTEGER NOT NULL DEFAULT 0,
"seasonal" "Season" NOT NULL DEFAULT 'S1',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "UserGroup" (
"id" SERIAL NOT NULL,
"group" "UserGroupType" NOT NULL DEFAULT 'PLAYER',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "UserGroup_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TournamentConfig" (
"id" SERIAL NOT NULL,
"tournament_name" TEXT NOT NULL DEFAULT 'AstarCup',
"max_pp_for_registration" DOUBLE PRECISION NOT NULL DEFAULT 0,
"min_pp_for_registration" DOUBLE PRECISION NOT NULL DEFAULT 0,
"current_seasonal" "Season" NOT NULL DEFAULT 'S1',
"current_category" "Category" NOT NULL DEFAULT 'QUA',
"canRegister" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TournamentConfig_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "_TeamToUser" (
"A" INTEGER NOT NULL,
"B" INTEGER NOT NULL,
CONSTRAINT "_TeamToUser_AB_pkey" PRIMARY KEY ("A","B")
);
-- CreateTable
CREATE TABLE "_UserToUserGroup" (
"A" INTEGER NOT NULL,
"B" INTEGER NOT NULL,
CONSTRAINT "_UserToUserGroup_AB_pkey" PRIMARY KEY ("A","B")
);
-- CreateIndex
CREATE UNIQUE INDEX "Beatmap_beatmap_id_key" ON "Beatmap"("beatmap_id");
-- CreateIndex
CREATE UNIQUE INDEX "Beatmap_beatmapset_id_key" ON "Beatmap"("beatmapset_id");
-- CreateIndex
CREATE UNIQUE INDEX "BeatmapComment_beatmap_id_key" ON "BeatmapComment"("beatmap_id");
-- CreateIndex
CREATE UNIQUE INDEX "BeatmapComment_osuid_key" ON "BeatmapComment"("osuid");
-- CreateIndex
CREATE UNIQUE INDEX "MultiplayerSoloRoom_player_red_id_key" ON "MultiplayerSoloRoom"("player_red_id");
-- CreateIndex
CREATE UNIQUE INDEX "MultiplayerSoloRoom_player_blue_id_key" ON "MultiplayerSoloRoom"("player_blue_id");
-- CreateIndex
CREATE UNIQUE INDEX "MultiplayerTeamvsRoom_team_red_id_key" ON "MultiplayerTeamvsRoom"("team_red_id");
-- CreateIndex
CREATE UNIQUE INDEX "MultiplayerTeamvsRoom_team_blue_id_key" ON "MultiplayerTeamvsRoom"("team_blue_id");
-- CreateIndex
CREATE UNIQUE INDEX "User_osuid_key" ON "User"("osuid");
-- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
-- CreateIndex
CREATE INDEX "_TeamToUser_B_index" ON "_TeamToUser"("B");
-- CreateIndex
CREATE INDEX "_UserToUserGroup_B_index" ON "_UserToUserGroup"("B");
-- AddForeignKey
ALTER TABLE "Beatmap" ADD CONSTRAINT "Beatmap_selectByosuId_fkey" FOREIGN KEY ("selectByosuId") REFERENCES "User"("osuid") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BeatmapComment" ADD CONSTRAINT "BeatmapComment_beatmap_id_fkey" FOREIGN KEY ("beatmap_id") REFERENCES "Beatmap"("beatmap_id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BeatmapComment" ADD CONSTRAINT "BeatmapComment_osuid_fkey" FOREIGN KEY ("osuid") REFERENCES "User"("osuid") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Message" ADD CONSTRAINT "Message_sender_id_fkey" FOREIGN KEY ("sender_id") REFERENCES "User"("osuid") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Message" ADD CONSTRAINT "Message_receiver_id_fkey" FOREIGN KEY ("receiver_id") REFERENCES "User"("osuid") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Message" ADD CONSTRAINT "Message_room_id_fkey" FOREIGN KEY ("room_id") REFERENCES "MultiplayerRoom"("room_id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MultiplayerRoom" ADD CONSTRAINT "MultiplayerRoom_multiplayerSoloRoomRoom_id_fkey" FOREIGN KEY ("multiplayerSoloRoomRoom_id") REFERENCES "MultiplayerSoloRoom"("room_id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MultiplayerRoom" ADD CONSTRAINT "MultiplayerRoom_multiplayerTeamvsRoomRoom_id_fkey" FOREIGN KEY ("multiplayerTeamvsRoomRoom_id") REFERENCES "MultiplayerTeamvsRoom"("room_id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MultiplayerSoloRoom" ADD CONSTRAINT "MultiplayerSoloRoom_player_red_id_fkey" FOREIGN KEY ("player_red_id") REFERENCES "User"("osuid") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MultiplayerSoloRoom" ADD CONSTRAINT "MultiplayerSoloRoom_player_blue_id_fkey" FOREIGN KEY ("player_blue_id") REFERENCES "User"("osuid") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MultiplayerTeamvsRoom" ADD CONSTRAINT "MultiplayerTeamvsRoom_team_red_id_fkey" FOREIGN KEY ("team_red_id") REFERENCES "Team"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MultiplayerTeamvsRoom" ADD CONSTRAINT "MultiplayerTeamvsRoom_team_blue_id_fkey" FOREIGN KEY ("team_blue_id") REFERENCES "Team"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_TeamToUser" ADD CONSTRAINT "_TeamToUser_A_fkey" FOREIGN KEY ("A") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_TeamToUser" ADD CONSTRAINT "_TeamToUser_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_UserToUserGroup" ADD CONSTRAINT "_UserToUserGroup_A_fkey" FOREIGN KEY ("A") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_UserToUserGroup" ADD CONSTRAINT "_UserToUserGroup_B_fkey" FOREIGN KEY ("B") REFERENCES "UserGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@@ -11,7 +11,7 @@ model User {
country_rank Int @default(0) country_rank Int @default(0)
approved Boolean @default(false) approved Boolean @default(false)
seed Int @default(0) seed Int @default(0)
seasonal Int @default(1) seasonal Season @default(S1)
SoloRedPlayer MultiplayerSoloRoom[] @relation("SoloRedPlayer") SoloRedPlayer MultiplayerSoloRoom[] @relation("SoloRedPlayer")
SoloBluePlayer MultiplayerSoloRoom[] @relation("SoloBluePlayer") SoloBluePlayer MultiplayerSoloRoom[] @relation("SoloBluePlayer")
teams Team[] teams Team[]