M1.5 관리자 사이트: 슈퍼어드민/어드민 권한, 게임·사이트 설정, 방·사용자 관리, 기록

- 권한: 디스코드 ID로 요청마다 계산. 슈퍼어드민 293719842274541579(SUPERADMIN_DISCORD_IDS),
  어드민은 슈퍼어드민이 디스코드 ID로 추가(로그인 전 미리 등록 가능), 고정 슈퍼어드민은 삭제 불가
- 게임 설정: 사용 여부, 표시 이름·설명·안내문, 원작 범위 안 인원, 모든 규칙 옵션 기본값,
  옵션별 방장 변경 금지(잠금), 초기화. 입력 화면은 옵션 스키마(zod→JSON Schema)로 자동 생성
- 오목 옵션 전체에 한국어 제목·선택지 메타, 엔진 고정값(자동 착수 허용 횟수, 무승부 재제안 간격,
  한 수 제한 초)을 옵션으로 꺼냄
- 사이트 설정: 이름, 공지, 점검 모드, 금지어, 방 수 제한, 연결 유예, 채팅 기본값
- 방 관리(목록·닫기), 사용자 관리(검색·닉네임 변경·이용 제한·강제 로그아웃·삭제, 슈퍼어드민 전용),
  관리자 작업 기록(전/후 값)
- 이용 제한: 세션 삭제 + 접속 종료 + 디스코드 재로그인 거부
- 테스트: 관리자 API 7개(권한·설정 반영·잠금·점검·금지어·방 닫기·이용 제한·기록), 오목 옵션 2개,
  e2e/admin.e2e.ts(슈퍼어드민 로그인→규칙 변경·잠금→어드민 추가→게스트 새 방 반영)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-04 04:52:38 +09:00
parent b2f998fa95
commit 242ab3ad71
31 changed files with 2009 additions and 68 deletions

View File

@@ -11,6 +11,8 @@ import { createHttpApp, type HttpDeps } from './http/app';
import { RoomManager } from './rooms/manager';
import { RoomStore } from './rooms/store';
import { Gateway, type WsData } from './ws/gateway';
import { Audit, Roles } from './admin/roles';
import { SettingsStore } from './admin/settings';
export type LogFn = (level: 'info' | 'warn' | 'error', msg: string, extra?: Record<string, unknown>) => void;
@@ -38,7 +40,11 @@ export function startServer(opts: StartOptions) {
const u = users.get(id);
return u ? toPublicUser(u) : null;
};
const rooms = new RoomManager({ store, games: GAMES, users: { publicUser }, log });
const settings = new SettingsStore(db, GAMES);
const roles = new Roles(db, config.superadminDiscordIds);
const audit = new Audit(db);
const startedAt = Date.now();
const rooms = new RoomManager({ store, games: GAMES, users: { publicUser }, log, settings });
const restored = rooms.restoreAll();
log('info', 'rooms restored', restored);
const gateway = new Gateway({ rooms, me: publicUser, log });
@@ -57,6 +63,24 @@ export function startServer(opts: StartOptions) {
ipOf,
discordExchange: opts.discordExchange,
health: () => ({ ok: ready, rooms: rooms.all().length, connections: gateway.connectionCount }),
roles,
settings,
admin: {
audit,
games: GAMES,
disconnectUser: (id) => gateway.disconnectUser(id),
stats: () => {
const all = rooms.all();
const day = Date.now() - 24 * 3600_000;
return {
connections: gateway.connectionCount,
rooms: { total: all.length, lobby: all.filter((r) => r.status === 'lobby').length, playing: all.filter((r) => r.status === 'playing').length },
games: db.query<{ total: number; today: number }, [number]>('SELECT COUNT(*) AS total, SUM(started_at > ?) AS today FROM games').get(day),
uptimeSec: Math.round((Date.now() - startedAt) / 1000),
memoryMb: Math.round(process.memoryUsage().rss / 1048576),
};
},
},
});
const staticDir = opts.staticDir === undefined ? join(import.meta.dir, '../../web/dist') : opts.staticDir;
@@ -114,7 +138,7 @@ export function startServer(opts: StartOptions) {
db.close();
};
return { server, stop, rooms, users, sessions, db, gateway };
return { server, stop, rooms, users, sessions, db, gateway, settings, roles };
}
async function serveFile(root: string, pathname: string): Promise<Response> {