diff --git a/src/services/userService.ts b/src/services/userService.ts index b73f667..2cc4d44 100644 --- a/src/services/userService.ts +++ b/src/services/userService.ts @@ -1,5 +1,5 @@ import type { DecodedIdToken } from "firebase-admin/auth"; -import { FieldValue } from "firebase-admin/firestore"; +import { FieldValue, Timestamp } from "firebase-admin/firestore"; import { HttpError } from "../middleware/errors"; import { auth } from "../firebase"; import { @@ -86,11 +86,25 @@ function providerFromToken(token: DecodedIdToken): Provider { ); } +/** + * 두 프로필 사진 URL이 실질적으로 동일한지 판정한다. + * + * Google 등 일부 제공자는 같은 사진에도 쿼리스트링(예: `=s96-c` 크기 파라미터)을 + * 매번 바꿔 내려준다. 쿼리스트링만 다른 경우는 동일 사진으로 보아, `getMe`(읽기 경로)가 + * 매 호출마다 photoUrl write를 유발하는 것을 막는다. + */ +function samePhotoUrl(a: string | undefined, b: string | undefined): boolean { + if (a === b) return true; + if (!a || !b) return false; + return a.split("?")[0] === b.split("?")[0]; +} + /** * 현재 로그인한 유저 문서를 조회한다. 없으면 404 + `USER_NOT_FOUND`. * - * 토큰의 `picture`가 저장된 `photoUrl`과 다르면 자동 동기화한다 - * (프로필 사진 변경 반영). 응답은 동기화된 값으로 반환한다. + * 토큰의 `picture`가 저장된 `photoUrl`과 **실질적으로** 다를 때만 자동 동기화한다 + * (쿼리스트링만 다른 경우는 write하지 않음 — 읽기 경로의 불필요한 write 방지). + * 응답은 동기화된 값으로 반환한다. */ export async function getMe(token: DecodedIdToken): Promise { const user = await getUser(token.uid); @@ -99,7 +113,7 @@ export async function getMe(token: DecodedIdToken): Promise { } const tokenPhoto = token.picture; - if (tokenPhoto && tokenPhoto !== user.photoUrl) { + if (tokenPhoto && !samePhotoUrl(tokenPhoto, user.photoUrl)) { await updateUser(token.uid, { photoUrl: tokenPhoto }); user.photoUrl = tokenPhoto; } @@ -159,11 +173,18 @@ export async function createMe( await deleteReservation(token.uid, displayName); - const created = await getUser(token.uid); - if (!created) { - throw new HttpError(500, "failed to read created user"); - } - return toUserProfile(created); + // 방금 쓴 값으로 응답을 합성한다(쓰기 후 2차 getUser 제거). `createdAt`은 저장본이 + // serverTimestamp로 기록되므로 응답에는 근사치(now)를 싣는다 — 이후 getMe가 저장본 반영. + const profile: UserProfile = { + displayName, + email: token.email, + provider, + knowledgeLevel, + createdAt: Timestamp.now(), + }; + if (token.picture) profile.photoUrl = token.picture; + if (favoriteTeamCode) profile.favoriteTeamCode = favoriteTeamCode; + return profile; } /** @@ -266,8 +287,22 @@ export async function updateMe( await updateUser(token.uid, patch); - const updated = await getUser(token.uid); - return toUserProfile(updated!); + // 쓰기 후 2차 getUser 제거: 시작 시 읽은 user에 방금 적용한 변경만 반영해 합성한다. + const merged: User = { ...user }; + if (typeof patch.displayName === "string") { + merged.displayName = patch.displayName; + } + if (body.favoriteTeamCode !== undefined) { + if (body.favoriteTeamCode === null) { + delete merged.favoriteTeamCode; + } else { + merged.favoriteTeamCode = patch.favoriteTeamCode as TeamCode; + } + } + if (typeof patch.knowledgeLevel === "string") { + merged.knowledgeLevel = patch.knowledgeLevel as KnowledgeLevel; + } + return toUserProfile(merged); } const NOTIFICATION_KEYS = new Set(Object.values(NotificationKey));