From 00a87fd119492d0cb32d78f48396f5373fed1055 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 30 Apr 2026 00:12:31 +0900 Subject: [PATCH 1/3] =?UTF-8?q?chore(gitignore):=20figma=20=EC=9E=91?= =?UTF-8?q?=EC=97=85=20=EB=94=94=EB=A0=89=ED=84=B0=EB=A6=AC=20ignore=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기획자 화면 명세를 로컬에서 관리하는 .figma/ 디렉터리를 ignore에 등록. 스크린샷/스펙 텍스트는 작업자 본인 로컬에서만 참고하도록 한다. --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 553a01f..dfd9e6b 100644 --- a/.gitignore +++ b/.gitignore @@ -84,3 +84,5 @@ docs/* .env.example caquick_ddl.sql src/features/example/* + +.figma/ From f59f38266e052ebc6721d28bc8ce4ffdf8d42bdd Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 30 Apr 2026 00:17:37 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(user):=20=EC=83=9D=EB=85=84=EC=9B=94?= =?UTF-8?q?=EC=9D=BC=201900-01-01=20=EC=9D=B4=EC=A0=84=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=20=EA=B1=B0=EB=B6=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기획 명세에 별도 하한 정책이 없으나, 봇/오입력 방지를 위해 normalizeBirthDate에 1900-01-01 이전 날짜 거부 로직을 추가한다. 비교 기준은 normalize와 동일한 로컬 타임존 자정으로 둔다. - MIN_BIRTH_DATE 상수 추가 (user.constants.ts) - 회귀 테스트 2건 추가 (1899-12-31/1850 reject, 1900-01-01 통과) --- src/features/user/constants/user.constants.ts | 7 +++++++ .../user/services/user-base.service.spec.ts | 15 +++++++++++++++ src/features/user/services/user-base.service.ts | 6 ++++++ 3 files changed, 28 insertions(+) diff --git a/src/features/user/constants/user.constants.ts b/src/features/user/constants/user.constants.ts index db7fac5..f823153 100644 --- a/src/features/user/constants/user.constants.ts +++ b/src/features/user/constants/user.constants.ts @@ -8,6 +8,13 @@ export const MAX_NICKNAME_LENGTH = 20; export const MIN_PHONE_LENGTH = 7; export const MAX_PHONE_LENGTH = 20; +// ── 생년월일 ── + +// figma 명세 외 정책 결정: 1900-01-01 이전 입력은 거부 (사실상 봇/오입력 방지). +// normalizeBirthDate가 로컬 타임존 기준 setHours(0,0,0,0)으로 정렬하므로 +// 비교 기준도 동일하게 로컬 타임존 자정으로 둔다. +export const MIN_BIRTH_DATE = new Date(1900, 0, 1); + // ── 페이지네이션 ── export const DEFAULT_PAGINATION_LIMIT = 20; diff --git a/src/features/user/services/user-base.service.spec.ts b/src/features/user/services/user-base.service.spec.ts index ce4bd08..c15f3ad 100644 --- a/src/features/user/services/user-base.service.spec.ts +++ b/src/features/user/services/user-base.service.spec.ts @@ -222,6 +222,21 @@ describe('UserBaseService (real DB)', () => { ); }); + it('1899-12-31 등 1900-01-01 이전이면 BadRequestException을 던진다', () => { + expect(() => service.testNormalizeBirthDate('1899-12-31')).toThrow( + BadRequestException, + ); + expect(() => + service.testNormalizeBirthDate(new Date(1850, 0, 1)), + ).toThrow(BadRequestException); + }); + + it('1900-01-01은 통과한다', () => { + const result = service.testNormalizeBirthDate(new Date(1900, 0, 1)); + expect(result).toBeInstanceOf(Date); + expect(result?.getFullYear()).toBe(1900); + }); + it('문자열 날짜를 Date 객체로 변환한다', () => { const result = service.testNormalizeBirthDate('1990-05-15'); expect(result).toBeInstanceOf(Date); diff --git a/src/features/user/services/user-base.service.ts b/src/features/user/services/user-base.service.ts index 9aeab63..f45abf0 100644 --- a/src/features/user/services/user-base.service.ts +++ b/src/features/user/services/user-base.service.ts @@ -10,6 +10,7 @@ import { MAX_NICKNAME_LENGTH, MAX_PAGINATION_LIMIT, MAX_PHONE_LENGTH, + MIN_BIRTH_DATE, MIN_NICKNAME_LENGTH, MIN_PHONE_LENGTH, } from '@/features/user/constants/user.constants'; @@ -111,6 +112,11 @@ export abstract class UserBaseService { today.setHours(0, 0, 0, 0); const normalized = new Date(date); normalized.setHours(0, 0, 0, 0); + if (normalized < MIN_BIRTH_DATE) { + throw new BadRequestException( + 'birthDate is too old (before 1900-01-01).', + ); + } if (normalized > today) { throw new BadRequestException('birthDate cannot be in the future.'); } From 7cfb5eaf2ba9e8004ff2633021fbdd7dc84145ef Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Thu, 30 Apr 2026 00:41:15 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix(user):=20normalizeBirthDate=20UTC=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=20=EC=A0=95=EA=B7=9C=ED=99=94=EB=A1=9C=20tim?= =?UTF-8?q?ezone=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 setHours(0,0,0,0) 로컬 자정 정규화 + 로컬 자정 MIN_BIRTH_DATE는 KST 환경에선 동작하나 음수 오프셋(예: America/New_York) 또는 UTC 환경에서는 '1900-01-01' 같은 ISO date string이 1899-12-31로 정렬되어 부당하게 reject되는 회귀가 있었음 (Codex 리뷰 P2). GraphQL DateTime은 ISO string을 UTC로 해석하고 DB 컬럼은 @db.Date(시간 무시)이므로 UTC 자정 기준으로 정규화/비교하도록 통일한다. - MIN_BIRTH_DATE = new Date(Date.UTC(1900, 0, 1)) - normalize: getUTCFullYear/getUTCMonth/getUTCDate로 UTC 자정 정렬 - 미래 비교의 today도 동일하게 UTC midnight 기준 - 회귀 테스트 입력을 ISO string으로 조정 + getUTCFullYear 검증 --- src/features/user/constants/user.constants.ts | 6 ++-- .../user/services/user-base.service.spec.ts | 31 +++++++++++-------- .../user/services/user-base.service.ts | 15 ++++++--- 3 files changed, 31 insertions(+), 21 deletions(-) diff --git a/src/features/user/constants/user.constants.ts b/src/features/user/constants/user.constants.ts index f823153..5c64aa7 100644 --- a/src/features/user/constants/user.constants.ts +++ b/src/features/user/constants/user.constants.ts @@ -11,9 +11,9 @@ export const MAX_PHONE_LENGTH = 20; // ── 생년월일 ── // figma 명세 외 정책 결정: 1900-01-01 이전 입력은 거부 (사실상 봇/오입력 방지). -// normalizeBirthDate가 로컬 타임존 기준 setHours(0,0,0,0)으로 정렬하므로 -// 비교 기준도 동일하게 로컬 타임존 자정으로 둔다. -export const MIN_BIRTH_DATE = new Date(1900, 0, 1); +// GraphQL DateTime은 ISO string을 UTC로 해석하므로 비교 기준도 UTC 자정으로 둔다. +// 운영 timezone과 무관하게 동일하게 동작. +export const MIN_BIRTH_DATE = new Date(Date.UTC(1900, 0, 1)); // ── 페이지네이션 ── diff --git a/src/features/user/services/user-base.service.spec.ts b/src/features/user/services/user-base.service.spec.ts index c15f3ad..6590cd0 100644 --- a/src/features/user/services/user-base.service.spec.ts +++ b/src/features/user/services/user-base.service.spec.ts @@ -226,32 +226,37 @@ describe('UserBaseService (real DB)', () => { expect(() => service.testNormalizeBirthDate('1899-12-31')).toThrow( BadRequestException, ); - expect(() => - service.testNormalizeBirthDate(new Date(1850, 0, 1)), - ).toThrow(BadRequestException); + expect(() => service.testNormalizeBirthDate('1850-01-01')).toThrow( + BadRequestException, + ); }); - it('1900-01-01은 통과한다', () => { - const result = service.testNormalizeBirthDate(new Date(1900, 0, 1)); + it('1900-01-01은 통과한다 (UTC 기준)', () => { + const result = service.testNormalizeBirthDate('1900-01-01'); expect(result).toBeInstanceOf(Date); - expect(result?.getFullYear()).toBe(1900); + expect(result?.getUTCFullYear()).toBe(1900); + expect(result?.getUTCMonth()).toBe(0); + expect(result?.getUTCDate()).toBe(1); }); it('문자열 날짜를 Date 객체로 변환한다', () => { const result = service.testNormalizeBirthDate('1990-05-15'); expect(result).toBeInstanceOf(Date); - expect(result?.getFullYear()).toBe(1990); + expect(result?.getUTCFullYear()).toBe(1990); + expect(result?.getUTCMonth()).toBe(4); + expect(result?.getUTCDate()).toBe(15); }); - it('오늘 날짜는 미래로 취급하지 않고 그대로 반환한다', () => { - const today = new Date(); - today.setHours(12, 0, 0, 0); + it('오늘(UTC) 날짜는 미래로 취급하지 않고 그대로 반환한다', () => { + const now = new Date(); + const todayIso = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, '0')}-${String(now.getUTCDate()).padStart(2, '0')}`; - const result = service.testNormalizeBirthDate(today); + const result = service.testNormalizeBirthDate(todayIso); expect(result).toBeInstanceOf(Date); - // 시간은 00:00:00으로 내부 정규화되지만 날짜 자체는 오늘과 같아야 함 - expect(result?.toDateString()).toBe(today.toDateString()); + expect(result?.getUTCFullYear()).toBe(now.getUTCFullYear()); + expect(result?.getUTCMonth()).toBe(now.getUTCMonth()); + expect(result?.getUTCDate()).toBe(now.getUTCDate()); }); }); diff --git a/src/features/user/services/user-base.service.ts b/src/features/user/services/user-base.service.ts index f45abf0..920512b 100644 --- a/src/features/user/services/user-base.service.ts +++ b/src/features/user/services/user-base.service.ts @@ -108,16 +108,21 @@ export abstract class UserBaseService { if (Number.isNaN(date.getTime())) { throw new BadRequestException('Invalid birthDate.'); } - const today = new Date(); - today.setHours(0, 0, 0, 0); - const normalized = new Date(date); - normalized.setHours(0, 0, 0, 0); + // DB가 @db.Date(시간 무시) + GraphQL DateTime이 ISO string을 UTC로 해석하므로 + // timezone 독립적으로 UTC 자정 기준으로 정규화한다. + const normalized = new Date( + Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()), + ); if (normalized < MIN_BIRTH_DATE) { throw new BadRequestException( 'birthDate is too old (before 1900-01-01).', ); } - if (normalized > today) { + const now = new Date(); + const todayUtc = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()), + ); + if (normalized > todayUtc) { throw new BadRequestException('birthDate cannot be in the future.'); } return normalized;