방: 나가기 버튼을 막대로 빼고 ← 홈도 나가기로, 이름 눌러 기록 카드, 게스트 경고, 관리자 계정별 기록

- 방 막대에 [🚪 나가기]를 두고 메뉴에서는 뺀다. [← 홈]도 같은 동작. 한 번 누르면 바로 나가고,
  게임 중에 자리에 앉아 있을 때만 기권 확인을 묻는다. 휴대폰은 방 링크 복사를 메뉴로 옮겨 한 줄에 맞춘다.
- 자리·관전자·결과 화면의 이름을 누르면 기록 카드(이 게임 판·승·패·무·승률, 이 방 전적, 전체 합계).
  GET /api/users/:id/stats (로그인 필요).
- 게스트 경고: 이름 정하기, 로그인 페이지, /me, 게스트가 있는 대기실.
- 관리자 사용자 상세: 전체 기록, 게임별 기록 표, 최근 게임(GET /api/admin/users/:id/games). 목록에 전적 열(PC).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-08 11:34:08 +09:00
parent f93ff4f656
commit 43156a9f63
17 changed files with 462 additions and 39 deletions

View File

@@ -255,4 +255,54 @@ describe('site settings, rooms, users', () => {
expect(byId(sup.id).deleteAt).toBeNull();
expect((await api(s.base, `/api/admin/users/${away.id}`, { cookie: sup.cookie })).body.user.deleteAt).toBe(seen + GUEST_IDLE_MS);
});
test('records: totals in the user list, per-game stats and recent games for superadmin; player card for logged-in users', async () => {
const s = start();
const sup = await discordLogin(s, SUPER);
const a = await guest(s.base, '호랑이');
const b = await guest(s.base, '고양이');
// Two finished omok games (a won one, b won one) and one draw-less yacht game for a.
const games: [string, string, number, [string, number][]][] = [
['01J00000000000000000000001', 'omok', 1, [[a.id, 1], [b.id, 2]]],
['01J00000000000000000000002', 'omok', 2, [[a.id, 2], [b.id, 1]]],
['01J00000000000000000000003', 'yacht', 3, [[a.id, 1]]],
];
for (const [id, gameId, no, players] of games) {
s.db
.query("INSERT INTO games (id, room_id, game_no, game_id, options_json, seed_hash, seed, started_at, ended_at, end_reason, result_json) VALUES (?, 'r', ?, ?, '{}', 'h', 's', ?, ?, 'normal', ?)")
.run(id, no, gameId, 1000 * no, 1000 * no + 500, JSON.stringify({ summary: '끝' }));
players.forEach(([u, rank], seat) => s.db.query('INSERT INTO game_players (game_pk, user_id, seat, rank, score) VALUES (?, ?, ?, ?, NULL)').run(id, u, seat, rank));
}
s.db.query("INSERT INTO user_stats (user_id, game_id, played, wins, draws) VALUES (?, 'omok', 2, 1, 0), (?, 'yacht', 1, 1, 0), (?, 'omok', 2, 1, 0)").run(a.id, a.id, b.id);
const list = (await api(s.base, '/api/admin/users', { cookie: sup.cookie })).body.users;
expect(list.find((u: any) => u.id === a.id)).toMatchObject({ played: 3, wins: 2, draws: 0 });
expect(list.find((u: any) => u.id === sup.id)).toMatchObject({ played: 0, wins: 0, draws: 0 });
const detail = (await api(s.base, `/api/admin/users/${a.id}`, { cookie: sup.cookie })).body;
expect(detail.stats).toEqual([
{ gameId: 'omok', played: 2, wins: 1, draws: 0 },
{ gameId: 'yacht', played: 1, wins: 1, draws: 0 },
]);
const recent = (await api(s.base, `/api/admin/users/${a.id}/games`, { cookie: sup.cookie })).body;
expect(recent.next).toBeNull();
expect(recent.games.map((g: any) => [g.gameId, g.myRank])).toEqual([
['yacht', 1],
['omok', 2],
['omok', 1],
]);
expect(recent.games[1].players.map((p: any) => p.nickname)).toEqual(['고양이', '호랑이']);
expect((await api(s.base, `/api/admin/users/${a.id}/games?before=bad`, { cookie: sup.cookie })).res.status).toBe(400);
expect((await api(s.base, '/api/admin/users/nobody/games', { cookie: sup.cookie })).res.status).toBe(404);
// Recent games are superadmin-only like the rest of the user pages.
expect((await api(s.base, `/api/admin/users/${a.id}/games`, { cookie: b.cookie })).res.status).toBe(403);
// Player card in a room: any logged-in user can read another player's record.
const card = await api(s.base, `/api/users/${a.id}/stats`, { cookie: b.cookie });
expect(card.res.status).toBe(200);
expect(card.body.user).toMatchObject({ id: a.id, nickname: '호랑이', kind: 'guest' });
expect(card.body.stats).toEqual(detail.stats);
expect((await api(s.base, `/api/users/${a.id}/stats`)).res.status).toBe(401);
expect((await api(s.base, '/api/users/nobody/stats', { cookie: b.cookie })).res.status).toBe(404);
});
});

