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 ── // ── Rank Formatting ──
function fmtWLD(wld: WinLossDraw): string { 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[]) { function printRankTable(year: number, teams: TeamRank[]) {
@ -82,8 +88,16 @@ function printRankTable(year: number, teams: TeamRank[]) {
for (const t of teams) { for (const t of teams) {
const row = [ const row = [
t.rank, t.team, t.games, t.wins, t.losses, t.draws, String(t.rank),
t.winRate, t.gamesBehind, t.last10, t.streak, 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(" | ")); console.log(" " + row.map((val, i) => padCell(val, widths[i])).join(" | "));
} }

View File

@ -12,22 +12,32 @@ const PAGE_URL =
// ── Types ── // ── Types ──
export interface TeamRank { export interface WinLoss {
rank: string; wins: number;
team: string; losses: number;
games: string;
wins: string;
losses: string;
draws: string;
winRate: string;
gamesBehind: string;
last10: string;
streak: string;
home: string;
away: string;
} }
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 { export interface TeamVsRecord {
team: string; team: string;
@ -45,7 +55,32 @@ export interface TeamRankResult {
export function parseWLD(s: string): WinLossDraw { export function parseWLD(s: string): WinLossDraw {
const parts = s.split("-").map(Number); 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[] { export function parseRankTable(html: string): TeamRank[] {
@ -67,18 +102,18 @@ export function parseRankTable(html: string): TeamRank[] {
const teamName = tds[1]; const teamName = tds[1];
if (/^[A-Z가-힣]/.test(teamName)) { if (/^[A-Z가-힣]/.test(teamName)) {
teams.push({ teams.push({
rank: tds[0], rank: toNum(tds[0]),
team: tds[1], team: tds[1],
games: tds[2], games: toNum(tds[2]),
wins: tds[3], wins: toNum(tds[3]),
losses: tds[4], losses: toNum(tds[4]),
draws: tds[5], draws: toNum(tds[5]),
winRate: tds[6], winRate: toNum(tds[6]),
gamesBehind: tds[7], gamesBehind: toNum(tds[7]),
last10: tds[8], last10: parseLast10(tds[8]),
streak: tds[9], streak: parseStreak(tds[9]),
home: tds[10] ?? "", home: parseWLD(tds[10] ?? ""),
away: tds[11] ?? "", away: parseWLD(tds[11] ?? ""),
}); });
} }
} }

View File

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

View File

@ -20,8 +20,27 @@ import {
TeamCode, TeamCode,
type Provider, type Provider,
type User, type User,
type UserProfile,
} from "../types/panit.js"; } 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 TEAM_CODE_VALUES = Object.values(TeamCode);
const KNOWLEDGE_LEVEL_VALUES = Object.values(KnowledgeLevel); const KNOWLEDGE_LEVEL_VALUES = Object.values(KnowledgeLevel);
@ -71,7 +90,7 @@ function providerFromToken(token: DecodedIdToken): Provider {
* `picture` `photoUrl` * `picture` `photoUrl`
* ( ). . * ( ). .
*/ */
export async function getMe(token: DecodedIdToken): Promise<User> { export async function getMe(token: DecodedIdToken): Promise<UserProfile> {
const user = await getUser(token.uid); const user = await getUser(token.uid);
if (!user) { if (!user) {
throw new HttpError(404, "user not found", "USER_NOT_FOUND"); 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; user.photoUrl = tokenPhoto;
} }
return user; return toUserProfile(user);
} }
export interface CreateMeBody { export interface CreateMeBody {
@ -99,7 +118,7 @@ export interface CreateMeBody {
export async function createMe( export async function createMe(
token: DecodedIdToken, token: DecodedIdToken,
body: CreateMeBody body: CreateMeBody
): Promise<User> { ): Promise<UserProfile> {
const existing = await getUser(token.uid); const existing = await getUser(token.uid);
if (existing) { if (existing) {
throw new HttpError(409, "user already exists", "USER_ALREADY_EXISTS"); throw new HttpError(409, "user already exists", "USER_ALREADY_EXISTS");
@ -142,7 +161,7 @@ export async function createMe(
if (!created) { if (!created) {
throw new HttpError(500, "failed to read created user"); 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( export async function updateMe(
token: DecodedIdToken, token: DecodedIdToken,
body: UpdateMeBody body: UpdateMeBody
): Promise<User> { ): Promise<UserProfile> {
const user = await getUser(token.uid); const user = await getUser(token.uid);
if (!user) { if (!user) {
throw new HttpError(404, "user not found", "USER_NOT_FOUND"); throw new HttpError(404, "user not found", "USER_NOT_FOUND");
@ -246,7 +265,7 @@ export async function updateMe(
await updateUser(token.uid, patch); await updateUser(token.uid, patch);
const updated = await getUser(token.uid); const updated = await getUser(token.uid);
return updated!; return toUserProfile(updated!);
} }
/** /**

View File

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

View File

@ -69,6 +69,26 @@ export interface User {
rankSnapshot?: RankSnapshot; 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 { export interface RankSnapshot {
/** 스냅샷 생성 날짜 (KST). */ /** 스냅샷 생성 날짜 (KST). */
date: DateString; date: DateString;