Implement UserProfile for API security and refactor KBO rank types.

- 클라이언트 응답 전용 `UserProfile` 인터페이스를 도입하여 내부 도메인 모델과의 의존성을 분리하고 데이터 보안을 강화했습니다.
- 스트릭, 티어, 티켓 등 지연 보정이 필요하거나 통계 성격이 강한 필드를 응답에서 제외하고 `/stats` API로 데이터 서빙 경로를 일원화했습니다.
- 특히 DB의 스트릭 값은 지연 보정(lazy correction) 전의 상태일 수 있어, 사용자에게 잘못된 정보가 노출되는 것을 방지했습니다.
- KBO 팀 순위 데이터를 단순 문자열 파싱에서 숫자형 및 구조화된 타입(`WinLoss`, `WinLossDraw`)으로 개선하여 데이터의 신뢰도와 활용도를 높였습니다.
- `getMe`, `createMe`, `updateMe` 등 사용자 관련 서비스 로직이 정제된 프로필 형식을 반환하도록 수정했습니다.
This commit is contained in:
윤정민 2026-05-06 15:51:45 +09:00
parent 1a68d41e4f
commit c94e2eb81b
6 changed files with 126 additions and 40 deletions

View File

@ -61,7 +61,13 @@ const PLAYER_CONFIGS: Record<string, PlayerPageConfig> = {
// ── 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(" | "));
}

View File

@ -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] ?? ""),
});
}
}

View File

@ -1,9 +1,6 @@
import { fetchRankFromKbo } from "../repositories/kboRepository.js";
import type { TeamRankResult } from "../types/kbo.js";
export async function getRank(
years: number[]
): Promise<TeamRankResult | TeamRankResult[]> {
const results = await fetchRankFromKbo(years);
return results.length === 1 ? results[0] : results;
export async function getRank(years: number[]): Promise<TeamRankResult[]> {
return fetchRankFromKbo(years);
}

View File

@ -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<User> {
export async function getMe(token: DecodedIdToken): Promise<UserProfile> {
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> {
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<User> {
): Promise<UserProfile> {
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<User> {
): Promise<UserProfile> {
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!);
}
/**

View File

@ -2,6 +2,7 @@
export type {
TeamRank,
WinLoss,
WinLossDraw,
TeamVsRecord,
TeamRankResult,

View File

@ -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;