From c94e2eb81b41c7b1f11cc9b11dc132fe004b7e9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=A0=95=EB=AF=BC?= Date: Wed, 6 May 2026 15:51:45 +0900 Subject: [PATCH] Implement UserProfile for API security and refactor KBO rank types. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 클라이언트 응답 전용 `UserProfile` 인터페이스를 도입하여 내부 도메인 모델과의 의존성을 분리하고 데이터 보안을 강화했습니다. - 스트릭, 티어, 티켓 등 지연 보정이 필요하거나 통계 성격이 강한 필드를 응답에서 제외하고 `/stats` API로 데이터 서빙 경로를 일원화했습니다. - 특히 DB의 스트릭 값은 지연 보정(lazy correction) 전의 상태일 수 있어, 사용자에게 잘못된 정보가 노출되는 것을 방지했습니다. - KBO 팀 순위 데이터를 단순 문자열 파싱에서 숫자형 및 구조화된 타입(`WinLoss`, `WinLossDraw`)으로 개선하여 데이터의 신뢰도와 활용도를 높였습니다. - `getMe`, `createMe`, `updateMe` 등 사용자 관련 서비스 로직이 정제된 프로필 형식을 반환하도록 수정했습니다. --- src/kbo/cli.ts | 20 +++++++-- src/kbo/team-rank.ts | 87 ++++++++++++++++++++++++++----------- src/services/rankService.ts | 7 +-- src/services/userService.ts | 31 ++++++++++--- src/types/kbo.ts | 1 + src/types/panit.ts | 20 +++++++++ 6 files changed, 126 insertions(+), 40 deletions(-) diff --git a/src/kbo/cli.ts b/src/kbo/cli.ts index 742db81..75614da 100644 --- a/src/kbo/cli.ts +++ b/src/kbo/cli.ts @@ -61,7 +61,13 @@ const PLAYER_CONFIGS: Record = { // ── Rank Formatting ── function fmtWLD(wld: WinLossDraw): string { - return `${wld[0]}-${wld[1]}-${wld[2]}`; + return `${wld.wins}-${wld.losses}-${wld.draws}`; +} + +function fmtStreak(s: number): string { + if (s > 0) return `${s}W`; + if (s < 0) return `${-s}L`; + return "-"; } function printRankTable(year: number, teams: TeamRank[]) { @@ -82,8 +88,16 @@ function printRankTable(year: number, teams: TeamRank[]) { for (const t of teams) { const row = [ - t.rank, t.team, t.games, t.wins, t.losses, t.draws, - t.winRate, t.gamesBehind, t.last10, t.streak, + String(t.rank), + t.team, + String(t.games), + String(t.wins), + String(t.losses), + String(t.draws), + t.winRate.toFixed(3), + t.gamesBehind.toFixed(1), + `${t.last10.wins}W ${t.last10.losses}L`, + fmtStreak(t.streak), ]; console.log(" " + row.map((val, i) => padCell(val, widths[i])).join(" | ")); } diff --git a/src/kbo/team-rank.ts b/src/kbo/team-rank.ts index 6f2b80f..486523b 100644 --- a/src/kbo/team-rank.ts +++ b/src/kbo/team-rank.ts @@ -12,22 +12,32 @@ const PAGE_URL = // ── Types ── -export interface TeamRank { - rank: string; - team: string; - games: string; - wins: string; - losses: string; - draws: string; - winRate: string; - gamesBehind: string; - last10: string; - streak: string; - home: string; - away: string; +export interface WinLoss { + wins: number; + losses: number; } -export type WinLossDraw = [win: number, loss: number, draw: number]; +export interface WinLossDraw { + wins: number; + losses: number; + draws: number; +} + +export interface TeamRank { + rank: number; + team: string; + games: number; + wins: number; + losses: number; + draws: number; + winRate: number; + gamesBehind: number; + last10: WinLoss; + /** 연속 기록: 양수=N연승, 음수=N연패, 0=없음. */ + streak: number; + home: WinLossDraw; + away: WinLossDraw; +} export interface TeamVsRecord { team: string; @@ -45,7 +55,32 @@ export interface TeamRankResult { export function parseWLD(s: string): WinLossDraw { const parts = s.split("-").map(Number); - return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0]; + return { + wins: parts[0] ?? 0, + losses: parts[1] ?? 0, + draws: parts[2] ?? 0, + }; +} + +function toNum(s: string): number { + const n = parseFloat(s); + return isNaN(n) ? 0 : n; +} + +function parseLast10(s: string): WinLoss { + const w = s.match(/(\d+)\s*승/); + const l = s.match(/(\d+)\s*패/); + return { + wins: w ? Number(w[1]) : 0, + losses: l ? Number(l[1]) : 0, + }; +} + +function parseStreak(s: string): number { + const m = s.match(/(\d+)\s*([승패])/); + if (!m) return 0; + const n = Number(m[1]); + return m[2] === "승" ? n : -n; } export function parseRankTable(html: string): TeamRank[] { @@ -67,18 +102,18 @@ export function parseRankTable(html: string): TeamRank[] { const teamName = tds[1]; if (/^[A-Z가-힣]/.test(teamName)) { teams.push({ - rank: tds[0], + rank: toNum(tds[0]), team: tds[1], - games: tds[2], - wins: tds[3], - losses: tds[4], - draws: tds[5], - winRate: tds[6], - gamesBehind: tds[7], - last10: tds[8], - streak: tds[9], - home: tds[10] ?? "", - away: tds[11] ?? "", + games: toNum(tds[2]), + wins: toNum(tds[3]), + losses: toNum(tds[4]), + draws: toNum(tds[5]), + winRate: toNum(tds[6]), + gamesBehind: toNum(tds[7]), + last10: parseLast10(tds[8]), + streak: parseStreak(tds[9]), + home: parseWLD(tds[10] ?? ""), + away: parseWLD(tds[11] ?? ""), }); } } diff --git a/src/services/rankService.ts b/src/services/rankService.ts index cae90a3..86dd453 100644 --- a/src/services/rankService.ts +++ b/src/services/rankService.ts @@ -1,9 +1,6 @@ import { fetchRankFromKbo } from "../repositories/kboRepository.js"; import type { TeamRankResult } from "../types/kbo.js"; -export async function getRank( - years: number[] -): Promise { - const results = await fetchRankFromKbo(years); - return results.length === 1 ? results[0] : results; +export async function getRank(years: number[]): Promise { + return fetchRankFromKbo(years); } diff --git a/src/services/userService.ts b/src/services/userService.ts index c8fabb7..87c1544 100644 --- a/src/services/userService.ts +++ b/src/services/userService.ts @@ -20,8 +20,27 @@ import { TeamCode, type Provider, type User, + type UserProfile, } from "../types/panit.js"; +/** + * 내부 User 문서를 클라이언트 응답용 프로필로 변환한다. + * streak/티어/티켓/판정 회계 필드는 의도적으로 제외 — 해당 데이터는 `/stats`로 서빙된다. + * (user doc의 streak은 lazy 보정 전 값이라 그대로 노출 금지.) + */ +function toUserProfile(user: User): UserProfile { + return { + displayName: user.displayName, + email: user.email, + photoUrl: user.photoUrl, + provider: user.provider, + favoriteTeamCode: user.favoriteTeamCode, + knowledgeLevel: user.knowledgeLevel, + createdAt: user.createdAt, + lastJudgedDate: user.lastJudgedDate, + }; +} + const TEAM_CODE_VALUES = Object.values(TeamCode); const KNOWLEDGE_LEVEL_VALUES = Object.values(KnowledgeLevel); @@ -71,7 +90,7 @@ function providerFromToken(token: DecodedIdToken): Provider { * 토큰의 `picture`가 저장된 `photoUrl`과 다르면 자동 동기화한다 * (프로필 사진 변경 반영). 응답은 동기화된 값으로 반환한다. */ -export async function getMe(token: DecodedIdToken): Promise { +export async function getMe(token: DecodedIdToken): Promise { const user = await getUser(token.uid); if (!user) { throw new HttpError(404, "user not found", "USER_NOT_FOUND"); @@ -83,7 +102,7 @@ export async function getMe(token: DecodedIdToken): Promise { user.photoUrl = tokenPhoto; } - return user; + return toUserProfile(user); } export interface CreateMeBody { @@ -99,7 +118,7 @@ export interface CreateMeBody { export async function createMe( token: DecodedIdToken, body: CreateMeBody -): Promise { +): Promise { const existing = await getUser(token.uid); if (existing) { throw new HttpError(409, "user already exists", "USER_ALREADY_EXISTS"); @@ -142,7 +161,7 @@ export async function createMe( if (!created) { throw new HttpError(500, "failed to read created user"); } - return created; + return toUserProfile(created); } /** @@ -202,7 +221,7 @@ export interface UpdateMeBody { export async function updateMe( token: DecodedIdToken, body: UpdateMeBody -): Promise { +): Promise { const user = await getUser(token.uid); if (!user) { throw new HttpError(404, "user not found", "USER_NOT_FOUND"); @@ -246,7 +265,7 @@ export async function updateMe( await updateUser(token.uid, patch); const updated = await getUser(token.uid); - return updated!; + return toUserProfile(updated!); } /** diff --git a/src/types/kbo.ts b/src/types/kbo.ts index 45474ea..26f9287 100644 --- a/src/types/kbo.ts +++ b/src/types/kbo.ts @@ -2,6 +2,7 @@ export type { TeamRank, + WinLoss, WinLossDraw, TeamVsRecord, TeamRankResult, diff --git a/src/types/panit.ts b/src/types/panit.ts index 3e3464d..2cd4fb6 100644 --- a/src/types/panit.ts +++ b/src/types/panit.ts @@ -69,6 +69,26 @@ export interface User { rankSnapshot?: RankSnapshot; } +/** + * 클라이언트에 노출되는 유저 프로필. + * + * `User`에서 streak/티어/티켓/랭킹 스냅샷 등 보정이 필요하거나 내부 전용인 + * 필드를 제외한 형태. 해당 데이터는 `StatsResponse`(GET /stats)로 서빙된다. + * + * `lastJudgedDate`는 stale 위험이 없는(판정 트랜잭션과 함께 갱신되는) 사실값이라 + * 노출한다. 클라의 "어제 예측 리캡" 등에서 마지막으로 판정된 날짜를 필요로 한다. + */ +export interface UserProfile { + displayName: string; + email: string; + photoUrl?: string; + provider: Provider; + favoriteTeamCode?: TeamCode; + knowledgeLevel: KnowledgeLevel; + createdAt: Timestamp; + lastJudgedDate?: DateString; +} + export interface RankSnapshot { /** 스냅샷 생성 날짜 (KST). */ date: DateString;