View File

@@ -126,9 +126,16 @@ export class Users {
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 }, [string, string, string, string, number, number]>(
`SELECT u.*, o.provider_user_id AS discord_id, o.username AS discord_name FROM users u
.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 ?`,
)

View File

@@ -8,11 +8,13 @@ import { GameSettingsSchema, SiteSettingsSchema, type SettingsStore } from '../a
import type { Sessions } from '../auth/sessions';
import { toPublicUser, type Users } from '../auth/users';
import type { RoomManager } from '../rooms/manager';
import type { Replays } from '../rooms/replay';
export interface AdminDeps {
users: Users;
sessions: Sessions;
rooms: RoomManager;
replays: Replays;
roles: Roles;
audit: Audit;
settings: SettingsStore;
@@ -148,6 +150,9 @@ export function createAdminApp(d: AdminDeps) {
createdAt: u.created_at,
lastSeenAt: u.last_seen_at,
deleteAt: d.guestDeleteAt(u),
played: u.played,
wins: u.wins,
draws: u.draws,
})),
});
});
@@ -171,6 +176,15 @@ export function createAdminApp(d: AdminDeps) {
});
});
/** A user's finished games, newest first (same shape as 내 게임 기록). */
app.get('/users/:id/games', (c) => {
const id = c.req.param('id');
if (!d.users.get(id)) return c.json({ error: '사용자를 찾을 수 없어요.' }, 404);
const before = c.req.query('before') ?? null;
if (before !== null && !/^[0-9A-Z]{26}$/.test(before)) return c.json({ error: '잘못된 요청이에요.' }, 400);
return c.json(d.replays.listForUser(id, before, 20));
});
app.patch('/users/:id', async (c) => {
const id = c.req.param('id');
const u = d.users.get(id);

View File

@@ -26,7 +26,7 @@ export interface HttpDeps {
health: () => { ok: boolean; [k: string]: unknown };
roles: Roles;
settings: SettingsStore;
admin: Omit<AdminDeps, 'users' | 'sessions' | 'rooms' | 'roles' | 'settings'>;
admin: Omit<AdminDeps, 'users' | 'sessions' | 'rooms' | 'replays' | 'roles' | 'settings'>;
/** Deletes a guest account everywhere (rooms, sockets, DB). */
removeGuest: (userId: string) => void;
}
@@ -42,6 +42,7 @@ export function createHttpApp(d: HttpDeps) {
const lookupLimiter = new WindowLimiter(30 * lift, 60_000);
const guestLimiter = new WindowLimiter(20 * lift, 60_000);
const replayLimiter = new WindowLimiter(30 * lift, 60_000);
const profileLimiter = new WindowLimiter(60 * lift, 60_000);
const redirectUri = `${d.config.publicOrigin}/api/auth/discord/callback`;
const cookieOpts = (maxAgeMs: number) => ({
@@ -246,6 +247,16 @@ export function createHttpApp(d: HttpDeps) {
return c.json({ stats: d.users.stats(u) });
});
/** Another player's record, for the player card in a room (docs/06 §5). Login only. */
app.get('/api/users/:id/stats', (c) => {
const u = requireUser(c);
if (!u) return c.json({ error: '로그인이 필요해요.' }, 401);
if (!profileLimiter.allow(d.ipOf(c.req.raw))) return c.json({ error: '잠시 후 다시 시도해 주세요.' }, 429);
const row = d.users.get(c.req.param('id'));
if (!row) return c.json({ error: '사용자를 찾을 수 없어요.' }, 404);
return c.json({ user: toPublicUser(row), stats: d.users.stats(row.id) });
});
app.get('/api/me/games', (c) => {
const u = requireUser(c);
if (!u) return c.json({ error: '로그인이 필요해요.' }, 401);
@@ -315,7 +326,7 @@ export function createHttpApp(d: HttpDeps) {
app.route(
'/api/admin',
createAdminApp({ ...d.admin, users: d.users, sessions: d.sessions, rooms: d.rooms, roles: d.roles, settings: d.settings }),
createAdminApp({ ...d.admin, users: d.users, sessions: d.sessions, rooms: d.rooms, replays: d.replays, roles: d.roles, settings: d.settings }),
);
return app;