방: 나가기 버튼을 막대로 빼고 ← 홈도 나가기로, 이름 눌러 기록 카드, 게스트 경고, 관리자 계정별 기록
- 방 막대에 [🚪 나가기]를 두고 메뉴에서는 뺀다. [← 홈]도 같은 동작. 한 번 누르면 바로 나가고, 게임 중에 자리에 앉아 있을 때만 기권 확인을 묻는다. 휴대폰은 방 링크 복사를 메뉴로 옮겨 한 줄에 맞춘다. - 자리·관전자·결과 화면의 이름을 누르면 기록 카드(이 게임 판·승·패·무·승률, 이 방 전적, 전체 합계). 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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 ?`,
|
||||
)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user