- 방 막대에 [🚪 나가기]를 두고 메뉴에서는 뺀다. [← 홈]도 같은 동작. 한 번 누르면 바로 나가고, 게임 중에 자리에 앉아 있을 때만 기권 확인을 묻는다. 휴대폰은 방 링크 복사를 메뉴로 옮겨 한 줄에 맞춘다. - 자리·관전자·결과 화면의 이름을 누르면 기록 카드(이 게임 판·승·패·무·승률, 이 방 전적, 전체 합계). GET /api/users/:id/stats (로그인 필요). - 게스트 경고: 이름 정하기, 로그인 페이지, /me, 게스트가 있는 대기실. - 관리자 사용자 상세: 전체 기록, 게임별 기록 표, 최근 게임(GET /api/admin/users/:id/games). 목록에 전적 열(PC). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
213 lines
8.8 KiB
TypeScript
213 lines
8.8 KiB
TypeScript
import type { Database } from 'bun:sqlite';
|
|
import { ulid } from 'ulid';
|
|
import type { PublicUser } from '@bg/shared';
|
|
|
|
export interface UserRow {
|
|
id: string;
|
|
kind: 'guest' | 'member';
|
|
nickname: string;
|
|
avatar_url: string | null;
|
|
use_avatar: number;
|
|
settings_json: string;
|
|
nick_changed_at: number | null;
|
|
nick_change_count: number;
|
|
created_at: number;
|
|
last_seen_at: number;
|
|
deleted_at: number | null;
|
|
banned_at: number | null;
|
|
banned_reason: string | null;
|
|
}
|
|
|
|
export const NICK_CHANGES_PER_DAY = 10;
|
|
|
|
export class Users {
|
|
constructor(private db: Database) {}
|
|
|
|
get(id: string): UserRow | null {
|
|
return this.db.query<UserRow, [string]>('SELECT * FROM users WHERE id = ? AND deleted_at IS NULL').get(id);
|
|
}
|
|
|
|
createGuest(nickname: string, now = Date.now()): UserRow {
|
|
const id = ulid(now);
|
|
this.db
|
|
.query('INSERT INTO users (id, kind, nickname, created_at, last_seen_at) VALUES (?, ?, ?, ?, ?)')
|
|
.run(id, 'guest', nickname, now, now);
|
|
return this.get(id)!;
|
|
}
|
|
|
|
findByOauth(provider: string, providerUserId: string): UserRow | null {
|
|
return this.db
|
|
.query<UserRow, [string, string]>(
|
|
`SELECT u.* FROM users u JOIN oauth_accounts o ON o.user_id = u.id
|
|
WHERE o.provider = ? AND o.provider_user_id = ? AND u.deleted_at IS NULL`,
|
|
)
|
|
.get(provider, providerUserId);
|
|
}
|
|
|
|
createMember(nickname: string, avatarUrl: string | null, now = Date.now()): UserRow {
|
|
const id = ulid(now);
|
|
this.db
|
|
.query('INSERT INTO users (id, kind, nickname, avatar_url, created_at, last_seen_at) VALUES (?, ?, ?, ?, ?, ?)')
|
|
.run(id, 'member', nickname, avatarUrl, now, now);
|
|
return this.get(id)!;
|
|
}
|
|
|
|
linkOauth(userId: string, provider: string, providerUserId: string, username: string | null, now = Date.now()): void {
|
|
this.db
|
|
.query('INSERT INTO oauth_accounts (provider, provider_user_id, user_id, username, created_at) VALUES (?, ?, ?, ?, ?)')
|
|
.run(provider, providerUserId, userId, username, now);
|
|
}
|
|
|
|
promoteToMember(userId: string, avatarUrl: string | null): void {
|
|
this.db.query("UPDATE users SET kind = 'member', avatar_url = ? WHERE id = ?").run(avatarUrl, userId);
|
|
}
|
|
|
|
setAvatar(userId: string, avatarUrl: string | null): void {
|
|
this.db.query('UPDATE users SET avatar_url = ? WHERE id = ?').run(avatarUrl, userId);
|
|
}
|
|
|
|
/** Moves stats from a guest into a member account and deletes the guest (docs/05 §4). */
|
|
mergeGuestInto(guestId: string, memberId: string): void {
|
|
this.db.transaction(() => {
|
|
const rows = this.db
|
|
.query<{ game_id: string; played: number; wins: number; draws: number }, [string]>(
|
|
'SELECT game_id, played, wins, draws FROM user_stats WHERE user_id = ?',
|
|
)
|
|
.all(guestId);
|
|
for (const r of rows) {
|
|
this.db
|
|
.query(
|
|
`INSERT INTO user_stats (user_id, game_id, played, wins, draws) VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT(user_id, game_id) DO UPDATE SET played = played + excluded.played,
|
|
wins = wins + excluded.wins, draws = draws + excluded.draws`,
|
|
)
|
|
.run(memberId, r.game_id, r.played, r.wins, r.draws);
|
|
}
|
|
this.db.query('DELETE FROM user_stats WHERE user_id = ?').run(guestId);
|
|
this.db.query('UPDATE game_players SET user_id = ? WHERE user_id = ?').run(memberId, guestId);
|
|
this.db.query('DELETE FROM sessions WHERE user_id = ?').run(guestId);
|
|
this.db.query('DELETE FROM users WHERE id = ?').run(guestId);
|
|
})();
|
|
}
|
|
|
|
/** Returns an error message or null. Enforces the daily change limit. */
|
|
changeNickname(userId: string, nickname: string, now = Date.now()): string | null {
|
|
const u = this.get(userId);
|
|
if (!u) return '사용자를 찾을 수 없어요.';
|
|
if (u.nickname === nickname) return null;
|
|
const sameDay = u.nick_changed_at !== null && now - u.nick_changed_at < 24 * 3600_000;
|
|
const count = sameDay ? u.nick_change_count : 0;
|
|
if (count >= NICK_CHANGES_PER_DAY) return '닉네임은 하루에 10번까지 바꿀 수 있어요.';
|
|
this.db
|
|
.query('UPDATE users SET nickname = ?, nick_changed_at = ?, nick_change_count = ? WHERE id = ?')
|
|
.run(nickname, sameDay ? u.nick_changed_at : now, count + 1, userId);
|
|
return null;
|
|
}
|
|
|
|
updateSettings(userId: string, patch: { useAvatar?: boolean; settings?: Record<string, unknown> }): void {
|
|
if (patch.useAvatar !== undefined) {
|
|
this.db.query('UPDATE users SET use_avatar = ? WHERE id = ?').run(patch.useAvatar ? 1 : 0, userId);
|
|
}
|
|
if (patch.settings) {
|
|
this.db.query('UPDATE users SET settings_json = ? WHERE id = ?').run(JSON.stringify(patch.settings), userId);
|
|
}
|
|
}
|
|
|
|
/** Admin rename: ignores the daily limit. */
|
|
forceNickname(userId: string, nickname: string): void {
|
|
this.db.query('UPDATE users SET nickname = ? WHERE id = ?').run(nickname, userId);
|
|
}
|
|
|
|
setBan(userId: string, reason: string | null, now = Date.now()): void {
|
|
if (reason === null) this.db.query('UPDATE users SET banned_at = NULL, banned_reason = NULL WHERE id = ?').run(userId);
|
|
else this.db.query('UPDATE users SET banned_at = ?, banned_reason = ? WHERE id = ?').run(now, reason, userId);
|
|
}
|
|
|
|
search(q: string, limit: number, offset: number) {
|
|
const like = `%${q.replace(/[%_]/g, (m) => '\\' + m)}%`;
|
|
const rows = this.db
|
|
.query<
|
|
UserRow & { discord_id: string | null; discord_name: string | null; played: number; wins: number; draws: number },
|
|
[string, string, string, string, number, number]
|
|
>(
|
|
`SELECT u.*, o.provider_user_id AS discord_id, o.username AS discord_name,
|
|
COALESCE(s.played, 0) AS played, COALESCE(s.wins, 0) AS wins, COALESCE(s.draws, 0) AS draws
|
|
FROM users u
|
|
LEFT JOIN oauth_accounts o ON o.user_id = u.id AND o.provider = 'discord'
|
|
LEFT JOIN (SELECT user_id, SUM(played) AS played, SUM(wins) AS wins, SUM(draws) AS draws FROM user_stats GROUP BY user_id) s
|
|
ON s.user_id = u.id
|
|
WHERE u.deleted_at IS NULL AND (? = '' OR u.nickname LIKE ? ESCAPE '\\' OR u.id = ? OR o.provider_user_id = ?)
|
|
ORDER BY u.last_seen_at DESC LIMIT ? OFFSET ?`,
|
|
)
|
|
.all(q, like, q, q, limit, offset);
|
|
const total = this.db.query<{ n: number }, []>('SELECT COUNT(*) AS n FROM users WHERE deleted_at IS NULL').get()!.n;
|
|
return { rows, total };
|
|
}
|
|
|
|
counts(): { total: number; guests: number; members: number; banned: number } {
|
|
return this.db
|
|
.query<{ total: number; guests: number; members: number; banned: number }, []>(
|
|
`SELECT COUNT(*) AS total, SUM(kind = 'guest') AS guests, SUM(kind = 'member') AS members, SUM(banned_at IS NOT NULL) AS banned
|
|
FROM users WHERE deleted_at IS NULL`,
|
|
)
|
|
.get()!;
|
|
}
|
|
|
|
touch(userId: string, now = Date.now()): void {
|
|
this.db.query('UPDATE users SET last_seen_at = ? WHERE id = ?').run(now, userId);
|
|
}
|
|
|
|
/** Guests not seen since `before`, oldest first. */
|
|
idleGuests(before: number, limit = 500): string[] {
|
|
return this.db
|
|
.query<{ id: string }, [number, number]>(
|
|
"SELECT id FROM users WHERE kind = 'guest' AND deleted_at IS NULL AND last_seen_at < ? ORDER BY last_seen_at LIMIT ?",
|
|
)
|
|
.all(before, limit)
|
|
.map((r) => r.id);
|
|
}
|
|
|
|
/**
|
|
* Removes a guest account for good (logout, or idle for a day): the user row, its stats and sessions.
|
|
* Past games keep the seat (shown as an unknown player). Returns false for non-guests.
|
|
*/
|
|
deleteGuest(userId: string): boolean {
|
|
return this.db.transaction(() => {
|
|
const row = this.db.query<{ kind: string }, [string]>('SELECT kind FROM users WHERE id = ?').get(userId);
|
|
if (row?.kind !== 'guest') return false;
|
|
this.db.query('DELETE FROM user_stats WHERE user_id = ?').run(userId);
|
|
this.db.query('DELETE FROM sessions WHERE user_id = ?').run(userId);
|
|
this.db.query('DELETE FROM oauth_accounts WHERE user_id = ?').run(userId);
|
|
this.db.query('DELETE FROM users WHERE id = ?').run(userId);
|
|
return true;
|
|
})();
|
|
}
|
|
|
|
softDelete(userId: string, now = Date.now()): void {
|
|
this.db.transaction(() => {
|
|
this.db
|
|
.query("UPDATE users SET deleted_at = ?, nickname = '탈퇴한 사용자', avatar_url = NULL WHERE id = ?")
|
|
.run(now, userId);
|
|
this.db.query('DELETE FROM oauth_accounts WHERE user_id = ?').run(userId);
|
|
this.db.query('DELETE FROM sessions WHERE user_id = ?').run(userId);
|
|
})();
|
|
}
|
|
|
|
stats(userId: string): { gameId: string; played: number; wins: number; draws: number }[] {
|
|
return this.db
|
|
.query<{ gameId: string; played: number; wins: number; draws: number }, [string]>(
|
|
'SELECT game_id AS gameId, played, wins, draws FROM user_stats WHERE user_id = ? ORDER BY played DESC',
|
|
)
|
|
.all(userId);
|
|
}
|
|
}
|
|
|
|
export function toPublicUser(u: UserRow): PublicUser {
|
|
return {
|
|
id: u.id,
|
|
nickname: u.nickname,
|
|
avatar: u.use_avatar && u.avatar_url ? u.avatar_url : null,
|
|
kind: u.kind,
|
|
};
|
|
}
|