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