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:
parent
1a68d41e4f
commit
c94e2eb81b
@ -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(" | "));
|
||||
}
|
||||
|
||||
@ -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] ?? ""),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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!);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
export type {
|
||||
TeamRank,
|
||||
WinLoss,
|
||||
WinLossDraw,
|
||||
TeamVsRecord,
|
||||
TeamRankResult,
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user