42 Commits

Author SHA1 Message Date
ttipo 5331362215 docs(rules): 서버화에 맞춰 규칙·스킬 문서 갱신
구현한 구조를 규칙 SSOT 에 반영한다 — 다음 작업이 같은 패턴을 쓰도록.

- security: 모바일 리프레시 토큰은 secure storage(쿠키 저장소가 없다) — 대신 해시 저장·회전·재사용 탐지.
  소셜 토큰은 서버가 제공자에게 확인, 개발용 로그인은 운영에서 코드로 닫힘, 업로드 매직 바이트 검사·소유자 확인
- nestjs 스킬: 인증·회원·도메인 모듈·커서 헬퍼·공통 검증·snake_case 이름 규칙·아이 삭제 정리 리스너,
  새 라우트는 기본 로그인 필요(@Public 예외)·새 엔티티는 소유자 조건 조회
- flutter 스킬: DATA_MODE, 세션·토큰 갱신, 실패 알림·succeeded(), 서버 사진, 저장소 계약(Local·Api) 패턴
- env·응답 형식·에러 코드·테스트·체크리스트: DATA_MODE·소셜 키, keysetPaginate·fetchAllPages,
  도메인 코드 목록, 앱의 AUTH_002 흐름, 가짜 서버·라이브 점검 테스트
- vue3 스킬: 웹 로그인 연동 시 필요한 것 (지금 인증은 모바일용)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:43:48 +09:00
ttipo 373d50576a test(flutter): 서버 모드 화면 흐름 테스트
기존 흐름 테스트는 기기 저장 모드로 돈다. 가짜 서버를 붙여 서버 모드에서도
로그인 → 온보딩 → 홈(서버의 아이) 흐름, 세션 만료 시 로그인 화면 복귀(AUTH_002),
로그인 전에는 서버에 묻지 않는 것을 화면 단위로 확인한다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:40:36 +09:00
ttipo b9c351d7cf fix(flutter): 라이브 점검의 테스트 전용 API 경고 정리
test_live/ 는 분석기가 테스트 폴더로 보지 않아 setMockInitialValues 가 경고로 잡혔다.
CI 의 flutter analyze 는 경고 0 을 요구하므로 이유를 적고 이 줄만 예외로 둔다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:37:30 +09:00
ttipo 42abefcc66 test(flutter): 서버 계약 라이브 점검 추가
단위 테스트는 응답 모양을 가정한 가짜 서버로 돈다. 그 가정이 실제 서버와 맞는지
개발 서버에 앱의 서버 저장소를 그대로 붙여 확인한다 (로그인 → 프로필 → 아이 → 일정 → 다이어리 → 명함 →
보관·삭제 때 서버 정리 → 탈퇴).

서버가 필요하므로 test/ 가 아닌 test_live/ 에 두어 CI 의 flutter test 에서는 돌지 않는다.
  cd flutter && flutter test test_live

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:36:33 +09:00
ttipo 9cb660a0ac fix(flutter): 추억 보관함·순서 바꾸기도 서버 실패를 견디게
서버에 저장하면서 실패할 수 있게 된 남은 흐름을 정리했다.

- 보관하기·다시 불러오기는 성공했을 때만 화면을 옮기고 토스트를 띄운다
- 우리 아이 순서 바꾸기는 화면에 먼저 반영하고, 저장하지 못하면 원래 순서로 되돌린다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:35:11 +09:00
ttipo 2c1d1cc97d feat(flutter): 펫 명함을 서버에 저장 (ApiPetCardRepository)
- 명함 목록·만들기·꾸미기·삭제·대표 명함을 /pet-cards, /pets/:id/representative-card 로 옮겼다
- 명함과 대표 지정을 한 번에 불러오도록 저장소가 PetCardBook 을 돌려준다
- 배경은 앱 그림(assets/…)이면 경로만, 기기 사진이면 올린 뒤 파일 id 로 보낸다
- 공유 화면은 서버가 발급한 명함 코드를 보여 준다 (기기 저장 모드는 지금처럼 명함 id 로 만든다)
- 명함을 지울 때 대표 지정 정리, 아이를 지울 때 명함 정리는 저장소가 맡는다 — 서버 모드는 서버가 이미 했다
- 명함 화면은 실패하면 꾸민 모양을 그대로 둔다 (알림은 인터셉터)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:32:38 +09:00
ttipo a09086daf7 feat(flutter): 다이어리를 서버에 저장 (ApiDiaryRepository)
다이어리 저장소도 "장부 전체 저장" 에서 건별 계약(다이어리·기록·댓글 만들기·고치기·지우기)으로 바꿨다.

- 서버 저장소: 다이어리·기록·댓글 목록을 합쳐 DiaryBook 으로 만들고, 표지·기록 사진은 저장할 때 올린다
- 기록·댓글의 작성자가 로그인한 나이면 앱의 "나"로 담는다 — 하루 한 번 쓰기 판단이 그대로 맞는다.
  로그인할 때 회원 id 를 기기에 둔다
- 책장이 가득 찬 경우(DIARY_001)는 null 로 받아 화면이 직접 안내하고 전역 알림은 끈다
- 아이 삭제 때의 다이어리 정리는 서버가 하므로 dropTarget 은 아무것도 보내지 않는다
- 다이어리 화면은 실패하면 쓴 기록·댓글을 그대로 둔다 (알림은 인터셉터)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:28:20 +09:00
ttipo 60efc65ee0 feat(flutter): 일정을 서버에 저장 (ApiScheduleRepository)
일정 저장소가 "목록 전체 저장" 이라 서버에 맞지 않아 건별 계약으로 바꿨다.

- 저장소: 날짜 일정 여러 건 만들기·내용 바꾸기·삭제, 반복 규칙 만들기·바꾸기·삭제, 완료 표시, 대상 떼기.
  돌려받은 결과(id 가 붙은 일정·규칙)를 알림기가 ScheduleBook 에 반영한다
- 일반↔반복 전환은 알림기가 저장소 호출로 풀고, 새 것을 먼저 만든 뒤 옛것을 지운다(중간 실패에도 일정이 남는다).
  ScheduleBook.update 는 지우고 같은 규칙을 알림기 테스트로 옮겼다
- 서버 저장소: 불러올 때 날짜 일정·반복 규칙·완료 표시를 합치고, 기간 설정은 500건씩 나눠 보낸다.
  아이 삭제·보관 때의 정리는 서버가 하므로 dropTarget 은 아무것도 보내지 않는다
- 완료 체크는 화면에 먼저 반영하고 저장에 실패하면 되돌린다
- 일정·아이 저장 화면은 실패하면(알림은 인터셉터) 입력을 둔 채 머문다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:23:55 +09:00
ttipo 100dbe3a35 feat(flutter): 우리 아이를 서버에 저장 (ApiPetRepository)
- 목록·등록·수정·삭제·순서·추억 보관함을 /pets 로 옮겼다. 목록은 마지막 페이지까지 이어 읽는다
- 아이 사진은 저장할 때 올리고, 이미 올린 사진(server-file:)은 다시 올리지 않는다
- 고유색은 알림기가 등록 순서대로 정해 넘긴다 — 기기·서버 저장소가 같은 규칙을 쓴다
- 보관한 때는 서버가 정하고, 회원이 바뀌면 목록을 다시 읽는다
- DATA_MODE=local 이면 지금까지처럼 기기에 저장한다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:17:43 +09:00
ttipo 00660c7d62 feat(flutter): 로그인·프로필·탈퇴를 서버에 연결
- 로그인: 소셜 앱 키가 생기기 전까지 기기 키로 개발용 로그인(POST /auth/dev-sessions)을 한다.
  기기 키는 로그인 수단이라 secure storage 에 둔다. SDK 를 붙이면 signIn 만 /auth/sessions 로 바꾼다
- 로그아웃은 리프레시 토큰을 폐기하고(실패해도 기기에서는 로그아웃), 탈퇴는 서버에서 회원을 지운 뒤
  기기 기록을 정리한다 — 서버 탈퇴가 실패하면 기기 기록도 그대로 둔다
- 서버가 세션을 끊으면(AUTH_002) 진입 상태가 로그인 화면으로 보낸다
- 프로필: GET/PATCH /users/me. 마지막 값을 기기에 두어 앱을 열자마자 보여 주고, 사진은 저장할 때 올린다
- 회원이 바뀌면(로그인·로그아웃) 기록을 다시 읽도록 sessionEpochProvider 를 두었다
- 화면은 succeeded() 로 성공 여부만 보고 멈춘다 (실패 알림은 인터셉터가 이미 띄웠다)
- 템플릿 예제 features/user(없는 /users/:id 를 부르던 코드)를 지웠다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:14:33 +09:00
ttipo cdd4856300 feat(flutter): 서버 연동 기반 — 세션·토큰 갱신·사진 업로드·서버 사진 표시
기기 저장소를 API 로 바꾸기 전에, 모든 도메인이 함께 쓸 네트워크 계층을 먼저 갖춘다.

- DATA_MODE(api|local): 기본은 서버. .env 를 읽지 않는 테스트·화면 확인은 기기 저장소로 돈다
- SessionStore: 액세스·리프레시 토큰을 secure storage 에 두고 앱을 열 때 미리 읽는다
  (모바일에는 쿠키 저장소가 없어 리프레시 토큰도 secure storage 에 둔다)
- DioClient: 401 이면 토큰을 한 번 갱신해 다시 보낸다(동시 요청도 갱신은 한 번).
  서버가 갱신을 거절하면 세션을 끊고 AUTH_002, 서버에 닿지 못하면 세션은 두고 SYS_001
- 실패 알림은 사전 문구로 토스트 한 곳에서 띄우고 같은 코드는 잠깐 한 번만 — 호출부가 처리하는 코드는 끌 수 있다
- 사진: 경로 자리에 server-file:<id> 를 담고, 기기 사진은 저장할 때 형식을 내용으로 확인해 올린다.
  서버 사진은 Dio 로 받아 토큰 갱신을 그대로 쓴다 (PetPhotoImage·PersonAvatar)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:07:49 +09:00
ttipo 607ff5c474 fix(backend): 명함 배경 그림 경로에 앱 내장 svg 허용
앱의 기본 명함 배경(assets/svg/card_bg/*.svg)이 svg 라 검증에 걸렸다.
경로 문자열만 저장하고 서버가 파일을 내려주지 않으므로 svg 를 막을 이유가 없다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:59:28 +09:00
ttipo fdfc659512 chore(backend): npm audit fix 와 swagger js-yaml override
템플릿을 만든 뒤 새로 나온 권고로 npm audit 이 29건(critical 4)을 보고했다.

- npm audit fix (--force 없이) 로 critical·high 를 모두 정리했다 (piscina·proxy-addr 등)
- @nestjs/swagger@11 이 고정한 js-yaml@5.3.0 은 override 로 5.4.x 로 올렸다
  (v12 가 선언하는 버전과 같은 메이저 — multer 와 같은 방식)
- 남은 moderate 20건은 jest 커버리지 도구 체인(개발 전용)이라 유예하고 판단 근거를 security.md 에 남겼다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:56:04 +09:00
ttipo 377c9d2f9a feat(backend): 펫 명함 API — 만들기·꾸미기·삭제·대표 명함
앱의 펫 명함(PetCardRepository)을 서버로 옮긴다.

- /pet-cards: 목록·만들기·꾸미기 바꾸기·삭제, 한 사람 최대 10장 → CARD_001
- /pets/:petId/representative-card: 대표 명함 정하기·풀기 (아이마다 하나 — 부분 유니크 인덱스)
- 명함 코드("0000-000")를 서버가 무작위로 발급한다 — 지금은 앱이 카드 id 로 계산해 기기마다 달라질 수 있었다.
  명함 받기에서 이 코드로 찾을 수 있다
- 배경은 색·앱 그림(assets/…)·올린 사진 중 하나만 저장한다. 그림 경로는 경로 탈출을 막는 형식만 받는다
- 아이를 지우면 명함은 FK 로 함께 지워지고 배경 사진은 커밋 뒤 정리한다
- 에러 코드 추가에 따라 프론트·Flutter 사전을 다시 생성했다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:41:20 +09:00
ttipo 77862249ee feat(backend): 다이어리 API — 다이어리·기록·댓글
앱의 다이어리(DiaryBook: 다이어리 + 기록 + 댓글)를 서버로 옮긴다.

- /diaries: 목록·만들기·수정(이름·대상·리본·표지·알림 끄기)·삭제, /diaries/:id/posts 로 기록 남기기
- /diary-posts: 목록·고치기·삭제, /diary-posts/:id/comments 로 댓글 쓰기
- /diary-comments: 목록·고치기·삭제
- 책장은 최대 6권 (추억 보관함의 아이만 담은 다이어리는 세지 않는다) → DIARY_001
- 한 사람이 한 다이어리에 하루 한 번 (유니크 제약) → DIARY_002
- 아이를 지우면 다이어리 대상에서만 떼고 다이어리·기록은 남긴다
- 지금은 주인만 읽고 쓴다 — 기록·댓글에 작성자를 두어 공유 다이어리로 넓힐 수 있게 했다
- 에러 코드 추가에 따라 프론트·Flutter 사전을 다시 생성했다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:35:08 +09:00
ttipo 8380e35277 feat(backend): 일정 API — 날짜 일정·반복 일정·완료 표시
앱의 일정(ScheduleBook: 날짜 일정 + 반복 규칙 + 완료 표시)을 서버로 옮긴다.
어느 날에 무엇이 놓이는지는 지금처럼 앱이 계산하고, 서버는 원본을 보관한다.

- /schedule-events: 목록·여러 건 만들기(기간 설정, 요청당 500건)·내용 바꾸기·삭제·완료 표시
- /repeat-rules: 목록·만들기·주기/내용 바꾸기·삭제, 날짜별 완료 표시(/completions/:date)
- /repeat-rule-completions: 반복 일정 완료 표시 목록
- 대상은 "self" 또는 내 아이만 받는다. 아이를 지우거나 보관하면 그 아이만 떼고 대상이 빈 일정은 지운다
- 날짜 일정과 반복 규칙은 같은 내용 컬럼(ScheduleDetails)을 쓴다
- @nestjs/schedule 의 ScheduleModule 과 이름이 겹쳐 크론 쪽을 별칭으로 부른다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:26:14 +09:00
ttipo 720b2cd858 feat(backend): 반려동물 API — 등록·수정·삭제·순서·추억 보관함
앱의 우리 아이 기록(PetRepository)을 서버로 옮긴다.

- GET/POST /pets, PATCH/DELETE /pets/:id, PUT /pets/order, PUT/DELETE /pets/:id/archive
- 목록은 정한 순서(sort_order) 기준 커서 페이지네이션 — 공통 keysetPaginate 로 뽑았다
- 아이를 지우거나 보관할 때 일정·다이어리·명함이 따라 정리할 수 있게 PetLifecycleListener 를 두었다
  (반려동물 모듈은 다른 도메인을 모르고, 정리는 같은 트랜잭션에서 한다)
- 사진을 바꾸면 쓰지 않는 파일만 지운다 — 다른 기록이 참조하면 FK 가 막는다
- 목록 응답 Swagger 를 실제 { items, pageInfo } 모양으로 그리는 ApiPaginatedResponse 추가
- e2e 에 목록 pageInfo 계약·미정의 쿼리 거부 검사를 되살렸다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:16:38 +09:00
ttipo 7e0e4c36b5 feat(backend): 소셜 로그인·JWT 인증과 회원·파일 API 추가
앱의 기록을 서버로 옮기려면 회원과 인증이 먼저 있어야 한다.

- 소셜 로그인: 앱이 SDK 로 받은 토큰을 서버가 제공자에게 직접 확인한다
  (카카오·네이버는 사용자 정보 API, 구글·Apple 은 id_token JWKS 검증)
- 소셜 앱 키가 생기기 전까지 쓰는 개발용 로그인(기기 키) — 운영에서는 닫힌다
- 액세스 JWT(1시간) + 리프레시 토큰(해시 저장·회전·재사용 탐지)
- 전역 JwtAuthGuard — 기본으로 모든 라우트가 로그인을 요구하고 @Public() 으로만 연다
- 내 정보 조회·수정·탈퇴(모든 기록과 파일 정리), 파일 소유자 기록과 내려받기,
  업로드 시 매직 바이트로 내용 형식 검사
- 컬럼은 snake_case 이름 규칙, 테스트 환경은 기동 시 마이그레이션 실행 (CI e2e 는 빈 DB)
- 템플릿 예제 사용자 API(in-memory)를 실제 회원 API 로 교체하고 e2e 계약 테스트를 옮겼다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:06:43 +09:00
ttipo 1f7693898c fix(flutter): 마이페이지·아이 정보·로그인 시안 점검 반영
- 반려동물 정보에서 뒤로 가면 마이페이지 첫 화면으로 간다 (시안 동작).
  명함 칩은 명함이 있으면 펫 명함 탭으로, 없으면 명함 관리로 간다.
- 사진이 없는 칸은 시안 바탕색으로 채우고, 아이 이름은 제목 서체 800 으로 맞췄다 (보관함 화면 포함).
- 아이 정보를 수정하면 그 아이를 선택한 채로 돌아온다. 삭제하기는 시안처럼 글자 버튼으로.
- 로그인: 인사말은 화면 위에서 96, 버튼 묶음은 아래에서 76 (상태바·홈 인디케이터가 크면 비켜 준다).
  소셜 로그인 버튼은 누르면 크기는 그대로 두고 어두워진다.
- 온보딩 그림, 프로필 수정·몸무게 단위 시트 간격을 시안 값으로 맞췄다.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 01:24:25 +09:00
ttipo 0c16763943 fix(flutter): 다이어리 시안 점검 반영
- 기록을 남기고 돌아오면 달력이 아니라 피드로 보여 준다.
- 기록 카드: 폴라로이드 위치, 테이프 양옆 흰 점선, 댓글 입력·수정칸 여백을 시안대로 맞추고
  입력 중이면 주황 테두리를 둔다. 등록 버튼은 제목 서체 800 으로.
- 기록 수정 시트: 글칸 최소 높이 120(작성 화면과 같게), 라벨 간격, 종류 선택에 아이콘을 붙였다.
- 다이어리에 참여하면 시트를 닫고, 표지 리본·그림자와 무지개 색 시작 위치(12시)를 맞췄다.
- 검색창 포커스 테두리, 다이어리 머리 여백을 시안 값으로. 다이어리 제거는 전용 확인창으로.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 01:24:25 +09:00
ttipo e6f5cd0720 feat(flutter): 펫 명함 이미지를 앨범에 저장
공유 시트의 '이미지로 저장'과 QR 창의 'QR 이미지 저장'이 시안처럼 휴대폰 앨범에 저장한다.
지금까지는 공유 창을 띄웠다. 웹은 앨범이 없으므로 공유 창을 그대로 쓴다.

- 저장은 AlbumSaver 뒤에 두고(gal), 테스트에서는 가짜 저장기로 바꿔 끼운다.
- 사진 접근을 거절해 저장하지 못하면 권한을 확인하라고 알린다.
- QR 창은 배경 흐림·테두리를 시안대로, 저장하면 QR 창과 공유 시트를 함께 닫는다.
- 코드 복사 안내 문구를 시안에 맞췄다.

권한: Android 는 10 미만에서만 저장 권한을 요청하고(maxSdkVersion 29), iOS 는 사진 추가 안내 문구를 넣었다.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 01:24:24 +09:00
ttipo 4173c64af8 fix(flutter): 펫 명함 화면 시안 점검 반영
- 새 명함은 그 아이의 고유색 한 가지로 시작한다 (CardStyle.forPet — 직접 선택 칸이 켜진다).
- 명함 이름이 길어도 윗줄(이름·성별·공유 버튼)이 깨지지 않게 하고, 공유 버튼에 누름 효과를 넣었다.
- 꾸미기에서 저장하고 돌아오면 상세히 보기로 돌아온다.
- 빈 명함 칸 누름 바탕, '기본' 칸은 골라도 그림자 없음, 명함 받기 안내 아이콘(QR) 등
  상세·관리·목록 화면의 간격과 줄 높이를 시안 값으로 맞췄다.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 01:24:24 +09:00
ttipo 81af93a813 fix(flutter): 홈·일정 시안 점검 반영과 우리 아이 순서 바꾸기
홈
- 우리 아이를 꾹 눌러(320ms) 끌면 순서가 바뀌고 저장된다 (PetRepository.reorder).
  보관함으로 간 아이는 목록에서 빠지므로 원래 순서대로 뒤에 둔다.
- 오늘 할 일 진행 막대 끝을 둥글게, 아이 사진 겹침의 글자 아래 여백(inline-flex)을 반영하고
  긴 일정 제목은 줄을 바꾼다.
- 최근 다이어리 칸을 누르면 시안처럼 일정·기록 탭의 다이어리 목록으로 간다.

일정
- 하루 종일이 켜진 채 기간을 하루 2회로 정하면 회차 시각(08:00·20:00)이 저장되지 않던 문제를 고쳤다.
- 예약 분류를 고른 뒤 같은 일반 일정 칩을 다시 누르면 분류 선택이 풀린다.
- 주기 직접 설정은 2주마다로 시작하고, 매일·평일을 골랐다 돌아와도 단위·횟수가 남는다.
- 12개월 보기에서는 일정 목록을 숨기고, 달력 점 묶음을 칸 가운데에 둔다.
- 저장 버튼 흐림 .45, 일정 제거는 전용 확인창으로.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 01:24:24 +09:00
ttipo d9a4eba9a3 feat(flutter): 공통 위젯 시안 맞춤 — 모달 배경 흐림·누름 효과·휠 직접 입력·제거 확인창
시안 전체 점검에서 여러 화면에 걸쳐 나온 차이를 공통 위젯에서 한 번에 고쳤다.

- 시트·확인창 뒤 화면을 시안(backdrop-filter blur)처럼 흐린다. showModalBottomSheet·showDialog
  대신 BlurredSheetRoute·showBlurredDialog 로 띄우고, 흐림은 등장 애니메이션을 따라간다.
- 탭바 배경 흐림에 채도(saturate 1.6)를 더하고, 탭 라벨은 본문 서체 12/800 으로 맞췄다.
- 누름 효과: Pressable 에 누름 밝기를 두어 인트로 흰 버튼은 살짝 줄고 어두워진다(.99·밝기 .94).
  하트 토글은 누르는 동안 손잡이가 움직이고 안쪽 그림자가 생긴다.
- 시간·날짜 휠의 가운데 숫자를 누르면 직접 입력할 수 있다 (범위를 넘으면 가장 가까운 값).
- 일정·다이어리 제거용 확인창(showRupRemoveConfirm)과 추억 보관함 토스트 색(ToastTone.memory) 추가.
- 버튼 줄 높이를 제목 서체 normal(1.10)로, 빈 상태 여백을 .rup-empty(위아래 40·좌우 20)로 맞추고,
  화면별로 필요한 선택지(작은 섹션 링크, 라벨 간격, 아이콘 달린 선택 버튼, 흐림 불투명도)를 열었다.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 01:24:24 +09:00
ttipo 03cb0224d6 feat(flutter): 추억 보관함 — 보관하기·다시 보기·다시 불러오기
반려동물 정보에서 아이를 추억 보관함으로 옮기고(함께한 기간·메모), 마이페이지의 보관함에서
메모리얼 사진·추억 기록과 원래 정보·펫 명함·다이어리(읽기 전용)를 다시 보며, 다시 불러올 수 있게 했다.

보관 기록은 반려동물에 붙여 저장한다(Pet.archive) — 서버가 생기면 보관/복구 요청으로 바꾼다.
보관하면 그 아이의 일정은 지우고(여러 아이 일정은 그 아이만 뗀다), 홈·나의 반려동물·펫 명함·일정 대상에서
빼며, 그 아이만 담은 다이어리는 책장·홈 최근 기록·기록 알림과 다이어리 수 한도에서 뺀다.

화면은 petListProvider 를 직접 보고 목록 확장(active/archived)으로 거른다 — 걸러 낸 목록을
파생 프로바이더로 두면 Riverpod 3.3 의 일시정지 해제 때 markNeedsBuild 오류가 난다.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 00:09:45 +09:00
ttipo 795509be1b fix(flutter): Riverpod 3.3.2 로 올리고 파생 프로바이더가 알림기만 보도록 정리
Riverpod 3 은 가려진 화면의 구독을 멈췄다가 다시 보일 때 빌드 도중에 풀면서 파생값을 다시 계산한다.
3.2.1 에서는 이때 멈춤 횟수 assertion 이 깨졌다 (3.3.2 에서 수정 — pubspec.lock 만 바뀐다).

3.3.2 에도 파생 프로바이더가 다른 파생 프로바이더에 알리면 markNeedsBuild 오류가 나는 경로가 남아 있다.
이를 고친 3.4 는 Dart 3.12 가 필요해 올릴 수 없으므로, 명함·알림의 파생값을 알림기에서 바로 계산하게 바꿔
파생 프로바이더끼리 잇지 않는다.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 00:09:09 +09:00
ttipo 877c833f7d fix(flutter): 사진 없는 아바타·다이어리 표지에 시안 바탕색 반영
시안의 사진 칸(image-slot)은 사진이 없을 때 rgba(0,0,0,.04) 바탕 위에 기본 그림을 올린다.
아이 아바타는 바탕이 비어 있었고, 다이어리 표지는 고유색이 시안보다 밝게 보였다.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 00:08:19 +09:00
ttipo 66fb329747 feat(flutter): 알림 화면과 홈 종 새 알림 표시 구현
시안의 알림 화면(오늘·어제·최근 7일 묶음)과 홈 종 버튼의 새 알림 점을 옮겼다.
서버가 아직 없어 공유 다이어리의 새 글·댓글 알림은 구조만 두고,
기기에서 만들 수 있는 기록 알림(저녁 8시까지 오늘 기록이 없는 다이어리)부터 보여 준다.
다이어리별 기록 알림 스위치와 마이페이지 일정·다이어리 알림 설정을 따른다.

시각에 따라 달라지는 동작을 테스트할 수 있게 clockProvider 로 현재 시각을 주입하고,
앱을 켜 둔 채 8시·자정이 지나도 알림이 맞게 바뀌도록 그 시각에 다시 계산한다.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:26:58 +09:00
ttipo d7f46d174a feat(flutter): 다이어리 책장·기록·댓글·달력 보기와 홈 최근 기록 구현
일정 관리 다음 흐름 — 일정·기록 탭의 다이어리.
- 책장: 표지 그리드(최대 6권), 반투명 책갈피 리본, 새 다이어리 만들기(표지·이름·대상·리본 색)
- 다이어리 안: 기록 피드(모두/사진/글) · 검색 · 달력 보기(날마다 기분 표정) · 하루 한 번 쓰기 버튼
- 기록하기: 사진 · 오늘 기분 6종 · 글 / 기록 수정·삭제 / 댓글 작성·수정·삭제
- 다이어리 설정: 정보 수정, 알림 스위치(값만 저장), 다이어리 제거(기록·댓글 함께)
- 홈 다이어리: 최근 기록 3개를 사진/글 타일로, 누르면 그 다이어리로
- 아이를 지우면 다이어리 대상에서만 떼고, 회원탈퇴는 다이어리도 지운다
- 기록은 기기에 저장한다 (공유 다이어리 서버가 생기면 DiaryRepository 구현을 바꾼다)

시안과 다른 점: 공유 다이어리(초대 코드·QR·멤버·참여)는 서버가 필요해 안내만 둔다.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:58:09 +09:00
ttipo 76e9fc1af1 refactor(flutter): 대상 아바타·칩을 반려동물 공용으로 옮기고 사진 칸·토스트 보완
다이어리도 "누구의 기록인지"(아이·나)를 고르므로 일정 전용이던 부품을 공용으로 옮긴다.
- TargetAvatar·PetStack·TargetChip → features/pet (칩에 선택 ✓ 표시 추가), "나" id 는 pet_target.dart
- 사진 칸에 높이·모서리·빈 칸 문구 지정, 앨범에서 골라 저장하는 도우미
- 여러 줄 입력칸 최소 높이 지정, "오늘·어제·N일 전" 날짜 표기
- 긴 토스트 문구가 화면 밖으로 넘치던 문제 — 넘치면 줄을 바꾼다

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:57:30 +09:00
ttipo a32c0d1337 feat(flutter): 일정·기록 탭의 일정 관리와 홈 일정 구현
마이페이지 다음 흐름 — 홈 "일정" 과 일정·기록 탭이 실제 일정을 다룬다.
- 달력: 일정 색 점, 월↔주 접기, 12개월 보기 / 고른 날의 일정 목록과 완료 체크
- 일정 추가·수정: 대상(여러 마리·나), 예약/일반 일정, 시간, 장소, 라벨 색
  - 반복(매일·평일·주말·N일/주/개월마다) — 끝나는 날 없는 반복은 날짜를 만들지 않고 규칙으로 보관
  - 기간 설정 + 하루 횟수 — 기간 안의 날짜(반복 주기에 맞는 날)마다 만든다
- 홈: 다음 일정(도는 테두리), 오늘 할 일(4개까지·밤 11시 하루 정리), 예약 일정 D-day
- 아이를 지우면 일정에서 그 아이만 떼고, 회원탈퇴는 일정도 지운다
- 일정은 기기에 저장한다 (백엔드 API 가 생기면 ScheduleRepository 구현을 바꾼다)

시안과 다른 점: "함께 관리"는 서버가 필요해 안내만, 다이어리 탭은 다음 단계,
기간+반복에 맞는 날이 없으면 저장하지 않고 알린다 (시안은 빈 저장 후 "추가했어요").

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:32:37 +09:00
ttipo f2a4451b92 feat(flutter): 시간 선택 시트와 휠·날짜 선택 등 공용 위젯 확장
일정 추가 화면에 필요한 공용 부품을 먼저 넣는다.
- 시간 선택 시트(오전/오후·시·분) — 시·분은 끝없이 돌아가는 휠, 조건이 안 맞으면 시트 안에 안내
- 날짜 선택 시트에 연도 범위 지정 (기간 설정은 올해~2050)
- 큰 알약 토글(일정 관리/다이어리)과 아이콘, 본문 서체 알약 버튼
- "직접 추가" 입력 칩을 공용으로 옮기고, 일반 일정 칩용 큰 × 추가
- HourMinute(시각 값), 주소 등 바깥 값용 날짜 파서

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:32:00 +09:00
ttipo 3e6206630f fix(flutter): 로그인 전에는 앱을 열 때마다 시작하기 화면을 보여 줌
"시작하기"를 한 번 누르면 기기에 저장해 이후 실행에서 인트로를 건너뛰었다.
그래서 시작하기를 누른 뒤 로그인하지 않고 앱을 다시 열면 스플래시 다음
바로 로그인 화면이 나왔다. 시작하기 여부는 이번 실행 동안만 기억한다.

- 로그아웃은 시안대로 곧바로 로그인 화면으로 간다
- 예전 버전이 저장한 entry.introSeen 값은 더 이상 읽지 않는다

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 21:51:06 +09:00
ttipo 1dba0c80ab feat(flutter): 마이페이지·나의 반려동물·반려동물 정보·설정 구현
시안은 아이를 등록하면 "나의 반려동물" 목록으로 보내므로 명함 다음에 만든다.
- 마이페이지: 프로필(닉네임·사진 편집), 반려동물·명함 바로가기, 권한·알림 스위치
  (스위치는 앱 설정값만 저장한다 — OS 권한·푸시는 해당 기능에서 요청)
- 나의 반려동물 / 반려동물 정보(읽기 전용, 연필로 수정) — 아이마다 고유색을 배정한다
- 설정: 약관 문서, 계정 변경·로그아웃, 회원탈퇴(기기 기록 삭제 후 인트로로)
- 등록·삭제 후와 홈 "아이 상세보기"는 나의 반려동물 목록으로 간다

시안 대조 중 발견한 공통 문제도 고쳤다.
- 줄 높이를 주지 않은 글자가 본문 기본값(1.6)을 물려받아 시안보다 줄이 컸다
  → AppTypography.normalHeight 를 두고 섹션 제목·시트 제목에 적용 (홈 간격 보정 포함)
- 버튼이 옆 제목 글자와 한 의미 단위로 합쳐져 스크린리더가 함께 읽었다
  → Pressable·설정 줄을 별도 의미 노드로

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 21:50:10 +09:00
ttipo 46bb77df9b feat(flutter): 펫 명함 만들기·상세·관리·공유 구현
홈에서 아이를 등록한 뒤 이어지는 흐름이라 먼저 만든다.
명함은 기기에 저장하고(아이별 대표 명함 지정), 공유는 명함을 이미지로
그려 기기 공유 창과 QR 로 보낸다. 명함 받기는 서버가 필요해 안내만 둔다.

- 아이를 삭제하면 그 아이의 명함도 함께 지운다
- 수정 화면은 아이 목록을 다 불러온 뒤 연다 (새 등록으로 오인 방지)
- 의존성: share_plus, qr_flutter

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 21:49:21 +09:00
ttipo 2fab3af26d feat(flutter): 홈 화면 구현과 반려동물 등록 연결
시안 screen-home 의 흐름을 옮겼다.
- 인사말(소셜 프로필 이름, 모르면 "집사") · 알림 아이콘
- 우리 아이: 없으면 등록 안내 카드 → 등록 화면, 있으면 아이 고르기 + 펫 명함 빈 슬롯
- 일정(오늘 일정·오늘 할 일·예약 일정)·다이어리는 해당 기능 전까지 시안의 빈 상태로 표시
- 등록·수정 화면을 탭바 위에 덮는 라우트로 연결, 토스트를 모든 화면 위에 표시

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 19:35:39 +09:00
ttipo 50d1aeea4b feat(flutter): 반려동물 등록·수정 화면과 기기 저장소 구현
시안 screen-register · pet-species 를 옮겼다.
- 사진(앨범 선택 → 앱 폴더로 복사), 이름, 종·품종 검색 시트(12 대분류), 성별·중성화,
  생년월일 휠, 몸무게(kg/g), 건강 정보, 알레르기(직접 추가 항목은 다음 등록에도 유지), 메모
- 필수값이 빠지면 칸마다 오류를 표시하고 첫 빠진 칸으로 스크롤
- 수정 모드에서 삭제(확인창) 지원
- 저장은 PetRepository 계약 뒤의 LocalPetRepository(SharedPreferences JSON) —
  백엔드 /api/pets 가 생기면 Dio 구현으로 바꾼다
- image_picker · path_provider 추가, iOS 사진 접근 문구(NSPhotoLibraryUsageDescription)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 19:35:39 +09:00
ttipo 544281ed8e feat(flutter): 입력 폼·시트·토스트 등 공통 위젯 추가
등록·일정 등 이후 화면이 함께 쓰는 시안 컴포넌트.
- 입력칸·선택 칸·2지선다·태그 칩 (테두리는 컨테이너가 그려 시안 치수 46 과 일치)
- 바텀시트(바닥까지 채우는 버튼 포함)·가운데 확인창·토스트(전역, 2초)
- 휠 선택 열 + 생년월일 선택 시트, 카드·점선 테두리·섹션 제목·구분 띠·하위 화면 헤더
- 날짜 표기(9월 28일 · 2021.09.14) · 을/를 조사 순수 함수

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 19:35:23 +09:00
ttipo e880f121b0 fix(flutter): 버튼·탭 라벨 글꼴을 시안 전역 규칙에 맞춤
시안 styles.css 뒷부분의 전역 규칙이 .rup-btn · 선택 버튼 · 하단 탭 라벨을
제목 서체(충북대체) 800 으로 덮어쓴다는 것을 놓쳤다.
- RupButton: 제목 서체 사용, thin(600)·danger 변형 추가 (인트로 CTA 는 본문 서체 유지)
- 탭 라벨: 제목 서체 800, 가운뎃점만 본문 서체로 그려 "일정·기록" 이 벌어져 보이지 않게 함
  (브라우저는 CJK 문장부호 간격을 자동으로 줄이지만 Flutter 는 글리프 폭을 그대로 쓴다)
- 이후 화면에 필요한 색 토큰(오류·시트 배경·빈 명함 등) 추가

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 19:35:23 +09:00
ttipo 2371dff023 feat(flutter): 진입 흐름(스플래시→인트로→로그인→온보딩)과 하단 탭 셸 구현
시안의 첫 진입 흐름을 순서대로 옮겼다.
- 스플래시: 이미지 디코딩 후 등장 애니메이션 재생, 2.6초 뒤 진입 단계에 맞는 화면으로 이동
- 인트로·온보딩은 설치 후 1회만 노출, 로그아웃 시 로그인으로 복귀 (EntryState → 라우터 redirect)
- 온보딩 일러스트는 블러·마스크 때문에 레이어별 PNG 로 래스터화하고 움직임은 Flutter 로 재현
  (하단 안내문·시작하기 버튼은 비율이 다른 기기에서 잘리지 않도록 위젯으로 분리)
- 로그인은 DemoAuthRepository 로 임시 처리 — 소셜 SDK 앱 키와 백엔드 인증 API 연동 시 교체
- 하단 탭 4개 셸(StatefulShellRoute), 탭 화면은 흐름 순서대로 교체할 자리만 둔다

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 18:54:14 +09:00
ttipo 8e2fbe79d8 feat(flutter): 시안 기반 테마·폰트·아이콘과 공통 위젯 추가
Claude Design 시안(rup)을 옮기기 위한 공통 기반.
- 색·크기·글꼴 토큰을 시안 styles.css 값 그대로 상수화하고 테마에 연결
- SUITE·충북대체·어비꿍꿍체 번들 (웹폰트만 배포되는 서체는 TTF 로 변환, 출처는 fonts/README.md)
- 시안 아이콘 56종·전용 글리프를 Dart 로 옮긴 RupIcon
- 눌림 축소(Pressable)·pill 버튼·시안 좌표 배치(DesignArtBox)·그림자 이미지 위젯
- CSS 키프레임처럼 구간마다 곡선을 적용하는 보간 유틸과 테스트
- go_router · flutter_svg · shared_preferences 의존성 추가

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 18:54:03 +09:00
ttipo 9b37235d1e chore: 디자인 시안 폴더(.design) 추적 제외
Claude Design 에서 내보낸 시안은 로컬 참고용이고 개인 사진이 섞여 있다.
앱에 필요한 에셋만 flutter/assets/ 로 옮겨 커밋한다.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 18:53:21 +09:00
385 changed files with 37163 additions and 1395 deletions
+3 -1
View File
@@ -10,7 +10,9 @@
- [ ] 루트 `.env.{env}.example` → `.env.{env}` 복사 후 **placeholder 값 교체** (`change-me-*` 가 남아 있으면 안 된다, [[env-structure]])
운영은 `BACKEND_API_URL`·`CORS_ORIGIN` 을 **실제 도메인**으로 — `localhost`/`example.com` 이 남으면 기동 실패한다
- [ ] `backend/.env.{env}.example` → `backend/.env.{env}` 복사 후 `JWT_SECRET` 등 고유 시크릿 작성 (compose `env_file` 로 로드 — 파일이 없으면 기동 실패)
- [ ] `flutter/.env.example` → `flutter/.env` 복사 후 `API_URL` 확인 (Android 에뮬레이터: `10.0.2.2`)
소셜 로그인을 쓰려면 제공자 콘솔 값(`KAKAO_APP_ID` 등)도 채운다 — 비어 있으면 그 제공자는 거절하고, 개발에서는 개발용 로그인을 쓴다
- [ ] DB 스키마 적용 — `dc exec backend npm run migration:run` ([[nestjs]] "DB 마이그레이션")
- [ ] `flutter/.env.example` → `flutter/.env` 복사 후 `API_URL`·`DATA_MODE` 확인 (Android 에뮬레이터: `10.0.2.2`)
— **이 파일이 없으면 `flutter analyze` 가 asset 경고를 낸다**
- [ ] 프로젝트 식별 값 교체 (`PROJECT_NAME` 등 루트 env 키)
- [ ] 의존성 설치: `cd backend && npm ci`, `cd frontend && npm ci`, `cd flutter && flutter pub get`
+6 -2
View File
@@ -32,12 +32,16 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{
`vite.config.ts` 가 루트 `.env.{mode}` 를 `loadEnv` 로 읽어 포트·프록시 대상을 정한다.
포트를 `frontend/.env` 나 `vite.config.ts` 에 중복 작성하지 말 것 — 루트가 SSOT 다.
(읽은 값은 dev 서버 설정에만 쓰고 클라이언트 번들에는 넣지 않는다 — `VITE_` 없는 값의 번들 노출 금지, [[security]])
- **Flutter 는 예외** — docker-compose 대상이 아니므로 `flutter/.env` 에서 `API_URL` 을 독립적으로 관리한다.
- **Flutter 는 예외** — docker-compose 대상이 아니므로 `flutter/.env` 에서 `API_URL`·`DATA_MODE` 를 독립적으로 관리한다.
`DATA_MODE=api`(기본)는 서버에 저장하고, `local` 은 서버 없이 기기에 저장한다(화면 확인용). `.env` 를 읽지 않는 테스트는 `local` 로 돈다.
- 백엔드 소셜 로그인 값(`KAKAO_APP_ID`·`NAVER_CLIENT_ID`·`GOOGLE_CLIENT_IDS`·`APPLE_CLIENT_IDS`)과 `AUTH_DEV_LOGIN` 은
루트에서 내려주지 않는 백엔드 고유 값이라 `backend/.env.{APP_ENV}` 에 둔다.
- 모든 env 파일은 커밋 금지, `*.example` 만 커밋한다. (`.gitignore` 의 `!*.example` 재포함 규칙)
- **설정이 잘못되면 서버가 뜨지 않아야 한다(fail-fast).** 조용히 기본값으로 굴러가다 운영에서 터지는 것이 더 위험하다.
검증은 `backend/src/shared/config/env.validation.ts` 가 담당하며 `ConfigModule.forRoot({ validate })` 에 연결되어 있다.
- 숫자 항목은 형식 검사 (모든 환경)
- 운영 환경: `DB_HOST`/`DB_USER`/`DB_PASSWORD`/`DB_NAME` 필수
- 운영 환경: `DB_HOST`/`DB_USER`/`DB_PASSWORD`/`DB_NAME`/`JWT_SECRET`(32자 이상) 필수
- 토큰 만료 기간(`JWT_ACCESS_EXPIRES_IN`·`JWT_REFRESH_EXPIRES_IN`)은 `15m`·`1h`·`14d` 형식 (모든 환경)
- 운영 환경: `change-me`·`password` 같은 **예시 값이 그대로 배포되는 것을 차단**
- 운영 환경: `CORS_ORIGIN`·`BACKEND_API_URL` 에 **로컬 주소(`localhost`·`127.0.0.1` 등)나
예시 도메인(`example.com`)이 남아 있으면 차단** — 기동은 되는데 브라우저만 조용히 실패하는 조합을 막는다.
+11 -1
View File
@@ -38,13 +38,23 @@ backend/src/common/errors/error-codes.ts ← 여기만 수정한다
| RES_001 | 404 | 리소스 없음 | "요청하신 정보나 페이지를 찾을 수 없습니다." |
| BIZ_001 | 400 | 그 외 비즈니스 규칙 위반 | "요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요." |
### 도메인 코드 (현재)
| 코드 | HTTP | 상황 |
| --- | --- | --- |
| FILE_001 / FILE_002 | 400 / 413 | 허용되지 않는 파일 형식(내용 검사 포함) / 크기 초과 |
| DIARY_001 | 409 | 다이어리 책장이 가득 참 (최대 6권 — 추억 보관함의 아이만 담은 다이어리는 세지 않는다) |
| DIARY_002 | 409 | 한 사람이 한 다이어리에 하루 한 번 |
| CARD_001 | 409 | 펫 명함 최대 10장 |
### AUTH_002 (클라이언트 전용)
서버는 이 코드를 **절대 내려주지 않는다.** 클라이언트가 다음 조건에서 스스로 생성한다.
- **인증된 세션이 있는 상태**에서 401 응답을 받았을 때 → `AUTH_001` 을 `AUTH_002` 로 승격하고 세션을 정리한다.
- 웹: `useApi.ts` 의 `toAuthCode()` (`authStore.clearAuth()` 호출)
- 앱: `dio_client.dart` 의 `_toApiException()` (`jwt_token` 삭제)
- 앱: `dio_client.dart` — 401 이면 먼저 리프레시 토큰으로 갱신해 다시 보내고, **서버가 갱신을 거절했을 때만** AUTH_002 + 세션 삭제
(`SessionStore.clear(expired: true)` → 진입 상태가 로그인 화면으로). 갱신하려다 서버에 닿지 못했으면 세션을 두고 SYS_001.
## 도메인 코드 추가
+4 -2
View File
@@ -71,7 +71,8 @@ exportCsv(@Res() res: Response) { /* ... */ }
- 구현 SSOT: backend `common/dto/pagination.dto.ts`
(`CursorPaginationQueryDto` · `OffsetPaginationQueryDto` · `PageInfoDto` · `toPaginatedResult()`)
- 클라이언트 타입: frontend `src/shared/types/pagination.ts`, flutter `UserRepository.getUsers()` 패턴
- 구현 헬퍼: backend `common/dto/keyset-pagination.ts` 의 `keysetPaginate()` (커서 = 정렬 키를 담은 불투명 문자열)
- 클라이언트 타입: frontend `src/shared/types/pagination.ts`, flutter `core/api/api_paging.dart` 의 `fetchAllPages()`
- **커서 기반이 기본**이다. 전체 페이지 수가 UI 에 필요한 경우에만 오프셋을 쓴다.
- `limit` 기본값 20, 최대 100 — 무제한 조회를 허용하지 않는다.
@@ -81,10 +82,11 @@ exportCsv(@Res() res: Response) { /* ... */ }
```typescript
@ApiStandardResponse(UserResponseDto, { description: '사용자 조회 성공' })
@ApiPaginatedResponse(PetResponseDto, '아이 목록') // 목록 — { items, pageInfo }
@ApiErrorResponse('RES_001', '사용자를 찾을 수 없음')
```
- `ApiStandardResponse` / `ApiErrorResponse` 는 `common/decorators/api-standard-response.decorator.ts` 제공.
- `ApiStandardResponse` / `ApiPaginatedResponse` / `ApiErrorResponse` 는 `common/decorators/api-standard-response.decorator.ts` 제공.
- `ApiErrorResponse` 는 상태 코드·메시지를 에러 레지스트리에서 가져오므로 문서가 구현과 자동으로 일치한다.
> 에러 코드 ↔ 메시지 매핑은 [[error-codes]], API 설계 규약은 [[api-design]] 참조.
+17 -7
View File
@@ -12,7 +12,11 @@
## 인증 · 토큰
- JWT는 HttpOnly·Secure 쿠키 사용 (웹 `localStorage` 금지) / 모바일은 `flutter_secure_storage` 사용 (키: `jwt_token`)
- Access Token 만료는 짧게(15분~1시간), Refresh Token 은 HttpOnly 쿠키로만 전달
- Access Token 만료는 짧게(15분~1시간), Refresh Token 은 웹이면 HttpOnly 쿠키로만 전달
- **모바일 앱은 예외** — 앱에는 쿠키 저장소가 없어 리프레시 토큰을 응답 본문으로 받아 `flutter_secure_storage`(키: `refresh_token`)에 둔다.
대신 서버는 리프레시 토큰을 **해시로만 저장**하고, **한 번 쓰면 폐기(회전)** 하며, 폐기된 토큰이 다시 오면 그 회원의 토큰을 모두 폐기한다 (`modules/auth`)
- 소셜 로그인은 앱이 보낸 사용자 id 를 믿지 않는다 — 서버가 토큰을 **제공자에게 직접 확인**한다 (카카오는 앱 ID 까지 대조)
- 개발용 로그인(`/auth/dev-sessions`)은 운영에서 **코드로 닫힌다** — env 값으로 열 수 없다
- 비밀번호는 단방향 해시(`bcrypt`/`argon2`)로 저장한다. 평문·가역 암호화 금지
- 인증 실패 응답에서 **원인을 구분해 알려주지 않는다** (아이디 없음/비밀번호 틀림 → 동일하게 AUTH_001)
@@ -26,7 +30,8 @@
템플릿은 `modules/upload` 에 로컬 디스크 기준 골격을 제공한다. 새 업로드 엔드포인트는 이 패턴을 복제한다.
- **확장자와 MIME 을 둘 다** 검사한다 (`upload.config.ts`). 확장자만 보면 위장이 쉽고,
MIME 만 보면 클라이언트가 보낸 값이라 신뢰할 수 없다.
MIME 만 보면 클라이언트가 보낸 값이라 신뢰할 수 없다. 저장한 뒤 **파일 앞부분(매직 바이트)도 확인**해 형식과 다르면 지운다.
- 파일은 **올린 회원의 것**으로 기록하고, 내려받기(`GET /files/:id`)·다른 기록에서의 참조 모두 본인 파일만 허용한다.
- **허용 목록(allowlist) 방식**으로만 받는다. 차단 목록은 언제나 빠뜨린다.
`.html`·`.svg`·`.js` 등 **브라우저가 실행할 수 있는 형식은 넣지 않는다.**
- **저장 파일명은 서버가 생성**한다(UUID + 검증된 확장자). 클라이언트가 보낸 이름을 경로에 쓰면
@@ -35,10 +40,8 @@
- 업로드 디렉터리를 **정적 파일로 그대로 서빙하지 않는다.** 내려줄 때는 컨트롤러를 거쳐
`Content-Type` 을 서버가 지정하고 `Content-Disposition: attachment` 를 붙인다.
> ⚠️ **알려진 한계**: 확장자·MIME 검사는 **파일 내용을 보지 않는다.**
> 이름이 `.png` 이고 MIME 이 `image/png` 이면 내용이 HTML 이어도 통과한다.
> 그래서 위의 "정적 서빙 금지"가 중요하다 — 내용 기반 검증이 필요하면
> 매직 바이트를 확인하는 라이브러리(`file-type` 등)를 파이프라인에 추가한다.
> ⚠️ **알려진 한계**: 매직 바이트는 파일의 **앞부분만** 본다. 앞은 PNG 이고 뒤에 다른 내용을 덧붙인 파일은 통과한다.
> 그래서 위의 "정적 서빙 금지"(attachment + 서버가 정한 Content-Type + nosniff)가 여전히 중요하다.
## 네트워크 · 인프라
@@ -85,7 +88,11 @@
2. **수정이 다른 것을 깨뜨리는가** — 생태계 버전이 어긋나면 더 큰 위험
3. 미룬다면 **언제 다시 볼지** 조건을 적어둔다
> **현재 유예 중인 항목**: 없음 (`npm audit` 0건)
> **현재 유예 중인 항목** (backend, 2026-10-11 기준 `npm audit` moderate 20건):
> `js-yaml@3`·`sprintf-js` — `ts-jest → @jest/transform → babel-plugin-istanbul` 안의 테스트 커버리지 도구 체인.
> 1. 도달 경로: 운영 이미지(`npm ci --omit=dev`)에 들어가지 않고, 테스트가 읽는 YAML 도 저장소의 설정 파일뿐이다
> 2. 수정 비용: 같은 메이저에 고친 버전이 없고 `--force` 는 ts-jest 를 메이저로 내린다
> 3. 다시 볼 때: jest·ts-jest 메이저를 올릴 때, 또는 istanbul 이 js-yaml 4+ 로 옮겨 가면
### 전이 의존성 취약점은 overrides 로 끊는다
@@ -102,6 +109,9 @@
> 실제 사례: `@nestjs/platform-express@11` 은 `multer` 를 `2.2.0` 으로 고정하는데 이 버전에 DoS 취약점(high)이 있었다.
> `@nestjs/platform-express@12` 는 `multer@2.4.0` 을 쓰지만, v12 로 올리면 나머지 Nest 생태계(v11)와 호환이 깨진다.
> **v12 가 하는 일이 결국 multer 를 올리는 것**이므로, override 로 같은 결과를 생태계 업그레이드 없이 얻었다.
>
> 같은 이유로 `@nestjs/swagger@11` 이 고정한 `js-yaml@5.3.0`(moderate)은 `"@nestjs/swagger": { "js-yaml": "^5.4.2" }` 로 올렸다
> (근거: `@nestjs/swagger@12` 가 `js-yaml@5.4.2` 를 선언한다). swagger 를 v12 로 올릴 때 함께 제거한다.
규칙:
+4 -2
View File
@@ -51,14 +51,16 @@ describe('formatSomething', () => {
| 에러 응답 포맷 | `backend/src/common/filters/http-exception.filter.spec.ts` | 코드/상태/traceId, **내부 정보 미노출**, 검증 상세 사유 미노출 |
| 프론트 에러 사전 | `frontend/src/composables/useApi.spec.ts` | 코드 목록이 백엔드와 일치, 폴백 동작 |
| 앱 에러 사전 | `flutter/test/error_lexicon_test.dart` | 코드 목록이 백엔드와 일치, 폴백 동작 |
| 응답 계약 (e2e) | `backend/test/app.e2e-spec.ts` | 실제 HTTP 로 래퍼·에러코드·traceId·페이지네이션·헬스체크 형식 검증 |
| 응답 계약 (e2e) | `backend/test/app.e2e-spec.ts` | 실제 HTTP 로 래퍼·에러코드·traceId·페이지네이션·헬스체크 형식 검증 (개발용 로그인으로 회원을 만들고 끝나면 탈퇴) |
| 앱 ↔ 서버 응답 모양 | `flutter/test/api_*_test.dart` + `test/support/fake_api.dart` | 앱의 서버 저장소가 보내고 읽는 JSON 모양 (가짜 서버) |
| 앱 ↔ 서버 라이브 점검 | `flutter/test_live/api_contract_live_test.dart` | 위 가정이 **실제 개발 서버**와 맞는지 — 서버가 필요해 CI 에서는 돌지 않는다 (`flutter test test_live`) |
> **e2e 는 DB 가 필요하다** (AppModule 이 TypeORM 을 초기화한다).
> 로컬에서는 개발 스택을 띄운 뒤 컨테이너 안에서 실행하는 것이 가장 간단하다.
> ```bash
> docker exec <프로젝트>-backend npx jest --config ./test/jest-e2e.json
> ```
> CI 는 postgres 서비스 컨테이너를 띄워 자동으로 실행한다.
> CI 는 postgres 서비스 컨테이너를 띄워 자동으로 실행한다. `NODE_ENV=test` 에서는 기동할 때 마이그레이션을 적용한다(빈 DB).
- 에러 코드를 추가/삭제하면 **세 곳의 기대 목록을 함께 갱신**한다 ([[checklist]] 3절).
- 매핑 로직(응답 래퍼·상태 역매핑)을 바꾸면 해당 분기 테스트를 추가한다.
+35 -30
View File
@@ -16,18 +16,23 @@ description: Flutter 모바일 앱(flutter/) 개발 규칙 — Dart Sound Null S
| 제공 항목 | 위치 | 비고 |
| --- | --- | --- |
| Dio 클라이언트 | `lib/core/api/dio_client.dart` | 싱글톤, baseUrl=dotenv `API_URL`(fallback 로컬), 타임아웃 |
| Auth/Error 인터셉터 | `dio_client.dart` 의 `InterceptorsWrapper` | onRequest(토큰 첨부)·onResponse(언래핑)·onError(코드 매핑) — 인라인 |
| 데이터 모드 | `lib/core/config/app_config.dart` | `.env` 의 `DATA_MODE` — `api`(기본, 서버) / `local`(기기 저장 · 서버 없이 화면 확인 · 테스트) |
| Dio 클라이언트 | `lib/core/api/dio_client.dart` + `dio_provider.dart` | Provider 로 주입(`ref.watch(dioProvider)`), baseUrl=dotenv `API_URL`, 타임아웃 |
| Auth/Error 인터셉터 | `dio_client.dart` 의 `InterceptorsWrapper` | onRequest(토큰 첨부)·onResponse(언래핑)·onError(**401 → 토큰 갱신 후 한 번 재시도**·코드 매핑) — 인라인 |
| **ApiException** | `lib/core/api/api_exception.dart` | `{code, message, traceId, statusCode}` 정규화 예외. 인터셉터가 `DioException.error` 에 실어준다 |
| 실패 알림 | `apiErrorReporterProvider` → `rup_toast.dart` 의 `apiErrorToastReporter` | 사전 문구 토스트, 같은 코드는 잠깐 한 번만. 호출부가 처리하는 코드는 `kSilentCodesExtra` 로 끈다 |
| ErrorLexicon | `lib/core/api/error_lexicon.dart` | 사전 접근 API. 실제 문구는 `error_lexicon.g.dart`(**자동 생성, 수정 금지**) ([[error-codes]]) |
| 세션 만료 처리 | `dio_client.dart` 의 `_toApiException()` | 토큰 보유 상태에서 401 → `AUTH_002` 승격 + 토큰 삭제 |
| 토큰 저장 | `flutter_secure_storage`, key 상수 `kJwtTokenKey` | `dio_client` 에서 사용 |
| 상태관리 초기화 | `main.dart` 의 `ProviderScope` | Riverpod 사용 준비됨 |
| Dio Provider | `lib/core/api/dio_provider.dart` | 인터셉터 적용된 Dio 를 DI 로 주입 |
| 예제 feature(참조용) | `lib/features/user/` | `data/models`(UserModel)·`data/repositories`(단건+커서 목록)·`presentation/providers` 수직 슬라이스 |
| 세션 | `lib/core/api/session_store.dart` | 액세스·리프레시 토큰(secure storage `kJwtTokenKey`·`kRefreshTokenKey`)을 들고 있다. 회원이 바뀌면 `sessionEpochProvider` 가 오른다 |
| 세션 만료 처리 | `dio_client.dart` 의 `_toApiException()` | 토큰 갱신을 서버가 거절하면 `AUTH_002` + 세션 삭제 → 진입 상태가 로그인 화면으로 보낸다 |
| 화면의 실패 처리 | `lib/core/api/api_guard.dart` | `succeeded(future)` / `isReportedApiFailure(e)` — 알림은 이미 떴으니 화면은 멈추기만 한다 |
| 목록 | `lib/core/api/api_paging.dart` | `fetchAllPages()` — `{items, pageInfo}` 를 마지막 페이지까지 이어 읽는다 |
| 사진 | `server_file.dart` · `file_uploader.dart` · `shared/widgets/photo_image.dart` | 사진 경로 자리에 `server-file:<id>`. 기기 사진은 저장할 때 내용으로 형식을 확인해 올린다. 서버 사진은 Dio 로 받아 그린다 |
| 상태관리 초기화 | `main.dart` 의 `ProviderScope` | 세션(토큰)을 첫 프레임 전에 읽어 override 한다 |
| 도메인 feature(패턴 참고) | `lib/features/pet/` | 저장소 **계약 + `Local*` + `Api*`**, 프로바이더가 `DATA_MODE` 로 고른다. 알림기 build 는 `sessionEpochProvider` 를 watch |
> **확장 시 따르는 규칙** (아래 "프로젝트 구조"는 목표 구조 — 기능 추가 시 점진적으로 채운다):
> - **상태관리**: `features/user` 의 model→repository→provider 패턴을 복제. 위젯에서 `ref.watch(userProvider(id))`
> - **상태관리**: `features/pet` 의 저장소 계약(Local·Api)→알림기 패턴을 복제. 서버가 이미 한 정리(아이 삭제에 따른 일정 정리 등)는 Api 저장소에서 아무것도 보내지 않는다
> - **소셜 로그인 SDK**: 앱 키를 받으면 `ApiAuthRepository.signIn` 만 SDK 토큰 → `POST /auth/sessions` 로 바꾼다 (지금은 기기 키로 개발용 로그인)
> - **앱 구조**: 라우터 도입 시 `MyApp` 을 `app.dart` 로 분리, `core/{constants,theme,storage}` 채움, 공통 위젯은 `shared/widgets/`
> - **토큰 저장 추상화(선택)**: 필요 시 `core/storage/` 래퍼로 분리
> - **권장 패키지(선택)**: `go_router`·`freezed`·`json_serializable`·`cached_network_image`·`fpdart` 도입 시 `pubspec.yaml` 추가
@@ -162,38 +167,33 @@ class UserWidget extends ConsumerWidget {
## API 통신 (Dio + 인터셉터)
실제 구현(`lib/core/api/dio_client.dart`)은 싱글톤 + `InterceptorsWrapper` 인라인 방식이다. 인터셉터 로직 추가 시 이 파일의 패턴을 그대로 따른다.
실제 구현(`lib/core/api/dio_client.dart`)은 `InterceptorsWrapper` 인라인 방식이고, `dio_provider.dart` 가 세션·실패 알림을 넣어 만든다.
인터셉터 로직 추가 시 이 파일의 패턴을 그대로 따른다.
```dart
// core/api/dio_client.dart (실제 구조 요약)
class DioClient {
DioClient._internal() {
// flutter_dotenv 로 .env 로드, 미설정 시 로컬 기본값
final baseUrl = dotenv.maybeGet('API_URL') ?? 'http://localhost:3000/api';
_dio = Dio(BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 15),
));
DioClient({required String baseUrl, required SessionStore session, ApiErrorReporter? onError}) {
_dio = Dio(BaseOptions(baseUrl: baseUrl, connectTimeout: ..., receiveTimeout: ...));
_authDio = Dio(...); // 갱신 전용 — 인터셉터 없음
_dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) async {
// jwt_token 읽어 Authorization 헤더 첨부
final token = await _storage.read(key: kJwtTokenKey);
if (token != null) options.headers['Authorization'] = 'Bearer $token';
handler.next(options);
onRequest: (options, handler) {
// 세션의 액세스 토큰을 Authorization 헤더에 붙인다 (토큰은 SessionStore 가 secure storage 에서 읽어 둔다)
},
onResponse: (response, handler) {
// success==true → data 언래핑, false → ApiException 으로 reject
handler.next(response);
// success==true → data 언래핑, false → ApiException 으로 reject (+ 알림)
},
onError: (err, handler) async {
// 상태코드/서버 error.code → ApiException{code,message,traceId} ([[error-codes]] 참조)
// 토큰 보유 상태의 401 은 AUTH_002(세션 만료)로 승격하고 토큰을 삭제한다.
handler.next(err.copyWith(error: await _toApiException(err)));
// 401 이면 리프레시 토큰으로 갱신해 한 번 다시 보낸다 (동시에 여러 요청이 와도 갱신은 한 번)
// 그래도 실패하면 상태코드/서버 error.code → ApiException{code,message,traceId} ([[error-codes]] 참조)
// 서버가 갱신을 거절하면 AUTH_002(세션 만료) + 세션 삭제, 서버에 닿지 못했으면 SYS_001
},
));
}
}
// 리포지토리는 ref.watch(dioProvider) 로 주입받는다
final dioProvider = Provider<Dio>((ref) => ref.watch(dioClientProvider).dio);
```
> 인터셉터를 별도 클래스(`AuthInterceptor`/`ErrorInterceptor`)로 분리하고 싶다면 리팩터링 가능하나, 현재는 위 인라인 구조가 SSOT다.
@@ -206,7 +206,8 @@ class DioClient {
// flutter_secure_storage 사용
const storage = FlutterSecureStorage();
// 저장 (키 이름은 dio_client 의 상수로 통일: kJwtTokenKey == 'jwt_token')
// 저장 (키 이름은 session_store 의 상수로 통일: kJwtTokenKey == 'jwt_token', kRefreshTokenKey == 'refresh_token')
// 앱 코드는 직접 쓰지 않고 SessionStore(SecureTokenVault)가 맡는다
await storage.write(key: kJwtTokenKey, value: token);
// 읽기
@@ -242,9 +243,12 @@ await storage.deleteAll();
- [ ] **위젯**: depth 깊어지면 private 위젯 분리, `const` 적극, `build()` 내 비즈니스 로직 금지.
- [ ] **반응형**: 하드코딩 픽셀 금지 → `MediaQuery`/`Expanded`/`Flexible`.
- [ ] **API**: `dio_client.dart` 의 인터셉터 패턴 재사용. 리포지토리에서 try/catch 로 메시지를 만들지 않는다.
- [ ] **새 저장소**: 계약 + `Local*`(기기) + `Api*`(서버), 프로바이더가 `DATA_MODE` 로 고른다. 로그인 전에는 서버를 부르지 않는다. `test/api_*_test.dart` 처럼 가짜 서버(`test/support/fake_api.dart`)로 테스트한다.
- [ ] **화면 실패 처리**: 서버 호출은 `succeeded()` 또는 `catch (e) { if (!isReportedApiFailure(e)) rethrow; }` — 입력을 지운 채 넘어가지 않는다.
- [ ] **사진**: 화면은 `PetPhotoImage`/`PersonAvatar`(→ `PhotoImage`)로 그린다 — 서버 파일·기기 파일을 같은 자리에서 처리한다.
- [ ] **에러 분기**: `e.error as ApiException` 의 **`code` 로 분기**한다. 문구는 사전에서만 온다.
- [ ] **에러 사전**: `error_lexicon.g.dart` 직접 수정 금지 — 백엔드 레지스트리 수정 후 `npm run sync:error-codes`.
- [ ] **목록**: 응답은 `{items, pageInfo}` — `UserRepository.getUsers()` 패턴 재사용 ([[response-format]]).
- [ ] **목록**: 응답은 `{items, pageInfo}` — `fetchAllPages()` 재사용 ([[response-format]]).
- [ ] **토큰**: `flutter_secure_storage` + 키 상수 `kJwtTokenKey` 통일. `SharedPreferences` 저장 금지.
- [ ] **상태관리**: 새 기능은 Riverpod 프로바이더/노티파이어로 작성 (`ProviderScope` 는 기본 제공).
- [ ] **구조 보완 시**: `app.dart` 분리, `core/{constants,theme,storage}` 생성. 공통 위젯은 `lib/shared/widgets/` 에 둔다(이동 완료).
@@ -253,5 +257,6 @@ await storage.deleteAll();
CI(`ci-flutter.yml`)는 **flutter/ 가 바뀐 커밋에서만** 자동 실행된다 (SDK 다운로드가 느려 분리).
다른 시점에 검사가 필요하면 Actions 탭의 "Run workflow" 로 수동 실행한다.
- [ ] **린트로 강제되는 항목**: `dynamic` 호출·`print`·`const` 누락·await 누락·작은따옴표·import 정렬 (`analysis_options.yaml`).
- [ ] **env**: `flutter/.env.example` → `.env` 복사 후 `API_URL` 설정 (Android 에뮬레이터는 `10.0.2.2`).
- [ ] **env**: `flutter/.env.example` → `.env` 복사 후 `API_URL`·`DATA_MODE` 설정 (Android 에뮬레이터는 `10.0.2.2`).
`.env` 는 `pubspec.yaml` 의 asset 이라 **없으면 `flutter analyze` 가 경고**한다.
- [ ] **서버 계약 점검**: 서버 쪽 응답 모양을 바꿨다면 개발 서버를 띄우고 `flutter test test_live` (CI 에서는 돌지 않는다).
+30 -14
View File
@@ -31,18 +31,24 @@ description: NestJS 백엔드(backend/) 개발 규칙 — 컨트롤러/서비스
| DB 설정 | `shared/config/database.config.ts` | 앱·CLI 공유 SSOT. `synchronize` 기본 off |
| 마이그레이션 | `src/data-source.ts` + `src/migrations/` | `npm run migration:*` (아래 "DB 마이그레이션") |
| Graceful shutdown | `main.ts` 의 `enableShutdownHooks()` + `shared/logger/shutdown.logger.ts` | SIGTERM 시 요청/커넥션 정리 후 종료 |
| 예제 모듈(참조용) | `modules/user`(DTO+Swagger+페이지네이션) | 새 모듈 패턴 참고. in-memory 데모 저장소 |
| 파일 업로드 | `modules/upload` | 로컬 디스크 저장 + 형식·크기 검증 + 안전한 파일명 생성 ([[security]]) |
| **인증(JWT)** | `modules/auth` + `common/guards/jwt-auth.guard.ts` (APP_GUARD) | **모든 라우트가 기본으로 로그인 필요** — 공개 라우트만 `@Public()`. 핸들러는 `@CurrentUser()` 로 회원을 받는다. 아래 "인증" 참조 |
| 회원 | `modules/user` | 내 정보 조회·수정·탈퇴(`/users/me`), 소셜 계정 연결 |
| 도메인 모듈(패턴 참고) | `modules/pet` · `schedule` · `diary` · `pet-card` | 새 모듈은 `modules/pet` 을 복제한다 (엔티티·DTO·서비스·컨트롤러·소유자 조회) |
| 커서 페이지네이션 헬퍼 | `common/dto/keyset-pagination.ts` | `keysetPaginate(qb, 정렬키, query)` + `mapPage()`. 목록 Swagger 는 `@ApiPaginatedResponse` |
| 공통 검증 데코레이터 | `common/validators` | `IsIsoDate`·`IsHexColor`·`IsHourMinute`·`IsTargetIdList`·`IsShortStringList` |
| 컬럼 이름 규칙 | `shared/config/snake-naming.strategy.ts` | 엔티티 속성 camelCase ↔ 컬럼 snake_case (database.config.ts 에 연결) |
| 도메인 간 정리 | `modules/pet/pet-lifecycle.ts` | 아이 삭제·보관을 따라 일정·다이어리·명함이 같은 트랜잭션에서 정리 (`onModuleInit` 에서 등록) |
| 파일 업로드 | `modules/upload` | 소유자 기록·매직 바이트 검사·`GET /files/:id`(본인만, attachment)·`releaseIfUnused()` ([[security]]) |
| 헬스체크 | `modules/health` | `@SkipThrottle`+`@SkipTransform` — 기계가 읽는 엔드포인트 패턴 ([[response-format]]) |
| 순수 유틸 | `shared/utils/` | 순수 함수 분리 위치 |
> **확장 시 따르는 규칙** (기능이 필요해지면 규칙대로 구현):
> - **인증(JWT)**: `@nestjs/jwt`·`@nestjs/passport` 설치 → `common/guards/` 생성 → AUTH_001/AUTH_003 흐름 검증
> - **DB 영속화**: TypeORM 엔티티/리포지토리 추가 (예제의 in-memory 저장 대체).
> `app.module.ts` 는 이미 `TypeOrmModule.forRootAsync` 로 연결되어 있다.
> - **소셜 로그인 운영**: 제공자 콘솔의 값을 `backend/.env.{env}` 에 넣는다 (`KAKAO_APP_ID`·`NAVER_CLIENT_ID`·`GOOGLE_CLIENT_IDS`·`APPLE_CLIENT_IDS`). 비어 있는 제공자는 로그인을 거절한다
> - **새 엔티티**: 소유자 컬럼(`owner_id`, 회원 삭제 시 CASCADE)을 두고 **조회는 항상 소유자 조건**으로 — 남의 것과 없는 것은 똑같이 `RES_001`.
> 스키마는 **마이그레이션으로 관리**하며 `synchronize` 는 기본 off 다 (아래 "DB 마이그레이션" 참조)
> - **파일을 참조하는 컬럼**: FK 는 `onDelete: 'NO ACTION'` 으로 둔다 — `UploadService.releaseIfUnused()` 가 이 제약으로 "아직 쓰는 파일"을 가린다
> - **도메인 에러 코드**: `error-codes.ts` 의 `DOMAIN_ERROR_CODES` 에 등록 → `npm run sync:error-codes`
> - `common/{guards,pipes}/` 는 해당 관심사 등장 시 생성 (아래 "모듈 구조"는 목표 구조)
> - `common/pipes/` 는 해당 관심사 등장 시 생성 (아래 "모듈 구조"는 목표 구조)
---
@@ -181,6 +187,7 @@ SwaggerModule.setup('api-docs', app, document);
```typescript
@ApiStandardResponse(UserResponseDto, { description: '사용자 조회 성공' })
@ApiPaginatedResponse(PetResponseDto, '아이 목록') // 목록 — { items, pageInfo } 로 그린다
@ApiErrorResponse('RES_001', '사용자를 찾을 수 없음') // 상태·메시지를 레지스트리에서 가져온다
```
@@ -190,12 +197,20 @@ SwaggerModule.setup('api-docs', app, document);
## 인증 (JWT)
> 📐 **확장 지점.** 이 템플릿은 JWT 인증을 강제하지 않는다(규칙만 제공). **인증이 필요해지면** 아래 규칙대로 구현한다: `@nestjs/jwt`·`@nestjs/passport` 설치 → `common/guards/` 생성 → AUTH_001/AUTH_003 흐름 검증.
`modules/auth` 가 구현되어 있다. 경로는 리소스 이름으로 둔다 ([[api-design]] — 동사 금지).
- Access Token: 만료 시간 짧게 (15분 ~ 1시간)
- Refresh Token: HttpOnly Secure 쿠키로 전달
- `@nestjs/jwt` + `@nestjs/passport` 사용
- Guard를 통한 라우트 보호
| 경로 | 하는 일 |
| --- | --- |
| `POST /auth/sessions` | 소셜 로그인(처음이면 가입) — 앱이 SDK 로 받은 토큰을 **서버가 제공자에게 직접 확인**한다 |
| `POST /auth/dev-sessions` | 개발용 로그인(기기 키) — 소셜 앱 키 전까지. **운영에서는 닫힌다**(RES_001) |
| `POST /auth/tokens` | 토큰 갱신 — 리프레시 토큰을 폐기하고 새 한 쌍 (회전) |
| `POST /auth/token-revocation` | 로그아웃 — 리프레시 토큰 폐기 (204) |
- Access Token: 1시간(`JWT_ACCESS_EXPIRES_IN`), HS256. 페이로드 `{ sub, typ: 'access' }`
- Refresh Token: 불투명 랜덤 값, **DB 에는 SHA-256 해시만**. 폐기된 토큰이 다시 오면 탈취로 보고 그 회원의 토큰을 모두 폐기한다
- 모바일은 리프레시 토큰을 응답 본문으로 받아 secure storage 에 둔다 (웹을 붙이면 HttpOnly 쿠키로 내려주는 경로를 따로 둔다 — [[security]])
- 전역 `JwtAuthGuard` — 토큰이 없거나 틀렸거나 탈퇴한 회원이면 원인을 가리지 않고 AUTH_001
- 로그인 엔드포인트는 전역보다 엄격한 rate limit(분당 10회)을 건다
- 토큰 저장·전송 보안 규칙은 [[security]] 참조.
---
@@ -217,7 +232,7 @@ SwaggerModule.setup('api-docs', app, document);
- **Rate Limiting**: `@nestjs/throttler` 로 일원화 (`app.module.ts` 의 `APP_GUARD`). 초과 시 429 → `REQ_001`.
라우트 단위 조정은 `@Throttle({ default: { ttl, limit } })`, 제외는 `@SkipThrottle()` (헬스체크에 적용되어 있다).
**rate limiter 를 중복으로 추가하지 말 것** ([[security]])
- **Pagination**: 목록 API는 반드시 페이지네이션 적용 — `common/dto/pagination.dto.ts` 의 쿼리 DTO + `toPaginatedResult()` 사용. 응답은 `{items, pageInfo}` 고정 ([[response-format]])
- **Pagination**: 목록 API는 반드시 페이지네이션 적용 — `common/dto/pagination.dto.ts` 의 쿼리 DTO + `common/dto/keyset-pagination.ts` 의 `keysetPaginate()` 사용. 응답은 `{items, pageInfo}` 고정 ([[response-format]]). Swagger 는 `@ApiPaginatedResponse(Dto)`
- **Logging**: `winston` 또는 `@nestjs/common Logger` 사용, `console.log` 금지. 레벨 기준·민감정보 마스킹은 [[logging]]
- **Graceful shutdown**: `app.enableShutdownHooks()` 가 적용되어 있다. 종료 시 정리가 필요한 리소스(큐 소비자, 워커, 외부 연결)는
`OnApplicationShutdown` 훅으로 정리한다. **`process.on(signal)` 로 직접 잡지 말 것** — Nest 의 종료 흐름에 간섭해 종료 코드가 달라진다
@@ -270,6 +285,7 @@ dc exec backend npm run migration:run
- 생성된 SQL 을 **검토 없이 커밋하지 않는다.** 컬럼 rename 이 DROP+ADD 로 생성되면 데이터가 날아간다.
- 마이그레이션 파일은 **한번 커밋되면 수정하지 않는다.** 잘못됐으면 새 마이그레이션으로 고친다.
- 운영 배포 시 `migrationsRun: true` 로 자동 실행된다 — 별도 명령이 필요 없다.
테스트 환경(`NODE_ENV=test`)도 자동 실행한다 — CI 의 e2e 는 빈 DB 에서 시작하기 때문이다.
- 빠른 실험이 필요하면 개발 환경에서만 `DB_SYNCHRONIZE=true` 로 임시 활성화하되,
**커밋 전에는 끄고 마이그레이션을 생성**한다. 운영에서는 이 값과 무관하게 항상 비활성화된다.
- 접속 설정은 `shared/config/database.config.ts` 하나만 수정한다 (앱과 CLI 가 공유).
@@ -317,6 +333,6 @@ dc exec backend npm uninstall <패키지>
- [ ] **의존성 변경 시**: 호스트가 아니라 **컨테이너 안에서** `npm install/uninstall` (위 주의 항목 참조). 변경 후 Docker 빌드 확인.
- [ ] **에러 던지기**: `HttpException` 직접 생성은 린트로 차단된다 — `BusinessException(code)` 사용.
- [ ] **새 필수 env 추가 시**: `shared/config/env.validation.ts` 에 검증 규칙과 테스트를 함께 추가 ([[env-structure]]).
- [ ] **DB 전환 시**: in-memory Map → TypeORM 엔티티/리포지토리로 교체.
- [ ] **인증 추가 시**: `@nestjs/jwt`·`@nestjs/passport` 설치 → `common/guards/` 생성 → AUTH_001/003 흐름 확인.
- [ ] **새 라우트**: 기본으로 로그인이 필요하다 — 공개해야 하는 라우트만 `@Public()`. 회원은 `@CurrentUser()` 로 받는다.
- [ ] **새 엔티티**: 소유자 조건으로만 조회(남의 것 = RES_001), 마이그레이션 생성·검토, 파일 FK 는 NO ACTION.
- [ ] **env**: DB/Redis/CORS/포트는 루트 `.env` 에서 주입된다. 백엔드 고유 시크릿(`JWT_SECRET` 등)만 `backend/.env.{env}` 에 작성(compose `env_file` 로 로드).
+3 -1
View File
@@ -36,7 +36,9 @@ description: Vue 3 프론트엔드(frontend/) 개발 규칙 — Composition API
> 두 포트 모두 `vite.config.ts` 가 **루트 `.env.{mode}` 에서 직접 읽는다** — 프론트 쪽에 포트를 중복 작성하지 않는다.
> **확장 시 따르는 규칙:**
> - **로그인 연동**: 실제 `/auth/login` 엔드포인트 연결 → 성공 시 `useAuth` 가 `authStore.markAuthenticated()` 호출
> - **로그인 연동**: 백엔드 인증(`modules/auth`)은 지금 **모바일용**이다 — 소셜 토큰으로 `POST /auth/sessions`, 토큰은 응답 본문.
> 웹을 붙일 때는 리프레시 토큰을 HttpOnly 쿠키로 내려주는 경로를 백엔드에 추가한 뒤 `useAuth` 를 연결하고,
> 성공 시 `authStore.markAuthenticated()` 를 호출한다 ([[security]]). `useAuth`·`useUser` 는 아직 템플릿 예제 경로를 가리킨다 (내 정보는 `GET /users/me`)
> - **로그인 페이지**: 추가 시 라우터 가드 리다이렉트를 `{ name: 'login' }` 으로 변경
> - **공통 컴포넌트**: `BaseButton` 패턴을 따라 `src/components/` 에 추가
+6
View File
@@ -51,6 +51,12 @@ pnpm-debug.log*
.DS_Store
Thumbs.db
# ==========================================
# 디자인 시안 (Claude Design 내보내기) — 로컬 참고용, 개인 사진 포함
# 앱에 필요한 에셋은 flutter/assets/ 로 옮겨 커밋한다
# ==========================================
/.design/
# ==========================================
# 캐시
# ==========================================
+21 -1
View File
@@ -9,7 +9,27 @@
# docker-compose 의 env_file 지시자 때문에 파일은 비어 있어도 존재해야 한다.
# ==========================================
# JWT 서명 키 — 인증 도입 시 사용. 환경마다 다른 값을 쓰고 절대 커밋하지 않는다.
# JWT 서명 키 — 환경마다 다른 값을 쓰고 절대 커밋하지 않는다. 운영은 32자 이상 필수.
# 생성 예: node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))"
# (개발에서 비워 두면 개발용 고정 키로 서명한다 — modules/auth/auth.config.ts)
JWT_SECRET=change-me-generate-a-long-random-string
JWT_ACCESS_EXPIRES_IN=1h
JWT_REFRESH_EXPIRES_IN=14d
# ==========================================
# 소셜 로그인 (앱이 SDK 로 받은 토큰을 서버가 제공자에게 확인한다)
#
# 비어 있는 제공자는 로그인을 거절한다(AUTH_001). 값은 각 개발자 콘솔에서 확인한다.
# ==========================================
# 카카오 앱 ID (숫자) — 토큰이 우리 앱에서 발급됐는지 확인한다
KAKAO_APP_ID=
# 네이버 클라이언트 ID — 설정해야 네이버 로그인이 켜진다
NAVER_CLIENT_ID=
# 구글 OAuth 클라이언트 ID (iOS·Android 등 여러 개면 콤마로 구분) — id_token 의 aud
GOOGLE_CLIENT_IDS=
# Apple 번들 ID·서비스 ID (콤마 구분) — id_token 의 aud
APPLE_CLIENT_IDS=
# 개발용 로그인(POST /api/auth/dev-sessions) — 소셜 앱 키 없이 기기 키로 가입한다.
# 운영에서는 이 값과 무관하게 항상 꺼진다. 개발에서도 끄려면 false.
AUTH_DEV_LOGIN=true
+17 -1
View File
@@ -9,7 +9,23 @@
# docker-compose 의 env_file 지시자 때문에 파일은 비어 있어도 존재해야 한다.
# ==========================================
# JWT 서명 키 — 인증 도입 시 사용. 환경마다 다른 값을 쓰고 절대 커밋하지 않는다.
# JWT 서명 키 — 환경마다 다른 값을 쓰고 절대 커밋하지 않는다. 운영은 32자 이상 필수.
# 생성 예: node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))"
# (개발에서 비워 두면 개발용 고정 키로 서명한다 — modules/auth/auth.config.ts)
JWT_SECRET=change-me-generate-a-long-random-string
JWT_ACCESS_EXPIRES_IN=1h
JWT_REFRESH_EXPIRES_IN=14d
# ==========================================
# 소셜 로그인 (앱이 SDK 로 받은 토큰을 서버가 제공자에게 확인한다)
#
# 비어 있는 제공자는 로그인을 거절한다(AUTH_001). 값은 각 개발자 콘솔에서 확인한다.
# ==========================================
# 카카오 앱 ID (숫자) — 토큰이 우리 앱에서 발급됐는지 확인한다
KAKAO_APP_ID=
# 네이버 클라이언트 ID — 설정해야 네이버 로그인이 켜진다
NAVER_CLIENT_ID=
# 구글 OAuth 클라이언트 ID (iOS·Android 등 여러 개면 콤마로 구분) — id_token 의 aud
GOOGLE_CLIENT_IDS=
# Apple 번들 ID·서비스 ID (콤마 구분) — id_token 의 aud
APPLE_CLIENT_IDS=
+1119 -958
View File
File diff suppressed because it is too large Load Diff
+9 -1
View File
@@ -32,6 +32,8 @@
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/schedule": "^6.1.1",
"@nestjs/swagger": "^11.2.6",
@@ -43,6 +45,8 @@
"class-validator": "^0.14.4",
"helmet": "^8.1.0",
"nest-winston": "^1.10.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pg": "^8.20.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
@@ -61,6 +65,7 @@
"@types/jest": "^30.0.0",
"@types/multer": "^2.2.0",
"@types/node": "^22.10.7",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.2",
"chokidar": "^5.0.0",
"eslint": "^9.18.0",
@@ -79,7 +84,10 @@
"typescript-eslint": "^8.20.0"
},
"overrides": {
"multer": "^2.4.0"
"multer": "^2.4.0",
"@nestjs/swagger": {
"js-yaml": "^5.4.2"
}
},
"jest": {
"moduleFileExtensions": [
+20 -2
View File
@@ -1,13 +1,20 @@
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
// 도메인 일정 모듈(modules/schedule)과 이름이 겹쳐 크론 모듈을 별칭으로 부른다
import { ScheduleModule as CronScheduleModule } from '@nestjs/schedule';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { CacheModule } from '@nestjs/cache-manager';
import { APP_GUARD } from '@nestjs/core';
import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard';
import { RequestIdMiddleware } from '@/common/middleware/request-id.middleware';
import { AuthModule } from '@/modules/auth/auth.module';
import { DiaryModule } from '@/modules/diary/diary.module';
import { HealthModule } from '@/modules/health/health.module';
import { PetModule } from '@/modules/pet/pet.module';
import { PetCardModule } from '@/modules/pet-card/pet-card.module';
import { ScheduleModule } from '@/modules/schedule/schedule.module';
import { UploadModule } from '@/modules/upload/upload.module';
import { UserModule } from '@/modules/user/user.module';
import { parsePositiveInt } from '@/shared/utils/env';
@@ -21,7 +28,7 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수
@Module({
imports: [
ScheduleModule.forRoot(),
CronScheduleModule.forRoot(),
ConfigModule.forRoot({
isGlobal: true,
envFilePath: `.env.${process.env.NODE_ENV || 'development'}`,
@@ -60,6 +67,11 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수
HealthModule,
UploadModule,
UserModule,
AuthModule,
PetModule,
ScheduleModule,
DiaryModule,
PetCardModule,
],
providers: [
ShutdownLogger,
@@ -67,6 +79,12 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
// 전역 인증 — 모든 라우트가 기본으로 로그인을 요구한다. 예외는 @Public() 으로 연다.
// (rate limit 이 먼저 돌아 토큰 없는 대량 요청도 한도에 걸린다)
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
],
})
export class AppModule implements NestModule {
@@ -1,6 +1,7 @@
import { Type, applyDecorators } from '@nestjs/common';
import { ApiExtraModels, ApiResponse, getSchemaPath } from '@nestjs/swagger';
import { PageInfoDto } from '@/common/dto/pagination.dto';
import { ErrorCode, getErrorDefinition } from '@/common/errors/error-codes';
/**
@@ -38,6 +39,38 @@ export function ApiStandardResponse<TModel extends Type<unknown>>(
);
}
/**
* 목록(페이지네이션) 응답을 문서화한다 — 실제 본문은 { success, data: { items, pageInfo } } 다.
* `ApiStandardResponse(Dto, { isArray: true })` 는 data 를 배열로 그려 실응답과 어긋난다.
*
* 사용 예:
* @ApiPaginatedResponse(PetResponseDto, '아이 목록')
*/
export function ApiPaginatedResponse<TModel extends Type<unknown>>(
model: TModel,
description = '목록 조회 성공',
) {
return applyDecorators(
ApiExtraModels(model, PageInfoDto),
ApiResponse({
status: 200,
description,
schema: {
properties: {
success: { type: 'boolean', example: true },
data: {
type: 'object',
properties: {
items: { type: 'array', items: { $ref: getSchemaPath(model) } },
pageInfo: { $ref: getSchemaPath(PageInfoDto) },
},
},
},
},
}),
);
}
/**
* 표준 실패 응답을 에러 코드와 함께 문서화한다.
* 상태 코드·메시지는 레지스트리에서 가져오므로 문서와 구현이 자동으로 일치한다.
@@ -0,0 +1,24 @@
import { ExecutionContext, createParamDecorator } from '@nestjs/common';
import type { Request } from 'express';
import { BusinessException } from '@/common/errors/business.exception';
/** 액세스 토큰에서 꺼낸 로그인 사용자 — JwtStrategy.validate 가 만든다 */
export interface AuthUser {
readonly userId: string;
}
/**
* 로그인한 사용자를 핸들러 인자로 받는다.
* findMine(@CurrentUser() user: AuthUser) { ... }
* @Public() 라우트에서는 쓰지 않는다 — 사용자가 없으면 AUTH_001 이다.
*/
export const CurrentUser = createParamDecorator(
(_data: unknown, context: ExecutionContext): AuthUser => {
const request = context
.switchToHttp()
.getRequest<Request & { user?: AuthUser }>();
if (!request.user) throw new BusinessException('AUTH_001');
return request.user;
},
);
@@ -0,0 +1,10 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
/**
* 로그인 없이 부를 수 있는 엔드포인트에 붙인다.
* 전역 JwtAuthGuard 가 기본으로 모든 라우트를 보호하므로, 예외만 이 데코레이터로 연다
* (로그인·토큰 갱신·헬스체크 등). 빠뜨려서 열리는 일은 없고, 빠뜨리면 닫힌다.
*/
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
@@ -0,0 +1,64 @@
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { BusinessException } from '@/common/errors/business.exception';
import { CursorValue, decodeCursor, keysetPage } from '@/shared/utils/cursor';
import { PaginatedResult, toPaginatedResult } from './pagination.dto';
/** 커서 정렬 키 — 앞의 키부터 오름차순으로 정렬하고, 마지막 키(보통 id)로 순서를 확정한다 */
export interface KeysetColumn<T> {
/** 쿼리 빌더 별칭을 붙인 컬럼 (예: `pet.sort_order`) */
readonly column: string;
/** 커서 값을 비교할 때 쓰는 DB 타입 (예: `integer`·`timestamptz`·`uuid`) */
readonly cast: string;
/** 행에서 커서에 담을 값 */
readonly read: (row: T) => CursorValue;
}
/**
* 커서 기반 목록 조회 — [query] 의 cursor 다음부터 limit 개를 읽는다.
* total 은 커서 조건 없이 센 전체 개수다. 형식이 틀린 커서는 VAL_001.
*/
export async function keysetPaginate<T extends ObjectLiteral>(
qb: SelectQueryBuilder<T>,
columns: readonly KeysetColumn<T>[],
query: { readonly limit: number; readonly cursor?: string },
): Promise<PaginatedResult<T>> {
const total = await qb.clone().getCount();
if (query.cursor !== undefined) {
const values = decodeCursor(query.cursor, columns.length);
if (!values) {
throw new BusinessException('VAL_001', {
context: { reason: 'invalid-cursor' },
});
}
const left = columns.map((c) => c.column).join(', ');
const right = columns.map((c, i) => `:cursor${i}::${c.cast}`).join(', ');
qb.andWhere(
`(${left}) > (${right})`,
Object.fromEntries(values.map((v, i) => [`cursor${i}`, v])),
);
}
columns.forEach((c, i) =>
i === 0 ? qb.orderBy(c.column, 'ASC') : qb.addOrderBy(c.column, 'ASC'),
);
const rows = await qb.limit(query.limit + 1).getMany();
const page = keysetPage(rows, query.limit, (row) =>
columns.map((c) => c.read(row)),
);
return toPaginatedResult(page.items, {
hasNext: page.hasNext,
nextCursor: page.nextCursor,
total,
});
}
/** 목록의 각 항목을 응답 DTO 로 바꾼다 (pageInfo 는 그대로) */
export function mapPage<T, R>(
page: PaginatedResult<T>,
map: (item: T) => R,
): PaginatedResult<R> {
return { items: page.items.map(map), pageInfo: page.pageInfo };
}
+15
View File
@@ -73,6 +73,21 @@ const DOMAIN_ERROR_CODES = {
status: HttpStatus.PAYLOAD_TOO_LARGE,
message: '파일 크기가 허용 범위를 초과했습니다.',
},
// 다이어리 — 책장에는 최대 6권 (추억 보관함의 아이만 담은 다이어리는 세지 않는다)
DIARY_001: {
status: HttpStatus.CONFLICT,
message: '다이어리는 최대 6권까지 만들 수 있어요.',
},
// 다이어리 — 한 사람이 한 다이어리에 하루 한 번 기록한다
DIARY_002: {
status: HttpStatus.CONFLICT,
message: '오늘 기록은 이미 남겼어요. 내일 다시 작성할 수 있어요.',
},
// 펫 명함 — 한 사람이 최대 10장
CARD_001: {
status: HttpStatus.CONFLICT,
message: '펫 명함은 최대 10장까지 만들 수 있어요.',
},
} as const satisfies Record<string, ErrorCodeDefinition>;
export const ERROR_CODES = {
@@ -0,0 +1,35 @@
import { ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { IS_PUBLIC_KEY } from '@/common/decorators/public.decorator';
import { BusinessException } from '@/common/errors/business.exception';
/**
* 전역 인증 가드 (app.module.ts 의 APP_GUARD).
* 모든 라우트는 기본으로 액세스 토큰이 필요하고, @Public() 이 붙은 라우트만 연다.
* 토큰이 없거나 틀렸거나 만료됐으면 원인을 가리지 않고 AUTH_001 로 응답한다 (.claude/rules/security.md).
*/
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private readonly reflector: Reflector) {
super();
}
override canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
return super.canActivate(context);
}
override handleRequest<TUser>(err: unknown, user: TUser | false): TUser {
if (err || !user) {
// passport 가 던진 예외(영문 메시지)를 그대로 내보내지 않고 에러 코드로 바꾼다
throw new BusinessException('AUTH_001');
}
return user;
}
}
+93
View File
@@ -0,0 +1,93 @@
import { applyDecorators } from '@nestjs/common';
import {
ArrayMaxSize,
ArrayMinSize,
IsArray,
IsString,
Matches,
MaxLength,
ValidateBy,
ValidationOptions,
buildMessage,
} from 'class-validator';
import { isValidIsoDate } from '@/shared/utils/iso-date';
/** `#RRGGBB` 색 — 앱(color_utils hexOf)이 쓰는 형식 */
export const HEX_COLOR_PATTERN = /^#[0-9A-Fa-f]{6}$/;
/** 시각 없는 날짜 `YYYY-MM-DD` — 달력에 있는 날짜만 */
export function IsIsoDate(options?: ValidationOptions): PropertyDecorator {
return ValidateBy(
{
name: 'isIsoDate',
validator: {
validate: (value: unknown) =>
typeof value === 'string' && isValidIsoDate(value),
defaultMessage: buildMessage(
() => '날짜는 YYYY-MM-DD 형식으로 입력해 주세요.',
options,
),
},
},
options,
);
}
/** `#RRGGBB` 색 */
export function IsHexColor(message = '색 값이 올바르지 않습니다.') {
return Matches(HEX_COLOR_PATTERN, { message });
}
/** 짧은 문자열 목록 (알레르기·질환처럼 칩으로 고르는 값) */
export function IsShortStringList(maxItems: number, maxLength: number) {
return applyDecorators(
IsArray({ message: '목록 형식이 올바르지 않습니다.' }),
ArrayMaxSize(maxItems, {
message: `최대 ${maxItems}개까지 입력할 수 있습니다.`,
}),
IsString({ each: true, message: '목록의 값은 문자로 입력해 주세요.' }),
MaxLength(maxLength, {
each: true,
message: `목록의 값은 ${maxLength}자 이하로 입력해 주세요.`,
}),
);
}
/** 일정·다이어리 대상의 "나(보호자 본인)" — 앱의 kSelfTargetId 와 같다 */
export const SELF_TARGET_ID = 'self';
const UUID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
/**
* 대상 목록 — 각 값은 "self" 이거나 아이 id(UUID). 기본은 1개 이상(일정), 다이어리는 0개도 된다.
* 형식만 본다 — 내 아이인지는 서비스가 PetService.assertOwnedTargets 로 확인한다.
*/
export function IsTargetIdList(maxItems: number, minItems = 1) {
return applyDecorators(
IsArray({ message: '대상을 골라 주세요.' }),
ArrayMinSize(minItems, { message: '대상을 골라 주세요.' }),
ArrayMaxSize(maxItems, { message: `대상은 최대 ${maxItems}개까지예요.` }),
ValidateBy({
name: 'isTargetId',
validator: {
validate: (value: unknown) =>
Array.isArray(value) &&
value.every(
(v) =>
typeof v === 'string' &&
(v === SELF_TARGET_ID || UUID_PATTERN.test(v)),
),
defaultMessage: () => '대상 정보가 올바르지 않습니다.',
},
}),
);
}
/** 하루 중 시각 `HH:mm` (00:00 ~ 23:59) */
export const HOUR_MINUTE_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/;
export function IsHourMinute(message = '시각은 HH:mm 형식으로 입력해 주세요.') {
return Matches(HOUR_MINUTE_PATTERN, { message });
}
@@ -0,0 +1,69 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddAuthUsersFiles1791705189542 implements MigrationInterface {
name = 'AddAuthUsersFiles1791705189542';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "files" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "owner_id" uuid NOT NULL, "stored_name" character varying(64) NOT NULL, "original_name" character varying(255) NOT NULL, "mime_type" character varying(100) NOT NULL, "size" integer NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "UQ_dea1609c1d56367529ace1e1530" UNIQUE ("stored_name"), CONSTRAINT "PK_6c16b9093a142e0e7613b04a3d9" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_4bc1db1f4f34ec9415acd88afd" ON "files" ("owner_id") `,
);
await queryRunner.query(
`CREATE TABLE "users" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "nickname" character varying(20), "photo_file_id" uuid, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_a3ffb1c0c8416b9fc6f907b7433" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE TABLE "social_accounts" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "user_id" uuid NOT NULL, "provider" character varying(10) NOT NULL, "subject" character varying(255) NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "UQ_820de75e7b1d6872a798f40850a" UNIQUE ("provider", "subject"), CONSTRAINT "PK_e9e58d2d8e9fafa20af914d9750" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_05a0f282d3bed93ca048a7e54d" ON "social_accounts" ("user_id") `,
);
await queryRunner.query(
`CREATE TABLE "refresh_tokens" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "user_id" uuid NOT NULL, "token_hash" character(64) NOT NULL, "expires_at" TIMESTAMP WITH TIME ZONE NOT NULL, "revoked_at" TIMESTAMP WITH TIME ZONE, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "UQ_a7838d2ba25be1342091b6695f1" UNIQUE ("token_hash"), CONSTRAINT "PK_7d8bee0204106019488c4c50ffa" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_3ddc983c5f7bcf132fd8732c3f" ON "refresh_tokens" ("user_id") `,
);
await queryRunner.query(
`ALTER TABLE "files" ADD CONSTRAINT "FK_4bc1db1f4f34ec9415acd88afdb" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "users" ADD CONSTRAINT "FK_e4313545e4345b359e66bdbebd8" FOREIGN KEY ("photo_file_id") REFERENCES "files"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "social_accounts" ADD CONSTRAINT "FK_05a0f282d3bed93ca048a7e54dd" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "refresh_tokens" ADD CONSTRAINT "FK_3ddc983c5f7bcf132fd8732c3f4" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "refresh_tokens" DROP CONSTRAINT "FK_3ddc983c5f7bcf132fd8732c3f4"`,
);
await queryRunner.query(
`ALTER TABLE "social_accounts" DROP CONSTRAINT "FK_05a0f282d3bed93ca048a7e54dd"`,
);
await queryRunner.query(
`ALTER TABLE "users" DROP CONSTRAINT "FK_e4313545e4345b359e66bdbebd8"`,
);
await queryRunner.query(
`ALTER TABLE "files" DROP CONSTRAINT "FK_4bc1db1f4f34ec9415acd88afdb"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_3ddc983c5f7bcf132fd8732c3f"`,
);
await queryRunner.query(`DROP TABLE "refresh_tokens"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_05a0f282d3bed93ca048a7e54d"`,
);
await queryRunner.query(`DROP TABLE "social_accounts"`);
await queryRunner.query(`DROP TABLE "users"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_4bc1db1f4f34ec9415acd88afd"`,
);
await queryRunner.query(`DROP TABLE "files"`);
}
}
@@ -0,0 +1,33 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddPets1791706303884 implements MigrationInterface {
name = 'AddPets1791706303884';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "pets" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "owner_id" uuid NOT NULL, "sort_order" integer NOT NULL, "tint" character varying(7) NOT NULL, "name" character varying(30) NOT NULL, "species" character varying(20) NOT NULL, "breed" character varying(60) NOT NULL DEFAULT '', "gender" character varying(6) NOT NULL, "neutered" boolean NOT NULL, "birth_date" date NOT NULL, "weight" double precision NOT NULL, "weight_unit" character varying(2) NOT NULL, "photo_file_id" uuid, "microchip" character varying(30), "blood_type" character varying(20), "vet" character varying(60), "memo" character varying(1000), "allergies" text array NOT NULL DEFAULT '{}', "conditions" text array NOT NULL DEFAULT '{}', "watch_conditions" text array NOT NULL DEFAULT '{}', "archive_from" date, "archive_to" date, "archived_at" TIMESTAMP WITH TIME ZONE, "archive_memo" character varying(1000), "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_d01e9e7b4ada753c826720bee8b" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_d6c565fded8031d4cdd54fe104" ON "pets" ("owner_id") `,
);
await queryRunner.query(
`ALTER TABLE "pets" ADD CONSTRAINT "FK_d6c565fded8031d4cdd54fe1043" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "pets" ADD CONSTRAINT "FK_816320a5aa9d3cafbdddcdde33c" FOREIGN KEY ("photo_file_id") REFERENCES "files"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "pets" DROP CONSTRAINT "FK_816320a5aa9d3cafbdddcdde33c"`,
);
await queryRunner.query(
`ALTER TABLE "pets" DROP CONSTRAINT "FK_d6c565fded8031d4cdd54fe1043"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_d6c565fded8031d4cdd54fe104"`,
);
await queryRunner.query(`DROP TABLE "pets"`);
}
}
@@ -0,0 +1,53 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddSchedule1791706872774 implements MigrationInterface {
name = 'AddSchedule1791706872774';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "schedule_events" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "owner_id" uuid NOT NULL, "date" date NOT NULL, "is_done" boolean NOT NULL DEFAULT false, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "title" character varying(50) NOT NULL, "kind" character varying(12) NOT NULL, "category" character varying(12) NOT NULL, "target_ids" text array NOT NULL, "start_time" character varying(5), "end_time" character varying(5), "place" character varying(60), "label_color" character varying(7), CONSTRAINT "PK_c14624cf0aa0f238ace86e789aa" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_3351bce510c509278ac3d0a5ad" ON "schedule_events" ("owner_id", "date") `,
);
await queryRunner.query(
`CREATE TABLE "repeat_rules" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "owner_id" uuid NOT NULL, "from_date" date NOT NULL, "mode" character varying(10) NOT NULL, "unit" character varying(5) NOT NULL, "n" smallint NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "title" character varying(50) NOT NULL, "kind" character varying(12) NOT NULL, "category" character varying(12) NOT NULL, "target_ids" text array NOT NULL, "start_time" character varying(5), "end_time" character varying(5), "place" character varying(60), "label_color" character varying(7), CONSTRAINT "PK_a77f6c9f8f34779343443201bbc" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_c3fbe53b49bb6e4c5fb15deb85" ON "repeat_rules" ("owner_id") `,
);
await queryRunner.query(
`CREATE TABLE "repeat_rule_completions" ("rule_id" uuid NOT NULL, "date" date NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_087b6885d4535901d3387d3f8a7" PRIMARY KEY ("rule_id", "date"))`,
);
await queryRunner.query(
`ALTER TABLE "schedule_events" ADD CONSTRAINT "FK_d5be69de4c90f931ccede796f1a" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "repeat_rules" ADD CONSTRAINT "FK_c3fbe53b49bb6e4c5fb15deb850" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "repeat_rule_completions" ADD CONSTRAINT "FK_e400c99c6640f922ca900709adc" FOREIGN KEY ("rule_id") REFERENCES "repeat_rules"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "repeat_rule_completions" DROP CONSTRAINT "FK_e400c99c6640f922ca900709adc"`,
);
await queryRunner.query(
`ALTER TABLE "repeat_rules" DROP CONSTRAINT "FK_c3fbe53b49bb6e4c5fb15deb850"`,
);
await queryRunner.query(
`ALTER TABLE "schedule_events" DROP CONSTRAINT "FK_d5be69de4c90f931ccede796f1a"`,
);
await queryRunner.query(`DROP TABLE "repeat_rule_completions"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_c3fbe53b49bb6e4c5fb15deb85"`,
);
await queryRunner.query(`DROP TABLE "repeat_rules"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_3351bce510c509278ac3d0a5ad"`,
);
await queryRunner.query(`DROP TABLE "schedule_events"`);
}
}
@@ -0,0 +1,83 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddDiaries1791707431165 implements MigrationInterface {
name = 'AddDiaries1791707431165';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "diaries" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "owner_id" uuid NOT NULL, "name" character varying(18) NOT NULL, "target_ids" text array NOT NULL DEFAULT '{}', "ribbon" character varying(7) NOT NULL, "tint" character varying(7) NOT NULL, "cover_file_id" uuid, "muted_notices" text array NOT NULL DEFAULT '{}', "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_ffd738e7d40dcfa59283dcaae87" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_66a03e13cfd64933272bd90721" ON "diaries" ("owner_id") `,
);
await queryRunner.query(
`CREATE TABLE "diary_posts" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "diary_id" uuid NOT NULL, "author_id" uuid NOT NULL, "date" date NOT NULL, "text" character varying(2000) NOT NULL, "mood" character varying(10), "photo_file_id" uuid, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "UQ_582c1c407ea8c8288009260222d" UNIQUE ("diary_id", "author_id", "date"), CONSTRAINT "PK_e5a9335997f1abec691b448fdd3" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_a9bbbf83bc3e7765878dec1f10" ON "diary_posts" ("diary_id") `,
);
await queryRunner.query(
`CREATE TABLE "diary_comments" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "post_id" uuid NOT NULL, "author_id" uuid NOT NULL, "text" character varying(50) NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_02bcbae3dcc56ec77d072c232d4" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_7a76737dfe5d0818b867696df6" ON "diary_comments" ("post_id") `,
);
await queryRunner.query(
`ALTER TABLE "diaries" ADD CONSTRAINT "FK_66a03e13cfd64933272bd90721a" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "diaries" ADD CONSTRAINT "FK_49e7cfa49fed5dd22a4e8c25dab" FOREIGN KEY ("cover_file_id") REFERENCES "files"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "diary_posts" ADD CONSTRAINT "FK_a9bbbf83bc3e7765878dec1f10e" FOREIGN KEY ("diary_id") REFERENCES "diaries"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "diary_posts" ADD CONSTRAINT "FK_c25aa8312dc21996eb00531bb68" FOREIGN KEY ("author_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "diary_posts" ADD CONSTRAINT "FK_07e1d0110b6bf6db265e9963882" FOREIGN KEY ("photo_file_id") REFERENCES "files"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "diary_comments" ADD CONSTRAINT "FK_7a76737dfe5d0818b867696df6b" FOREIGN KEY ("post_id") REFERENCES "diary_posts"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "diary_comments" ADD CONSTRAINT "FK_ac7b686ceee2547592e05072dca" FOREIGN KEY ("author_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "diary_comments" DROP CONSTRAINT "FK_ac7b686ceee2547592e05072dca"`,
);
await queryRunner.query(
`ALTER TABLE "diary_comments" DROP CONSTRAINT "FK_7a76737dfe5d0818b867696df6b"`,
);
await queryRunner.query(
`ALTER TABLE "diary_posts" DROP CONSTRAINT "FK_07e1d0110b6bf6db265e9963882"`,
);
await queryRunner.query(
`ALTER TABLE "diary_posts" DROP CONSTRAINT "FK_c25aa8312dc21996eb00531bb68"`,
);
await queryRunner.query(
`ALTER TABLE "diary_posts" DROP CONSTRAINT "FK_a9bbbf83bc3e7765878dec1f10e"`,
);
await queryRunner.query(
`ALTER TABLE "diaries" DROP CONSTRAINT "FK_49e7cfa49fed5dd22a4e8c25dab"`,
);
await queryRunner.query(
`ALTER TABLE "diaries" DROP CONSTRAINT "FK_66a03e13cfd64933272bd90721a"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_7a76737dfe5d0818b867696df6"`,
);
await queryRunner.query(`DROP TABLE "diary_comments"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_a9bbbf83bc3e7765878dec1f10"`,
);
await queryRunner.query(`DROP TABLE "diary_posts"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_66a03e13cfd64933272bd90721"`,
);
await queryRunner.query(`DROP TABLE "diaries"`);
}
}
@@ -0,0 +1,45 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddPetCards1791707889244 implements MigrationInterface {
name = 'AddPetCards1791707889244';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "pet_cards" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "owner_id" uuid NOT NULL, "pet_id" uuid NOT NULL, "code" character varying(8) NOT NULL, "tint" character varying(7) NOT NULL, "tint2" character varying(7) NOT NULL, "name_bg" character varying(7), "name_bg_asset" character varying(120), "name_bg_file_id" uuid, "text_light" boolean NOT NULL DEFAULT false, "scrim" boolean NOT NULL DEFAULT false, "scrim_tone" character varying(5) NOT NULL DEFAULT 'light', "is_representative" boolean NOT NULL DEFAULT false, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "UQ_b0cdb9b6d8843b8ccdd8189475e" UNIQUE ("code"), CONSTRAINT "PK_cb97f59edc0aab4f315acd5c1fa" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_71ffe780c984a78a864a07465f" ON "pet_cards" ("owner_id") `,
);
await queryRunner.query(
`CREATE UNIQUE INDEX "UQ_pet_cards_representative" ON "pet_cards" ("pet_id") WHERE "is_representative"`,
);
await queryRunner.query(
`ALTER TABLE "pet_cards" ADD CONSTRAINT "FK_71ffe780c984a78a864a07465f8" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "pet_cards" ADD CONSTRAINT "FK_5d2fc9ac2b34f34dfaca776fc7f" FOREIGN KEY ("pet_id") REFERENCES "pets"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "pet_cards" ADD CONSTRAINT "FK_672dc0c78f4a9ffde6239f1db86" FOREIGN KEY ("name_bg_file_id") REFERENCES "files"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "pet_cards" DROP CONSTRAINT "FK_672dc0c78f4a9ffde6239f1db86"`,
);
await queryRunner.query(
`ALTER TABLE "pet_cards" DROP CONSTRAINT "FK_5d2fc9ac2b34f34dfaca776fc7f"`,
);
await queryRunner.query(
`ALTER TABLE "pet_cards" DROP CONSTRAINT "FK_71ffe780c984a78a864a07465f8"`,
);
await queryRunner.query(
`DROP INDEX "public"."UQ_pet_cards_representative"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_71ffe780c984a78a864a07465f"`,
);
await queryRunner.query(`DROP TABLE "pet_cards"`);
}
}
@@ -0,0 +1,63 @@
import {
accessTokenTtlMs,
isDevLoginEnabled,
refreshTokenTtlMs,
resolveJwtSecret,
socialAuthConfig,
} from './auth.config';
describe('인증 설정', () => {
it('토큰 유효 시간은 env 값을, 없거나 틀리면 기본값(1시간·14일)을 쓴다', () => {
expect(accessTokenTtlMs({ JWT_ACCESS_EXPIRES_IN: '15m' })).toBe(900_000);
expect(accessTokenTtlMs({})).toBe(3_600_000);
expect(accessTokenTtlMs({ JWT_ACCESS_EXPIRES_IN: '' })).toBe(3_600_000);
expect(refreshTokenTtlMs({ JWT_REFRESH_EXPIRES_IN: '30d' })).toBe(
2_592_000_000,
);
expect(refreshTokenTtlMs({ JWT_REFRESH_EXPIRES_IN: '2주' })).toBe(
1_209_600_000,
);
});
it('JWT_SECRET 이 비어 있으면 개발용 키로 대신한다 (운영은 env 검증이 막는다)', () => {
expect(resolveJwtSecret({ JWT_SECRET: 'real-secret' })).toBe('real-secret');
expect(resolveJwtSecret({ JWT_SECRET: ' ' })).toMatch(/dev-only/);
});
it('개발용 로그인은 운영에서 항상 꺼진다', () => {
expect(isDevLoginEnabled({ NODE_ENV: 'development' })).toBe(true);
expect(isDevLoginEnabled({ NODE_ENV: 'test' })).toBe(true);
expect(
isDevLoginEnabled({ NODE_ENV: 'development', AUTH_DEV_LOGIN: 'false' }),
).toBe(false);
expect(
isDevLoginEnabled({ NODE_ENV: 'production', AUTH_DEV_LOGIN: 'true' }),
).toBe(false);
});
it('소셜 설정은 비어 있으면 끈 것으로 본다', () => {
expect(socialAuthConfig({})).toEqual({
kakaoAppId: null,
naverClientId: null,
googleClientIds: [],
appleClientIds: [],
});
});
it('카카오 앱 ID 는 숫자, 구글·Apple 은 콤마 목록으로 읽는다', () => {
expect(
socialAuthConfig({
KAKAO_APP_ID: '123456',
NAVER_CLIENT_ID: 'naver-client',
GOOGLE_CLIENT_IDS: 'ios.apps.googleusercontent.com, android.apps',
APPLE_CLIENT_IDS: 'net.wandergo.rup,',
}),
).toEqual({
kakaoAppId: 123456,
naverClientId: 'naver-client',
googleClientIds: ['ios.apps.googleusercontent.com', 'android.apps'],
appleClientIds: ['net.wandergo.rup'],
});
expect(socialAuthConfig({ KAKAO_APP_ID: 'abc' }).kakaoAppId).toBeNull();
});
});
+83
View File
@@ -0,0 +1,83 @@
import { parseDurationMs } from '@/shared/utils/duration';
/**
* 인증 설정 — backend/.env.{APP_ENV} 에서 읽는다 (루트에서 내려주지 않는 백엔드 고유 값).
* 값은 모듈을 불러올 때가 아니라 쓸 때 읽는다 (ConfigModule 이 env 파일을 읽은 뒤여야 한다).
* 형식 검증은 shared/config/env.validation.ts 가 기동 시점에 한다.
*/
type Env = Readonly<Record<string, string | undefined>>;
/** 액세스 토큰 기본 유효 시간 — 짧게 둔다 (.claude/rules/security.md) */
const DEFAULT_ACCESS_TTL_MS = 3_600_000; // 1시간
/** 리프레시 토큰 기본 유효 시간 */
const DEFAULT_REFRESH_TTL_MS = 1_209_600_000; // 14일
/**
* 개발·테스트 전용 서명 키 — JWT_SECRET 을 비워 둔 로컬·CI 에서만 쓴다.
* 운영은 env.validation 이 JWT_SECRET 을 필수로 요구하므로 이 값으로 기동할 수 없다.
*/
const DEV_ONLY_JWT_SECRET = 'rup-dev-only-jwt-secret-not-for-production';
function readEnv(env: Env, key: string): string | undefined {
const value = env[key]?.trim();
return value === '' ? undefined : value;
}
export function resolveJwtSecret(env: Env = process.env): string {
return readEnv(env, 'JWT_SECRET') ?? DEV_ONLY_JWT_SECRET;
}
export function accessTokenTtlMs(env: Env = process.env): number {
return parseDurationMs(env.JWT_ACCESS_EXPIRES_IN) ?? DEFAULT_ACCESS_TTL_MS;
}
export function refreshTokenTtlMs(env: Env = process.env): number {
return parseDurationMs(env.JWT_REFRESH_EXPIRES_IN) ?? DEFAULT_REFRESH_TTL_MS;
}
/**
* 개발용 로그인(소셜 앱 키 없이 기기 키로 가입) 허용 여부.
* 운영에서는 어떤 값을 주어도 꺼진다. 개발·테스트에서는 AUTH_DEV_LOGIN=false 로 끌 수 있다.
*/
export function isDevLoginEnabled(env: Env = process.env): boolean {
if (env.NODE_ENV === 'production') return false;
return readEnv(env, 'AUTH_DEV_LOGIN') !== 'false';
}
/** 콤마로 나눈 목록 (빈 칸 제거) */
function readList(env: Env, key: string): string[] {
return (readEnv(env, key) ?? '')
.split(',')
.map((v) => v.trim())
.filter((v) => v !== '');
}
/** 소셜 제공자별 앱 정보 — 비어 있으면 그 제공자 로그인은 거절한다 */
export interface SocialAuthConfig {
/** 카카오 앱 ID (숫자) — 토큰이 우리 앱에서 발급됐는지 확인한다 */
readonly kakaoAppId: number | null;
/** 네이버 로그인 사용 여부 (클라이언트 ID 가 있으면 켠다) */
readonly naverClientId: string | null;
/** 구글 OAuth 클라이언트 ID 들 (iOS·Android·웹) — id_token 의 aud */
readonly googleClientIds: readonly string[];
/** Apple 번들 ID·서비스 ID 들 — id_token 의 aud */
readonly appleClientIds: readonly string[];
}
export function socialAuthConfig(env: Env = process.env): SocialAuthConfig {
const kakao = Number(readEnv(env, 'KAKAO_APP_ID'));
return {
kakaoAppId: Number.isInteger(kakao) && kakao > 0 ? kakao : null,
naverClientId: readEnv(env, 'NAVER_CLIENT_ID') ?? null,
googleClientIds: readList(env, 'GOOGLE_CLIENT_IDS'),
appleClientIds: readList(env, 'APPLE_CLIENT_IDS'),
};
}
/** 액세스 토큰 페이로드의 종류 표시 — 다른 용도의 JWT 를 액세스 토큰으로 쓰지 못하게 한다 */
export const ACCESS_TOKEN_TYPE = 'access';
/** 액세스 토큰 서명 알고리즘 — 다른 알고리즘(none 등)으로 서명한 토큰은 받지 않는다 */
export const ACCESS_TOKEN_ALGORITHM = 'HS256';
@@ -0,0 +1,83 @@
import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
import { ApiNoContentResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import {
ApiErrorResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import { Public } from '@/common/decorators/public.decorator';
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
import { AuthService } from './auth.service';
import { AuthResultDto, AuthTokensDto } from './dto/auth-result.dto';
import { DevLoginDto, RefreshTokenDto, SocialLoginDto } from './dto/login.dto';
/**
* 로그인처럼 브루트포스 표적이 되는 엔드포인트는 전역 한도보다 엄격하게 막는다 (.claude/rules/security.md).
* 분당 10회 — 정상 사용(로그인 한 번, 갱신 몇 번)에는 걸리지 않는다.
*/
const AUTH_THROTTLE = { default: { ttl: 60_000, limit: 10 } };
// 인증 — 경로는 리소스 이름으로 둔다 (세션 만들기 = 로그인, 토큰 만들기 = 갱신, 토큰 폐기 = 로그아웃)
@ApiTags('Auth')
@Public()
@Throttle(AUTH_THROTTLE)
@ApiErrorResponse('REQ_001', '요청 한도 초과')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('sessions')
@ApiOperation({
summary: '소셜 로그인 (처음이면 가입)',
description:
'앱이 소셜 SDK 로 받은 토큰을 서버가 제공자에게 직접 확인한다. 실패 원인은 구분하지 않는다.',
})
@ApiStandardResponse(AuthResultDto, {
status: 201,
description: '로그인 성공',
})
@ApiErrorResponse('AUTH_001', '토큰 확인 실패')
@ApiErrorResponse('SYS_001', '소셜 제공자 장애')
login(@Body() dto: SocialLoginDto): Promise<AuthResultDto> {
return this.authService.socialLogin(dto);
}
@Post('dev-sessions')
@ApiOperation({
summary: '개발용 로그인 (운영에서는 열리지 않음)',
description:
'소셜 앱 키가 준비되기 전, 기기 키를 소셜 계정 대신 써서 가입·로그인한다.',
})
@ApiStandardResponse(AuthResultDto, {
status: 201,
description: '로그인 성공',
})
@ApiErrorResponse('RES_001', '운영 환경 — 엔드포인트 없음')
devLogin(@Body() dto: DevLoginDto): Promise<AuthResultDto> {
return this.authService.devLogin(dto);
}
@Post('tokens')
@ApiOperation({
summary: '토큰 갱신',
description:
'리프레시 토큰은 한 번만 쓸 수 있다. 새 한 쌍으로 바꿔 보관한다.',
})
@ApiStandardResponse(AuthTokensDto, { status: 201, description: '새 토큰' })
@ApiErrorResponse('AUTH_001', '만료·폐기·없는 토큰')
refresh(@Body() dto: RefreshTokenDto): Promise<AuthTokensDto> {
return this.authService.refresh(dto.refreshToken);
}
@Post('token-revocation')
@HttpCode(HttpStatus.NO_CONTENT)
@SkipTransform() // 204 — 본문이 없다
@ApiOperation({ summary: '로그아웃 (리프레시 토큰 폐기)' })
@ApiNoContentResponse({
description: '폐기 완료 (이미 폐기된 토큰이어도 성공)',
})
async logout(@Body() dto: RefreshTokenDto): Promise<void> {
await this.authService.logout(dto.refreshToken);
}
}
+38
View File
@@ -0,0 +1,38 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserModule } from '@/modules/user/user.module';
import { ACCESS_TOKEN_ALGORITHM, resolveJwtSecret } from './auth.config';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { RefreshToken } from './entities/refresh-token.entity';
import { JwtStrategy } from './jwt.strategy';
import { RefreshTokenCleanup } from './refresh-token.cleanup';
import { IdTokenVerifier } from './social/id-token.verifier';
import { SocialVerifierService } from './social/social-verifier.service';
@Module({
imports: [
TypeOrmModule.forFeature([RefreshToken]),
PassportModule,
// 서명 키는 쓸 때 읽는다 — ConfigModule 이 env 파일을 읽은 뒤여야 한다
JwtModule.registerAsync({
useFactory: () => ({
secret: resolveJwtSecret(),
signOptions: { algorithm: ACCESS_TOKEN_ALGORITHM },
}),
}),
UserModule,
],
controllers: [AuthController],
providers: [
AuthService,
JwtStrategy,
SocialVerifierService,
IdTokenVerifier,
RefreshTokenCleanup,
],
})
export class AuthModule {}
@@ -0,0 +1,218 @@
import { JwtService } from '@nestjs/jwt';
import { Repository } from 'typeorm';
import { BusinessException } from '@/common/errors/business.exception';
import { UserService } from '@/modules/user/user.service';
import { sha256Hex } from '@/shared/utils/hash';
import { AuthService } from './auth.service';
import { RefreshToken } from './entities/refresh-token.entity';
import { SocialVerifierService } from './social/social-verifier.service';
const USER_ID = '0b6f3c4e-2a1d-4f5e-8c9b-7a6d5e4f3c2b';
const SIGNED_IN = {
user: {
id: USER_ID,
nickname: '집사',
photoFileId: null,
provider: 'kakao' as const,
createdAt: '2026-10-11T00:00:00.000Z',
},
isNewUser: true,
};
function storedToken(overrides: Partial<RefreshToken> = {}): RefreshToken {
return {
id: 'rt-1',
userId: USER_ID,
tokenHash: sha256Hex('old-token'),
expiresAt: new Date(Date.now() + 60_000),
revokedAt: null,
createdAt: new Date(),
...overrides,
};
}
async function codeOf(promise: Promise<unknown>): Promise<string> {
try {
await promise;
} catch (error) {
if (error instanceof BusinessException) return error.code;
throw error;
}
throw new Error('예외가 나지 않았다');
}
describe('AuthService', () => {
const repo = {
findOneBy: jest.fn(),
update: jest.fn(),
save: jest.fn(),
create: jest.fn((v: Partial<RefreshToken>) => v),
};
const users = { findOrCreateBySocial: jest.fn() };
const verifier = { verify: jest.fn() };
const jwt = new JwtService({ secret: 'test-secret' });
const service = new AuthService(
jwt,
users as unknown as UserService,
verifier as unknown as SocialVerifierService,
repo as unknown as Repository<RefreshToken>,
);
const originalEnv = process.env;
beforeEach(() => {
jest.clearAllMocks();
process.env = { ...originalEnv, NODE_ENV: 'test' };
users.findOrCreateBySocial.mockResolvedValue(SIGNED_IN);
repo.save.mockImplementation((v: unknown) => Promise.resolve(v));
});
afterAll(() => {
process.env = originalEnv;
});
describe('로그인', () => {
it('개발용 로그인은 기기 키를 소셜 id 로 써서 가입시키고 토큰을 준다', async () => {
const result = await service.devLogin({
provider: 'kakao',
deviceKey: 'device-key-0000000001',
nickname: ' 집사 ',
});
expect(users.findOrCreateBySocial).toHaveBeenCalledWith({
provider: 'kakao',
subject: 'dev:device-key-0000000001',
nickname: '집사',
});
expect(result.user.id).toBe(USER_ID);
expect(result.isNewUser).toBe(true);
expect(
jwt.verify<{ sub: string; typ: string }>(result.accessToken),
).toMatchObject({ sub: USER_ID, typ: 'access' });
});
it('리프레시 토큰은 원문이 아니라 해시로 저장한다', async () => {
const result = await service.devLogin({
provider: 'kakao',
deviceKey: 'device-key-0000000001',
});
const [[saved]] = repo.save.mock.calls as [[Partial<RefreshToken>]];
expect(saved.tokenHash).toBe(sha256Hex(result.refreshToken));
expect(saved.tokenHash).not.toContain(result.refreshToken);
});
it('운영에서는 개발용 로그인이 없는 엔드포인트처럼 응답한다', async () => {
process.env.NODE_ENV = 'production';
await expect(
codeOf(
service.devLogin({ provider: 'kakao', deviceKey: 'device-key-01' }),
),
).resolves.toBe('RES_001');
expect(users.findOrCreateBySocial).not.toHaveBeenCalled();
});
it('소셜 로그인은 제공자가 이름을 주지 않을 때만 앱이 보낸 이름을 쓴다', async () => {
verifier.verify.mockResolvedValueOnce({
provider: 'apple',
subject: 'a-1',
nickname: null,
});
await service.socialLogin({
provider: 'apple',
token: 't',
nickname: '두부맘',
});
expect(users.findOrCreateBySocial).toHaveBeenLastCalledWith(
expect.objectContaining({ nickname: '두부맘' }),
);
verifier.verify.mockResolvedValueOnce({
provider: 'kakao',
subject: 'k-1',
nickname: '카카오이름',
});
await service.socialLogin({
provider: 'kakao',
token: 't',
nickname: '앱이름',
});
expect(users.findOrCreateBySocial).toHaveBeenLastCalledWith(
expect.objectContaining({ nickname: '카카오이름' }),
);
});
});
describe('토큰 갱신', () => {
it('유효한 토큰이면 폐기하고 새 한 쌍을 준다', async () => {
repo.findOneBy.mockResolvedValue(storedToken());
repo.update.mockResolvedValue({ affected: 1 });
const tokens = await service.refresh('old-token');
expect(repo.findOneBy).toHaveBeenCalledWith({
tokenHash: sha256Hex('old-token'),
});
expect(repo.update).toHaveBeenCalledWith(
expect.objectContaining({ id: 'rt-1' }),
expect.objectContaining({ revokedAt: expect.any(Date) as Date }),
);
expect(tokens.refreshToken).not.toBe('old-token');
expect(tokens.accessTokenExpiresIn).toBe(3600);
});
it('없는 토큰이면 AUTH_001', async () => {
repo.findOneBy.mockResolvedValue(null);
await expect(codeOf(service.refresh('nope'))).resolves.toBe('AUTH_001');
});
it('만료된 토큰이면 AUTH_001 이고 새 토큰을 만들지 않는다', async () => {
repo.findOneBy.mockResolvedValue(
storedToken({ expiresAt: new Date(Date.now() - 1) }),
);
await expect(codeOf(service.refresh('old-token'))).resolves.toBe(
'AUTH_001',
);
expect(repo.save).not.toHaveBeenCalled();
});
it('폐기된 토큰이 다시 오면 그 회원의 토큰을 모두 폐기한다 (탈취 대응)', async () => {
repo.findOneBy.mockResolvedValue(
storedToken({ revokedAt: new Date(Date.now() - 1000) }),
);
repo.update.mockResolvedValue({ affected: 2 });
await expect(codeOf(service.refresh('old-token'))).resolves.toBe(
'AUTH_001',
);
expect(repo.update).toHaveBeenCalledWith(
expect.objectContaining({ userId: USER_ID }),
expect.objectContaining({ revokedAt: expect.any(Date) as Date }),
);
expect(repo.save).not.toHaveBeenCalled();
});
it('같은 토큰으로 동시에 갱신하면 늦은 쪽은 AUTH_001', async () => {
repo.findOneBy.mockResolvedValue(storedToken());
repo.update.mockResolvedValue({ affected: 0 });
await expect(codeOf(service.refresh('old-token'))).resolves.toBe(
'AUTH_001',
);
expect(repo.save).not.toHaveBeenCalled();
});
});
it('로그아웃은 토큰 해시로 폐기한다', async () => {
repo.update.mockResolvedValue({ affected: 0 });
await expect(service.logout('old-token')).resolves.toBeUndefined();
expect(repo.update).toHaveBeenCalledWith(
expect.objectContaining({ tokenHash: sha256Hex('old-token') }),
expect.objectContaining({ revokedAt: expect.any(Date) as Date }),
);
});
});
+164
View File
@@ -0,0 +1,164 @@
import { Injectable, Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, Repository } from 'typeorm';
import { BusinessException } from '@/common/errors/business.exception';
import { VerifiedSocialAccount } from '@/modules/user/entities/social-account.entity';
import { SignedInUser, UserService } from '@/modules/user/user.service';
import { randomToken, sha256Hex } from '@/shared/utils/hash';
import {
ACCESS_TOKEN_TYPE,
accessTokenTtlMs,
isDevLoginEnabled,
refreshTokenTtlMs,
} from './auth.config';
import { AuthResultDto, AuthTokensDto } from './dto/auth-result.dto';
import { DevLoginDto, SocialLoginDto } from './dto/login.dto';
import { RefreshToken } from './entities/refresh-token.entity';
import { SocialVerifierService } from './social/social-verifier.service';
export interface AccessTokenPayload {
readonly sub: string;
readonly typ: typeof ACCESS_TOKEN_TYPE;
}
/** 리프레시 토큰 원문 길이 (바이트) */
const REFRESH_TOKEN_BYTES = 32;
/** 개발용 로그인의 소셜 id 접두어 — 실제 소셜 id 와 겹치지 않게 한다 */
const DEV_SUBJECT_PREFIX = 'dev:';
const MS_PER_SECOND = 1_000;
// 로그인·토큰 갱신·로그아웃
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
constructor(
private readonly jwt: JwtService,
private readonly users: UserService,
private readonly verifier: SocialVerifierService,
@InjectRepository(RefreshToken)
private readonly refreshTokens: Repository<RefreshToken>,
) {}
/** 소셜 로그인 — 제공자에게 토큰을 확인하고, 처음이면 가입시킨다 */
async socialLogin(dto: SocialLoginDto): Promise<AuthResultDto> {
const verified = await this.verifier.verify(dto.provider, dto.token);
return this.signIn({
...verified,
nickname: verified.nickname ?? dto.nickname?.trim() ?? null,
});
}
/**
* 개발용 로그인 — 기기 키를 소셜 계정 대신 쓴다. 운영에서는 없는 엔드포인트처럼 응답한다.
* 소셜 앱 키가 준비되면 앱은 socialLogin 으로 바꾼다.
*/
async devLogin(dto: DevLoginDto): Promise<AuthResultDto> {
if (!isDevLoginEnabled()) {
throw new BusinessException('RES_001', {
context: { reason: 'dev-login-disabled' },
});
}
return this.signIn({
provider: dto.provider,
subject: `${DEV_SUBJECT_PREFIX}${dto.deviceKey}`,
nickname: dto.nickname?.trim() || null,
});
}
/**
* 토큰 갱신 — 리프레시 토큰을 폐기하고 새 한 쌍을 준다(회전).
* 이미 폐기된 토큰이 다시 오면 탈취로 보고 그 회원의 리프레시 토큰을 모두 폐기한다.
*/
async refresh(refreshToken: string): Promise<AuthTokensDto> {
const stored = await this.refreshTokens.findOneBy({
tokenHash: sha256Hex(refreshToken),
});
if (!stored) {
throw new BusinessException('AUTH_001', {
context: { reason: 'unknown-refresh-token' },
});
}
if (stored.revokedAt !== null) {
await this.revokeAll(stored.userId);
this.logger.warn(
`폐기된 리프레시 토큰 재사용 — 회원 ${stored.userId} 의 토큰을 모두 폐기`,
);
throw new BusinessException('AUTH_001', {
context: { reason: 'refresh-token-reused', userId: stored.userId },
});
}
if (stored.expiresAt.getTime() <= Date.now()) {
throw new BusinessException('AUTH_001', {
context: { reason: 'refresh-token-expired', userId: stored.userId },
});
}
// 같은 토큰으로 동시에 갱신하면 한쪽만 성공해야 한다 — 아직 유효한 경우에만 폐기한다
const revoked = await this.refreshTokens.update(
{ id: stored.id, revokedAt: IsNull() },
{ revokedAt: new Date() },
);
if (!revoked.affected) {
throw new BusinessException('AUTH_001', {
context: { reason: 'refresh-token-raced', userId: stored.userId },
});
}
return this.issueTokens(stored.userId);
}
/** 로그아웃 — 이 기기의 리프레시 토큰을 폐기한다. 이미 없거나 폐기된 토큰이어도 성공으로 본다 */
async logout(refreshToken: string): Promise<void> {
await this.refreshTokens.update(
{ tokenHash: sha256Hex(refreshToken), revokedAt: IsNull() },
{ revokedAt: new Date() },
);
}
private async signIn(account: VerifiedSocialAccount): Promise<AuthResultDto> {
const signedIn: SignedInUser =
await this.users.findOrCreateBySocial(account);
const tokens = await this.issueTokens(signedIn.user.id);
return { ...tokens, ...signedIn };
}
private async issueTokens(userId: string): Promise<AuthTokensDto> {
const accessTtlMs = accessTokenTtlMs();
const payload: AccessTokenPayload = { sub: userId, typ: ACCESS_TOKEN_TYPE };
const accessToken = await this.jwt.signAsync(payload, {
expiresIn: Math.floor(accessTtlMs / MS_PER_SECOND),
});
const refreshToken = randomToken(REFRESH_TOKEN_BYTES);
const expiresAt = new Date(Date.now() + refreshTokenTtlMs());
await this.refreshTokens.save(
this.refreshTokens.create({
userId,
tokenHash: sha256Hex(refreshToken),
expiresAt,
revokedAt: null,
}),
);
return {
accessToken,
accessTokenExpiresIn: Math.floor(accessTtlMs / MS_PER_SECOND),
refreshToken,
refreshTokenExpiresAt: expiresAt.toISOString(),
};
}
private async revokeAll(userId: string): Promise<void> {
await this.refreshTokens.update(
{ userId, revokedAt: IsNull() },
{ revokedAt: new Date() },
);
}
}
@@ -0,0 +1,30 @@
import { ApiProperty } from '@nestjs/swagger';
import { UserResponseDto } from '@/modules/user/dto/user-response.dto';
/** 발급한 토큰 한 쌍 */
export class AuthTokensDto {
@ApiProperty({ description: '액세스 토큰 — Authorization: Bearer 로 보낸다' })
accessToken!: string;
@ApiProperty({ description: '액세스 토큰 유효 시간 (초)', example: 3600 })
accessTokenExpiresIn!: number;
@ApiProperty({
description:
'리프레시 토큰 — 한 번 쓰면 폐기되고 새 토큰이 온다. 앱은 secure storage 에 보관한다',
})
refreshToken!: string;
@ApiProperty({ description: '리프레시 토큰 만료 시각 (ISO 8601 UTC)' })
refreshTokenExpiresAt!: string;
}
/** 로그인 결과 */
export class AuthResultDto extends AuthTokensDto {
@ApiProperty({ description: '로그인한 회원', type: UserResponseDto })
user!: UserResponseDto;
@ApiProperty({ description: '이번에 처음 가입했는지', example: false })
isNewUser!: boolean;
}
+76
View File
@@ -0,0 +1,76 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsIn,
IsNotEmpty,
IsOptional,
IsString,
Matches,
MaxLength,
} from 'class-validator';
import { SOCIAL_PROVIDERS } from '@/modules/user/entities/social-account.entity';
import type { SocialProvider } from '@/modules/user/entities/social-account.entity';
import { NICKNAME_MAX_LENGTH } from '@/modules/user/entities/user.entity';
/** 소셜 토큰 최대 길이 — 제공자 토큰은 수백~2천 자 정도다. 과도한 입력만 막는다 */
const SOCIAL_TOKEN_MAX_LENGTH = 4096;
/** 개발용 로그인의 기기 키 — 앱이 처음 실행할 때 만든 무작위 값 */
const DEVICE_KEY_PATTERN = /^[A-Za-z0-9_-]{16,128}$/;
class LoginBaseDto {
@ApiProperty({
description: '소셜 로그인 제공자',
enum: SOCIAL_PROVIDERS,
example: 'kakao',
})
@IsIn(SOCIAL_PROVIDERS, { message: '지원하지 않는 로그인 방식입니다.' })
provider!: SocialProvider;
@ApiPropertyOptional({
description: `처음 가입할 때 쓸 이름 (제공자가 이름을 주지 않는 경우 — Apple 등). 최대 ${NICKNAME_MAX_LENGTH}자`,
example: '집사',
})
@IsOptional()
@IsString({ message: '이름은 문자로 입력해 주세요.' })
@MaxLength(NICKNAME_MAX_LENGTH, {
message: `이름은 ${NICKNAME_MAX_LENGTH}자 이하로 입력해 주세요.`,
})
nickname?: string;
}
/** 소셜 로그인 — 앱이 소셜 SDK 로 받은 토큰을 넘긴다 */
export class SocialLoginDto extends LoginBaseDto {
@ApiProperty({
description:
'카카오·네이버는 액세스 토큰, 구글·Apple 은 id_token. 서버가 제공자에게 직접 확인한다',
})
@IsString({ message: '로그인 정보가 올바르지 않습니다.' })
@IsNotEmpty({ message: '로그인 정보가 올바르지 않습니다.' })
@MaxLength(SOCIAL_TOKEN_MAX_LENGTH, {
message: '로그인 정보가 올바르지 않습니다.',
})
token!: string;
}
/**
* 개발용 로그인 — 소셜 앱 키가 준비되기 전, 기기 키를 소셜 계정 대신 쓴다.
* 운영에서는 엔드포인트가 열리지 않는다.
*/
export class DevLoginDto extends LoginBaseDto {
@ApiProperty({
description: '기기 키 (영문·숫자·-·_ 16~128자). 같은 키면 같은 회원이다',
example: 'f3b1c2d4e5a6978812345678',
})
@Matches(DEVICE_KEY_PATTERN, { message: '기기 정보가 올바르지 않습니다.' })
deviceKey!: string;
}
/** 토큰 갱신·로그아웃 — 리프레시 토큰을 넘긴다 */
export class RefreshTokenDto {
@ApiProperty({ description: '로그인 때 받은 리프레시 토큰' })
@IsString({ message: '로그인 정보가 올바르지 않습니다.' })
@IsNotEmpty({ message: '로그인 정보가 올바르지 않습니다.' })
@MaxLength(256, { message: '로그인 정보가 올바르지 않습니다.' })
refreshToken!: string;
}
@@ -0,0 +1,43 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { User } from '@/modules/user/entities/user.entity';
/**
* 리프레시 토큰 — 원문은 저장하지 않고 SHA-256 해시만 둔다 (DB 가 새어도 토큰으로 쓸 수 없다).
* 한 번 쓰면 폐기하고 새 토큰을 발급한다(회전). 폐기된 토큰이 다시 오면 탈취로 보고 그 회원의 토큰을 모두 폐기한다.
*/
@Entity('refresh_tokens')
export class RefreshToken {
@PrimaryGeneratedColumn('uuid')
id!: string;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user?: User;
@Index()
@Column({ type: 'uuid' })
userId!: string;
/** 토큰 원문의 SHA-256 (hex) */
@Column({ type: 'char', length: 64, unique: true })
tokenHash!: string;
@Column({ type: 'timestamptz' })
expiresAt!: Date;
/** 회전·로그아웃으로 폐기한 때 — 유효하면 null */
@Column({ type: 'timestamptz', nullable: true })
revokedAt!: Date | null;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
}
+38
View File
@@ -0,0 +1,38 @@
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { AuthUser } from '@/common/decorators/current-user.decorator';
import { BusinessException } from '@/common/errors/business.exception';
import { UserService } from '@/modules/user/user.service';
import {
ACCESS_TOKEN_ALGORITHM,
ACCESS_TOKEN_TYPE,
resolveJwtSecret,
} from './auth.config';
/** Authorization: Bearer 액세스 토큰을 확인해 요청에 로그인 사용자를 붙인다 */
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(private readonly users: UserService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: resolveJwtSecret(),
algorithms: [ACCESS_TOKEN_ALGORITHM],
ignoreExpiration: false,
});
}
async validate(payload: { sub?: unknown; typ?: unknown }): Promise<AuthUser> {
if (typeof payload.sub !== 'string' || payload.typ !== ACCESS_TOKEN_TYPE) {
throw new BusinessException('AUTH_001');
}
// 탈퇴한 회원의 토큰은 만료 전이라도 거절한다
if (!(await this.users.exists(payload.sub))) {
throw new BusinessException('AUTH_001', {
context: { reason: 'user-gone', userId: payload.sub },
});
}
return { userId: payload.sub };
}
}
@@ -0,0 +1,30 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import { LessThan, Repository } from 'typeorm';
import { RefreshToken } from './entities/refresh-token.entity';
/**
* 만료된 리프레시 토큰을 매일 지운다.
* 폐기된 토큰은 만료 전까지 남겨 둔다 — 재사용(탈취)을 알아보려면 기록이 있어야 한다.
*/
@Injectable()
export class RefreshTokenCleanup {
private readonly logger = new Logger(RefreshTokenCleanup.name);
constructor(
@InjectRepository(RefreshToken)
private readonly refreshTokens: Repository<RefreshToken>,
) {}
@Cron(CronExpression.EVERY_DAY_AT_4AM)
async removeExpired(): Promise<void> {
const result = await this.refreshTokens.delete({
expiresAt: LessThan(new Date()),
});
if (result.affected) {
this.logger.log(`만료된 리프레시 토큰 ${result.affected}개 정리`);
}
}
}
@@ -0,0 +1,130 @@
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { JsonWebKey, createPublicKey } from 'crypto';
import { BusinessException } from '@/common/errors/business.exception';
import { fetchProviderJson, pick } from './social-http';
/** 공개키 목록을 다시 받아 오는 주기 — 제공자는 키를 주기적으로 바꾼다 */
const JWKS_CACHE_TTL_MS = 3_600_000; // 1시간
/** 제공자 id_token 의 서명 알고리즘 (구글·Apple 모두 RS256) */
const ID_TOKEN_ALGORITHM = 'RS256';
export interface IdTokenRules {
/** 제공자의 공개키 목록 주소 (JWKS) */
readonly jwksUrl: string;
/** 발급자(iss) 로 허용하는 값 */
readonly issuers: readonly string[];
/** 수신자(aud) 로 허용하는 값 — 우리 앱의 클라이언트 ID */
readonly audiences: readonly string[];
}
interface CachedKeys {
readonly keys: ReadonlyMap<string, JsonWebKey>;
readonly fetchedAt: number;
}
/** JWT 헤더에서 서명 키 id(kid)와 알고리즘을 꺼낸다 — 형식이 틀리면 null */
export function readJwtHeader(
token: string,
): { kid: string; alg: string } | null {
const [encoded] = token.split('.');
if (!encoded) return null;
try {
const header: unknown = JSON.parse(
Buffer.from(encoded, 'base64url').toString('utf8'),
);
const kid = pick(header, 'kid');
const alg = pick(header, 'alg');
return typeof kid === 'string' && typeof alg === 'string'
? { kid, alg }
: null;
} catch {
return null;
}
}
/**
* 구글·Apple 의 id_token 을 확인한다 — 서명(JWKS)·발급자·수신자·만료.
* 확인에 실패하면 원인을 가리지 않고 AUTH_001 이다.
*/
@Injectable()
export class IdTokenVerifier {
private readonly cache = new Map<string, CachedKeys>();
constructor(private readonly jwt: JwtService) {}
async verify(
token: string,
rules: IdTokenRules,
): Promise<Record<string, unknown>> {
const header = readJwtHeader(token);
if (!header || header.alg !== ID_TOKEN_ALGORITHM) {
throw new BusinessException('AUTH_001', {
context: { reason: 'invalid-id-token-header' },
});
}
const jwk = await this.findKey(rules.jwksUrl, header.kid);
const publicKey = createPublicKey({ key: jwk, format: 'jwk' })
.export({ type: 'spki', format: 'pem' })
.toString();
try {
return await this.jwt.verifyAsync<Record<string, unknown>>(token, {
publicKey,
algorithms: [ID_TOKEN_ALGORITHM],
// 호출한 쪽이 비어 있지 않은 목록만 넘긴다 (설정이 비면 그 전에 거절한다)
issuer: [...rules.issuers] as [string, ...string[]],
audience: [...rules.audiences] as [string, ...string[]],
});
} catch (error) {
throw new BusinessException('AUTH_001', {
context: {
reason: 'id-token-rejected',
detail: error instanceof Error ? error.message : String(error),
},
});
}
}
/** kid 에 맞는 공개키 — 없으면 목록을 한 번 새로 받아 본다 (키가 막 바뀐 경우) */
private async findKey(jwksUrl: string, kid: string): Promise<JsonWebKey> {
const cached = this.cache.get(jwksUrl);
const fresh =
cached !== undefined && Date.now() - cached.fetchedAt < JWKS_CACHE_TTL_MS;
const key = fresh ? cached.keys.get(kid) : undefined;
if (key) return key;
const reloaded = await this.loadKeys(jwksUrl);
const found = reloaded.get(kid);
if (!found) {
throw new BusinessException('AUTH_001', {
context: { reason: 'unknown-signing-key', kid },
});
}
return found;
}
private async loadKeys(
jwksUrl: string,
): Promise<ReadonlyMap<string, JsonWebKey>> {
const body = await fetchProviderJson(jwksUrl);
const list = pick(body, 'keys');
const keys = new Map<string, JsonWebKey>();
if (Array.isArray(list)) {
for (const item of list as unknown[]) {
const kid = pick(item, 'kid');
if (typeof kid === 'string' && pick(item, 'kty') === 'RSA') {
keys.set(kid, item as JsonWebKey);
}
}
}
this.cache.set(jwksUrl, { keys, fetchedAt: Date.now() });
return keys;
}
}
@@ -0,0 +1,74 @@
import { BusinessException } from '@/common/errors/business.exception';
/** 소셜 제공자 API 응답을 기다리는 최대 시간 */
const SOCIAL_HTTP_TIMEOUT_MS = 5_000;
/** 4xx — 토큰이 틀렸거나 만료됐다 (사용자 책임). 5xx·네트워크 오류는 제공자 장애다 */
const CLIENT_ERROR_MIN = 400;
const SERVER_ERROR_MIN = 500;
/**
* 소셜 제공자 API 를 부르고 JSON 본문을 돌려준다.
* - 토큰 거절(4xx) → AUTH_001 (원인을 구분해 알리지 않는다 — .claude/rules/security.md)
* - 제공자 장애(5xx·시간 초과·네트워크) → SYS_001 (사용자에게 "다시 로그인" 이 아니라 "잠시 후" 로 안내)
*/
export async function fetchProviderJson(
url: string,
init: { headers?: Record<string, string> } = {},
): Promise<unknown> {
let response: Response;
try {
response = await fetch(url, {
headers: init.headers,
signal: AbortSignal.timeout(SOCIAL_HTTP_TIMEOUT_MS),
});
} catch (error) {
throw new BusinessException('SYS_001', {
context: {
url,
reason: error instanceof Error ? error.message : String(error),
},
});
}
if (response.status >= SERVER_ERROR_MIN) {
throw new BusinessException('SYS_001', {
context: { url, status: response.status },
});
}
if (response.status >= CLIENT_ERROR_MIN) {
throw new BusinessException('AUTH_001', {
context: { url, status: response.status },
});
}
try {
return (await response.json()) as unknown;
} catch {
throw new BusinessException('SYS_001', {
context: { url, reason: 'invalid-json' },
});
}
}
/** 응답 객체에서 경로를 따라 값을 꺼낸다 — 형식이 다르면 undefined */
export function pick(source: unknown, ...path: string[]): unknown {
let current: unknown = source;
for (const key of path) {
if (typeof current !== 'object' || current === null) return undefined;
current = (current as Record<string, unknown>)[key];
}
return current;
}
/** 문자열·숫자 id 를 문자열로 — 카카오는 숫자, 네이버는 문자열로 준다 */
export function idString(value: unknown): string | null {
if (typeof value === 'string' && value !== '') return value;
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
return null;
}
/** 비어 있지 않은 문자열만 */
export function nonEmptyString(value: unknown): string | null {
return typeof value === 'string' && value.trim() !== '' ? value.trim() : null;
}
@@ -0,0 +1,194 @@
import { BusinessException } from '@/common/errors/business.exception';
import { IdTokenVerifier } from './id-token.verifier';
import { readJwtHeader } from './id-token.verifier';
import { SocialVerifierService } from './social-verifier.service';
/** fetch 를 정해진 응답으로 바꿔 끼운다 — 외부 네트워크에 의존하지 않는다 */
function mockFetch(
responses: Record<string, { status?: number; body: unknown }>,
): jest.SpyInstance {
return jest.spyOn(global, 'fetch').mockImplementation((input) => {
const url =
typeof input === 'string'
? input
: input instanceof URL
? input.href
: input.url;
const found = responses[url];
if (!found) return Promise.reject(new Error(`unexpected ${url}`));
return Promise.resolve(
new Response(JSON.stringify(found.body), { status: found.status ?? 200 }),
);
});
}
async function codeOf(promise: Promise<unknown>): Promise<string> {
try {
await promise;
} catch (error) {
if (error instanceof BusinessException) return error.code;
throw error;
}
throw new Error('예외가 나지 않았다');
}
describe('SocialVerifierService', () => {
const idTokens = { verify: jest.fn() };
const service = new SocialVerifierService(
idTokens as unknown as IdTokenVerifier,
);
const originalEnv = process.env;
beforeEach(() => {
process.env = { ...originalEnv };
idTokens.verify.mockReset();
});
afterEach(() => {
process.env = originalEnv;
jest.restoreAllMocks();
});
describe('카카오', () => {
beforeEach(() => {
process.env.KAKAO_APP_ID = '1001';
});
it('우리 앱의 토큰이면 카카오 id 와 닉네임을 돌려준다', async () => {
mockFetch({
'https://kapi.kakao.com/v1/user/access_token_info': {
body: { id: 42, app_id: 1001 },
},
'https://kapi.kakao.com/v2/user/me': {
body: { id: 42, kakao_account: { profile: { nickname: '콩이맘' } } },
},
});
await expect(service.verify('kakao', 'token')).resolves.toEqual({
provider: 'kakao',
subject: '42',
nickname: '콩이맘',
});
});
it('다른 앱에서 발급된 토큰이면 AUTH_001', async () => {
mockFetch({
'https://kapi.kakao.com/v1/user/access_token_info': {
body: { id: 42, app_id: 9999 },
},
});
await expect(codeOf(service.verify('kakao', 'token'))).resolves.toBe(
'AUTH_001',
);
});
it('카카오가 토큰을 거절하면(401) AUTH_001', async () => {
mockFetch({
'https://kapi.kakao.com/v1/user/access_token_info': {
status: 401,
body: { code: -401 },
},
});
await expect(codeOf(service.verify('kakao', 'token'))).resolves.toBe(
'AUTH_001',
);
});
it('카카오 장애(5xx)는 로그인 실패가 아니라 SYS_001', async () => {
mockFetch({
'https://kapi.kakao.com/v1/user/access_token_info': {
status: 503,
body: {},
},
});
await expect(codeOf(service.verify('kakao', 'token'))).resolves.toBe(
'SYS_001',
);
});
it('앱 ID 가 설정되지 않았으면 제공자를 부르지 않고 AUTH_001', async () => {
delete process.env.KAKAO_APP_ID;
const fetchSpy = mockFetch({});
await expect(codeOf(service.verify('kakao', 'token'))).resolves.toBe(
'AUTH_001',
);
expect(fetchSpy).not.toHaveBeenCalled();
});
});
describe('네이버', () => {
it('성공 응답이면 네이버 id 와 닉네임을 돌려준다', async () => {
process.env.NAVER_CLIENT_ID = 'client';
mockFetch({
'https://openapi.naver.com/v1/nid/me': {
body: {
resultcode: '00',
response: { id: 'naver-abc', nickname: '두부' },
},
},
});
await expect(service.verify('naver', 'token')).resolves.toEqual({
provider: 'naver',
subject: 'naver-abc',
nickname: '두부',
});
});
});
describe('구글·Apple', () => {
it('구글 id_token 의 sub 와 name 을 쓴다', async () => {
process.env.GOOGLE_CLIENT_IDS = 'client-a,client-b';
idTokens.verify.mockResolvedValue({ sub: 'g-1', name: '집사' });
await expect(service.verify('google', 'id-token')).resolves.toEqual({
provider: 'google',
subject: 'g-1',
nickname: '집사',
});
expect(idTokens.verify).toHaveBeenCalledWith(
'id-token',
expect.objectContaining({ audiences: ['client-a', 'client-b'] }),
);
});
it('Apple 은 이름을 주지 않는다', async () => {
process.env.APPLE_CLIENT_IDS = 'net.wandergo.rup';
idTokens.verify.mockResolvedValue({ sub: 'apple-1' });
await expect(service.verify('apple', 'id-token')).resolves.toEqual({
provider: 'apple',
subject: 'apple-1',
nickname: null,
});
});
it('클라이언트 ID 가 없으면 id_token 을 확인하지 않고 AUTH_001', async () => {
await expect(codeOf(service.verify('google', 'id-token'))).resolves.toBe(
'AUTH_001',
);
expect(idTokens.verify).not.toHaveBeenCalled();
});
});
});
describe('readJwtHeader', () => {
const encode = (value: unknown) =>
Buffer.from(JSON.stringify(value)).toString('base64url');
it('kid 와 alg 를 꺼낸다', () => {
expect(readJwtHeader(`${encode({ kid: 'k1', alg: 'RS256' })}.x.y`)).toEqual(
{ kid: 'k1', alg: 'RS256' },
);
});
it('형식이 틀리면 null', () => {
expect(readJwtHeader('')).toBeNull();
expect(readJwtHeader('not-json.x.y')).toBeNull();
expect(readJwtHeader(`${encode({ alg: 'RS256' })}.x.y`)).toBeNull();
});
});
@@ -0,0 +1,167 @@
import { Injectable } from '@nestjs/common';
import { BusinessException } from '@/common/errors/business.exception';
import {
SocialProvider,
VerifiedSocialAccount,
} from '@/modules/user/entities/social-account.entity';
import { SocialAuthConfig, socialAuthConfig } from '../auth.config';
import { IdTokenVerifier } from './id-token.verifier';
import {
fetchProviderJson,
idString,
nonEmptyString,
pick,
} from './social-http';
/** 제공자 API 주소 */
const KAKAO_TOKEN_INFO_URL = 'https://kapi.kakao.com/v1/user/access_token_info';
const KAKAO_ME_URL = 'https://kapi.kakao.com/v2/user/me';
const NAVER_ME_URL = 'https://openapi.naver.com/v1/nid/me';
const NAVER_SUCCESS_CODE = '00';
const GOOGLE_JWKS_URL = 'https://www.googleapis.com/oauth2/v3/certs';
const GOOGLE_ISSUERS = ['accounts.google.com', 'https://accounts.google.com'];
const APPLE_JWKS_URL = 'https://appleid.apple.com/auth/keys';
const APPLE_ISSUERS = ['https://appleid.apple.com'];
/**
* 앱이 소셜 SDK 로 받은 토큰을 제공자에게 직접 확인한다. 앱이 보낸 사용자 id 는 믿지 않는다.
* - 카카오·네이버: 액세스 토큰으로 사용자 정보 API 를 부른다
* - 구글·Apple: id_token 의 서명·발급자·수신자를 확인한다
* 그 제공자의 앱 정보가 설정되지 않았으면 로그인을 거절한다 (AUTH_001).
*/
@Injectable()
export class SocialVerifierService {
constructor(private readonly idTokens: IdTokenVerifier) {}
verify(
provider: SocialProvider,
token: string,
): Promise<VerifiedSocialAccount> {
const config = socialAuthConfig();
switch (provider) {
case 'kakao':
return this.verifyKakao(token, config);
case 'naver':
return this.verifyNaver(token, config);
case 'google':
return this.verifyGoogle(token, config);
case 'apple':
return this.verifyApple(token, config);
}
}
private async verifyKakao(
token: string,
config: SocialAuthConfig,
): Promise<VerifiedSocialAccount> {
if (config.kakaoAppId === null)
throw notConfigured('kakao', 'KAKAO_APP_ID');
const headers = { Authorization: `Bearer ${token}` };
// 다른 앱에서 발급된 토큰으로 로그인하는 것을 막는다 — 토큰의 앱 ID 가 우리 앱이어야 한다
const info = await fetchProviderJson(KAKAO_TOKEN_INFO_URL, { headers });
if (pick(info, 'app_id') !== config.kakaoAppId) {
throw rejected('kakao', 'app-id-mismatch');
}
const me = await fetchProviderJson(KAKAO_ME_URL, { headers });
const subject = idString(pick(me, 'id'));
if (!subject) throw rejected('kakao', 'missing-id');
return {
provider: 'kakao',
subject,
nickname:
nonEmptyString(pick(me, 'kakao_account', 'profile', 'nickname')) ??
nonEmptyString(pick(me, 'properties', 'nickname')),
};
}
/**
* ⚠️ 네이버는 액세스 토큰이 어느 앱에서 발급됐는지 확인하는 API 가 없다.
* 클라이언트 ID 를 설정해야 켜지게 해 두었고, 더 엄격히 하려면 인가 코드를 서버가 교환하는 방식으로 바꾼다.
*/
private async verifyNaver(
token: string,
config: SocialAuthConfig,
): Promise<VerifiedSocialAccount> {
if (config.naverClientId === null) {
throw notConfigured('naver', 'NAVER_CLIENT_ID');
}
const me = await fetchProviderJson(NAVER_ME_URL, {
headers: { Authorization: `Bearer ${token}` },
});
if (pick(me, 'resultcode') !== NAVER_SUCCESS_CODE) {
throw rejected('naver', 'result-code');
}
const subject = idString(pick(me, 'response', 'id'));
if (!subject) throw rejected('naver', 'missing-id');
return {
provider: 'naver',
subject,
nickname:
nonEmptyString(pick(me, 'response', 'nickname')) ??
nonEmptyString(pick(me, 'response', 'name')),
};
}
private async verifyGoogle(
token: string,
config: SocialAuthConfig,
): Promise<VerifiedSocialAccount> {
if (config.googleClientIds.length === 0) {
throw notConfigured('google', 'GOOGLE_CLIENT_IDS');
}
const claims = await this.idTokens.verify(token, {
jwksUrl: GOOGLE_JWKS_URL,
issuers: GOOGLE_ISSUERS,
audiences: config.googleClientIds,
});
const subject = idString(claims.sub);
if (!subject) throw rejected('google', 'missing-sub');
return {
provider: 'google',
subject,
nickname: nonEmptyString(claims.name),
};
}
/** Apple 은 id_token 에 이름을 싣지 않는다 — 앱이 첫 로그인 때 받은 이름을 따로 보낸다 */
private async verifyApple(
token: string,
config: SocialAuthConfig,
): Promise<VerifiedSocialAccount> {
if (config.appleClientIds.length === 0) {
throw notConfigured('apple', 'APPLE_CLIENT_IDS');
}
const claims = await this.idTokens.verify(token, {
jwksUrl: APPLE_JWKS_URL,
issuers: APPLE_ISSUERS,
audiences: config.appleClientIds,
});
const subject = idString(claims.sub);
if (!subject) throw rejected('apple', 'missing-sub');
return { provider: 'apple', subject, nickname: null };
}
}
function rejected(provider: SocialProvider, reason: string): BusinessException {
return new BusinessException('AUTH_001', { context: { provider, reason } });
}
/** 설정 누락은 운영자가 고칠 일이다 — 로그에 어떤 값이 빠졌는지 남긴다 */
function notConfigured(
provider: SocialProvider,
envKey: string,
): BusinessException {
return new BusinessException('AUTH_001', {
context: { provider, reason: `not-configured:${envKey}` },
});
}
@@ -0,0 +1,120 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiNoContentResponse,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import {
ApiErrorResponse,
ApiPaginatedResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import { CurrentUser } from '@/common/decorators/current-user.decorator';
import type { AuthUser } from '@/common/decorators/current-user.decorator';
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
import {
CursorPaginationQueryDto,
PaginatedResult,
} from '@/common/dto/pagination.dto';
import { DiaryService } from './diary.service';
import {
CreateDiaryDto,
CreatePostDto,
DiaryPostResponseDto,
DiaryResponseDto,
UpdateDiaryDto,
} from './dto/diary.dto';
// 다이어리 — 라우팅과 입출력만 담당, 비즈니스 로직은 모두 service 위임
@ApiTags('Diary')
@ApiBearerAuth()
@ApiErrorResponse('AUTH_001', '로그인 필요')
@Controller('diaries')
export class DiariesController {
constructor(private readonly diary: DiaryService) {}
@Get()
@ApiOperation({ summary: '내 다이어리 목록 (만든 순, 커서 페이지네이션)' })
@ApiPaginatedResponse(DiaryResponseDto, '다이어리 목록')
@ApiErrorResponse('VAL_001', '페이지네이션 파라미터 오류')
list(
@CurrentUser() user: AuthUser,
@Query() query: CursorPaginationQueryDto,
): Promise<PaginatedResult<DiaryResponseDto>> {
return this.diary.listDiaries(user.userId, query);
}
@Post()
@ApiOperation({ summary: '새 다이어리' })
@ApiStandardResponse(DiaryResponseDto, {
status: 201,
description: '만든 다이어리',
})
@ApiErrorResponse('DIARY_001', '책장이 가득 참 (최대 6권)')
@ApiErrorResponse('VAL_001', '입력값 검증 실패 · 내 아이가 아닌 대상')
create(
@CurrentUser() user: AuthUser,
@Body() dto: CreateDiaryDto,
): Promise<DiaryResponseDto> {
return this.diary.createDiary(user.userId, dto);
}
@Patch(':id')
@ApiOperation({
summary: '다이어리 수정 (이름·대상·리본·표지·알림, 보낸 값만)',
})
@ApiStandardResponse(DiaryResponseDto, { description: '수정한 다이어리' })
@ApiErrorResponse('RES_001', '다이어리를 찾을 수 없음')
@ApiErrorResponse('VAL_001', '입력값 검증 실패')
update(
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateDiaryDto,
): Promise<DiaryResponseDto> {
return this.diary.updateDiary(user.userId, id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@SkipTransform() // 204 — 본문이 없다
@ApiOperation({ summary: '다이어리 삭제 (기록·댓글 함께)' })
@ApiNoContentResponse({ description: '삭제 완료' })
@ApiErrorResponse('RES_001', '다이어리를 찾을 수 없음')
async remove(
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
): Promise<void> {
await this.diary.deleteDiary(user.userId, id);
}
@Post(':id/posts')
@ApiOperation({ summary: '기록 남기기 (하루 한 번)' })
@ApiStandardResponse(DiaryPostResponseDto, {
status: 201,
description: '남긴 기록',
})
@ApiErrorResponse('DIARY_002', '오늘 이미 기록함')
@ApiErrorResponse('RES_001', '다이어리를 찾을 수 없음')
@ApiErrorResponse('VAL_001', '입력값 검증 실패')
createPost(
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: CreatePostDto,
): Promise<DiaryPostResponseDto> {
return this.diary.createPost(user.userId, id, dto);
}
}
@@ -0,0 +1,82 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Query,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiNoContentResponse,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import {
ApiErrorResponse,
ApiPaginatedResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import { CurrentUser } from '@/common/decorators/current-user.decorator';
import type { AuthUser } from '@/common/decorators/current-user.decorator';
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
import {
CursorPaginationQueryDto,
PaginatedResult,
} from '@/common/dto/pagination.dto';
import { DiaryService } from './diary.service';
import { CommentDto, DiaryCommentResponseDto } from './dto/diary.dto';
// 다이어리 댓글 — 라우팅과 입출력만 담당, 비즈니스 로직은 모두 service 위임
@ApiTags('Diary')
@ApiBearerAuth()
@ApiErrorResponse('AUTH_001', '로그인 필요')
@Controller('diary-comments')
export class DiaryCommentsController {
constructor(private readonly diary: DiaryService) {}
@Get()
@ApiOperation({
summary: '내 다이어리들의 댓글 목록 (쓴 순, 커서 페이지네이션)',
})
@ApiPaginatedResponse(DiaryCommentResponseDto, '댓글 목록')
@ApiErrorResponse('VAL_001', '페이지네이션 파라미터 오류')
list(
@CurrentUser() user: AuthUser,
@Query() query: CursorPaginationQueryDto,
): Promise<PaginatedResult<DiaryCommentResponseDto>> {
return this.diary.listComments(user.userId, query);
}
@Patch(':id')
@ApiOperation({ summary: '댓글 고치기 (내가 쓴 댓글만)' })
@ApiStandardResponse(DiaryCommentResponseDto, { description: '고친 댓글' })
@ApiErrorResponse('RES_001', '댓글을 찾을 수 없음')
@ApiErrorResponse('AUTH_003', '내가 쓴 댓글이 아님')
update(
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: CommentDto,
): Promise<DiaryCommentResponseDto> {
return this.diary.updateComment(user.userId, id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@SkipTransform() // 204 — 본문이 없다
@ApiOperation({ summary: '댓글 삭제 (내가 쓴 댓글만)' })
@ApiNoContentResponse({ description: '삭제 완료' })
@ApiErrorResponse('RES_001', '댓글을 찾을 수 없음')
@ApiErrorResponse('AUTH_003', '내가 쓴 댓글이 아님')
async remove(
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
): Promise<void> {
await this.diary.deleteComment(user.userId, id);
}
}
@@ -0,0 +1,104 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiNoContentResponse,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import {
ApiErrorResponse,
ApiPaginatedResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import { CurrentUser } from '@/common/decorators/current-user.decorator';
import type { AuthUser } from '@/common/decorators/current-user.decorator';
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
import {
CursorPaginationQueryDto,
PaginatedResult,
} from '@/common/dto/pagination.dto';
import { DiaryService } from './diary.service';
import {
CommentDto,
DiaryCommentResponseDto,
DiaryPostResponseDto,
UpdatePostDto,
} from './dto/diary.dto';
// 다이어리 기록 — 라우팅과 입출력만 담당, 비즈니스 로직은 모두 service 위임
@ApiTags('Diary')
@ApiBearerAuth()
@ApiErrorResponse('AUTH_001', '로그인 필요')
@Controller('diary-posts')
export class DiaryPostsController {
constructor(private readonly diary: DiaryService) {}
@Get()
@ApiOperation({
summary: '내 다이어리들의 기록 목록 (쓴 순, 커서 페이지네이션)',
})
@ApiPaginatedResponse(DiaryPostResponseDto, '기록 목록')
@ApiErrorResponse('VAL_001', '페이지네이션 파라미터 오류')
list(
@CurrentUser() user: AuthUser,
@Query() query: CursorPaginationQueryDto,
): Promise<PaginatedResult<DiaryPostResponseDto>> {
return this.diary.listPosts(user.userId, query);
}
@Patch(':id')
@ApiOperation({ summary: '기록 내용 고치기 (내가 쓴 기록만)' })
@ApiStandardResponse(DiaryPostResponseDto, { description: '고친 기록' })
@ApiErrorResponse('RES_001', '기록을 찾을 수 없음')
@ApiErrorResponse('AUTH_003', '내가 쓴 기록이 아님')
update(
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdatePostDto,
): Promise<DiaryPostResponseDto> {
return this.diary.updatePost(user.userId, id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@SkipTransform() // 204 — 본문이 없다
@ApiOperation({ summary: '기록 삭제 (댓글 함께)' })
@ApiNoContentResponse({ description: '삭제 완료' })
@ApiErrorResponse('RES_001', '기록을 찾을 수 없음')
@ApiErrorResponse('AUTH_003', '내가 쓴 기록이 아님')
async remove(
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
): Promise<void> {
await this.diary.deletePost(user.userId, id);
}
@Post(':id/comments')
@ApiOperation({ summary: '댓글 쓰기' })
@ApiStandardResponse(DiaryCommentResponseDto, {
status: 201,
description: '쓴 댓글',
})
@ApiErrorResponse('RES_001', '기록을 찾을 수 없음')
@ApiErrorResponse('VAL_001', '입력값 검증 실패')
createComment(
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: CommentDto,
): Promise<DiaryCommentResponseDto> {
return this.diary.createComment(user.userId, id, dto);
}
}
@@ -0,0 +1,25 @@
import { countShelfDiaries } from './diary-shelf';
describe('countShelfDiaries', () => {
const archived = new Set(['old']);
it('대상이 모두 보관된 아이인 다이어리는 세지 않는다', () => {
expect(countShelfDiaries([['old'], ['old', 'old']], archived)).toBe(0);
});
it('함께 지내는 아이나 나(self)가 섞이면 책장에 둔다', () => {
expect(
countShelfDiaries(
[
['old', 'kong'],
['old', 'self'],
],
archived,
),
).toBe(2);
});
it('대상이 없는 다이어리도 책장에 둔다', () => {
expect(countShelfDiaries([[]], archived)).toBe(1);
});
});
+15
View File
@@ -0,0 +1,15 @@
// 다이어리 책장 — 부수효과 없는 순수 함수 (앱의 DiaryBook.shelf 와 같은 규칙)
/**
* 책장에 꽂힌 다이어리 수 — 대상이 모두 추억 보관함의 아이인 다이어리는 보관함에서 보므로 세지 않는다.
* 대상이 없는 다이어리는 책장에 둔다.
*/
export function countShelfDiaries(
diaryTargets: ReadonlyArray<readonly string[]>,
archivedPetIds: ReadonlySet<string>,
): number {
return diaryTargets.filter(
(targets) =>
!(targets.length > 0 && targets.every((id) => archivedPetIds.has(id))),
).length;
}
+25
View File
@@ -0,0 +1,25 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PetModule } from '@/modules/pet/pet.module';
import { UploadModule } from '@/modules/upload/upload.module';
import { DiariesController } from './diaries.controller';
import { DiaryCommentsController } from './diary-comments.controller';
import { DiaryPostsController } from './diary-posts.controller';
import { DiaryService } from './diary.service';
import { Diary, DiaryComment, DiaryPost } from './entities/diary.entity';
@Module({
imports: [
TypeOrmModule.forFeature([Diary, DiaryPost, DiaryComment]),
PetModule,
UploadModule,
],
controllers: [
DiariesController,
DiaryPostsController,
DiaryCommentsController,
],
providers: [DiaryService],
})
export class DiaryModule {}
+343
View File
@@ -0,0 +1,343 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { DataSource, IsNull, Not, Repository } from 'typeorm';
import { keysetPaginate, mapPage } from '@/common/dto/keyset-pagination';
import {
CursorPaginationQueryDto,
PaginatedResult,
} from '@/common/dto/pagination.dto';
import { BusinessException } from '@/common/errors/business.exception';
import { Pet } from '@/modules/pet/entities/pet.entity';
import {
PetLifecycleEvent,
PetLifecycleListener,
} from '@/modules/pet/pet-lifecycle';
import { PetService } from '@/modules/pet/pet.service';
import { UploadService } from '@/modules/upload/upload.service';
import { isUniqueViolation } from '@/shared/utils/db-error';
import {
CommentDto,
CreateDiaryDto,
CreatePostDto,
DiaryCommentResponseDto,
DiaryPostResponseDto,
DiaryResponseDto,
UpdateDiaryDto,
UpdatePostDto,
} from './dto/diary.dto';
import {
DIARY_LIMITS,
Diary,
DiaryComment,
DiaryPost,
} from './entities/diary.entity';
import { countShelfDiaries } from './diary-shelf';
const createdAtKeys = <T extends { createdAt: Date; id: string }>(
alias: string,
) => [
{
column: `${alias}.created_at`,
cast: 'timestamptz',
read: (row: T) => row.createdAt.toISOString(),
},
{ column: `${alias}.id`, cast: 'uuid', read: (row: T) => row.id },
];
// 다이어리 — 다이어리·기록·댓글. 지금은 주인만 읽고 쓴다 (공유 다이어리는 다음 단계)
@Injectable()
export class DiaryService implements OnModuleInit, PetLifecycleListener {
constructor(
@InjectRepository(Diary)
private readonly diaries: Repository<Diary>,
@InjectRepository(DiaryPost)
private readonly posts: Repository<DiaryPost>,
@InjectRepository(DiaryComment)
private readonly comments: Repository<DiaryComment>,
@InjectDataSource()
private readonly dataSource: DataSource,
private readonly pets: PetService,
private readonly uploads: UploadService,
) {}
onModuleInit(): void {
this.pets.registerListener(this);
}
// ── 다이어리 ───────────────────────────────────────────
async listDiaries(
ownerId: string,
query: CursorPaginationQueryDto,
): Promise<PaginatedResult<DiaryResponseDto>> {
const qb = this.diaries
.createQueryBuilder('diary')
.where('diary.owner_id = :ownerId', { ownerId });
const page = await keysetPaginate(qb, createdAtKeys<Diary>('diary'), query);
return mapPage(page, (d) => DiaryResponseDto.from(d));
}
/** 새 다이어리 — 책장이 가득 찼으면 DIARY_001 (추억 보관함의 아이만 담은 다이어리는 세지 않는다) */
async createDiary(
ownerId: string,
dto: CreateDiaryDto,
): Promise<DiaryResponseDto> {
await this.pets.assertOwnedTargets(ownerId, dto.targetIds);
await this.uploads.assertOwnedImage(dto.coverFileId, ownerId);
return this.dataSource.transaction(async (manager) => {
// 동시에 만들어 한도를 넘지 않도록 회원의 다이어리를 잠그고 센다
const existing = await manager.find(Diary, {
where: { ownerId },
select: { id: true, targetIds: true },
lock: { mode: 'pessimistic_write' },
});
const archived = await manager.find(Pet, {
where: { ownerId, archivedAt: Not(IsNull()) },
select: { id: true },
});
const shelf = countShelfDiaries(
existing.map((d) => d.targetIds),
new Set(archived.map((p) => p.id)),
);
if (shelf >= DIARY_LIMITS.maxDiaries) {
throw new BusinessException('DIARY_001', { context: { shelf } });
}
const diary = await manager.save(
manager.create(Diary, {
ownerId,
name: dto.name,
targetIds: [...new Set(dto.targetIds)],
ribbon: dto.ribbon,
tint: dto.tint,
coverFileId: dto.coverFileId ?? null,
mutedNotices: [],
}),
);
return DiaryResponseDto.from(diary);
});
}
async updateDiary(
ownerId: string,
diaryId: string,
dto: UpdateDiaryDto,
): Promise<DiaryResponseDto> {
const diary = await this.findOwnedDiary(ownerId, diaryId);
const previousCover = diary.coverFileId;
if (dto.targetIds !== undefined) {
await this.pets.assertOwnedTargets(ownerId, dto.targetIds);
diary.targetIds = [...new Set(dto.targetIds)];
}
if (dto.coverFileId !== undefined) {
await this.uploads.assertOwnedImage(dto.coverFileId, ownerId);
diary.coverFileId = dto.coverFileId;
}
if (dto.name !== undefined) diary.name = dto.name;
if (dto.ribbon !== undefined) diary.ribbon = dto.ribbon;
if (dto.mutedNotices !== undefined) {
diary.mutedNotices = [...new Set(dto.mutedNotices)];
}
const saved = await this.diaries.save(diary);
if (previousCover !== saved.coverFileId) {
await this.uploads.releaseIfUnused([previousCover]);
}
return DiaryResponseDto.from(saved);
}
/** 다이어리를 지우면 그 안의 기록·댓글도 모두 지운다 (FK) — 쓰지 않게 된 사진도 정리한다 */
async deleteDiary(ownerId: string, diaryId: string): Promise<void> {
const diary = await this.findOwnedDiary(ownerId, diaryId);
const photos = await this.posts.find({
where: { diaryId, photoFileId: Not(IsNull()) },
select: { photoFileId: true },
});
await this.diaries.delete({ id: diaryId, ownerId });
await this.uploads.releaseIfUnused([
diary.coverFileId,
...photos.map((p) => p.photoFileId),
]);
}
// ── 기록 ───────────────────────────────────────────────
/** 내 다이어리들의 모든 기록 (쓴 순서) */
async listPosts(
ownerId: string,
query: CursorPaginationQueryDto,
): Promise<PaginatedResult<DiaryPostResponseDto>> {
const qb = this.posts
.createQueryBuilder('post')
.innerJoin('post.diary', 'diary')
.where('diary.owner_id = :ownerId', { ownerId });
const page = await keysetPaginate(
qb,
createdAtKeys<DiaryPost>('post'),
query,
);
return mapPage(page, (p) => DiaryPostResponseDto.from(p));
}
/** 기록 남기기 — 한 사람이 한 다이어리에 하루 한 번 (DIARY_002) */
async createPost(
userId: string,
diaryId: string,
dto: CreatePostDto,
): Promise<DiaryPostResponseDto> {
await this.findOwnedDiary(userId, diaryId);
await this.uploads.assertOwnedImage(dto.photoFileId, userId);
try {
const post = await this.posts.save(
this.posts.create({
diaryId,
authorId: userId,
date: dto.date,
text: dto.text,
mood: dto.mood ?? null,
photoFileId: dto.photoFileId ?? null,
}),
);
return DiaryPostResponseDto.from(post);
} catch (error) {
if (isUniqueViolation(error)) {
throw new BusinessException('DIARY_002', {
context: { diaryId, date: dto.date },
});
}
throw error;
}
}
async updatePost(
userId: string,
postId: string,
dto: UpdatePostDto,
): Promise<DiaryPostResponseDto> {
const post = await this.findOwnPost(userId, postId);
post.text = dto.text;
return DiaryPostResponseDto.from(await this.posts.save(post));
}
/** 기록을 지우면 댓글도 함께 지워진다 (FK) */
async deletePost(userId: string, postId: string): Promise<void> {
const post = await this.findOwnPost(userId, postId);
await this.posts.delete({ id: post.id });
await this.uploads.releaseIfUnused([post.photoFileId]);
}
// ── 댓글 ───────────────────────────────────────────────
/** 내 다이어리들의 모든 댓글 (쓴 순서) */
async listComments(
ownerId: string,
query: CursorPaginationQueryDto,
): Promise<PaginatedResult<DiaryCommentResponseDto>> {
const qb = this.comments
.createQueryBuilder('comment')
.innerJoin('comment.post', 'post')
.innerJoin('post.diary', 'diary')
.where('diary.owner_id = :ownerId', { ownerId });
const page = await keysetPaginate(
qb,
createdAtKeys<DiaryComment>('comment'),
query,
);
return mapPage(page, (c) => DiaryCommentResponseDto.from(c));
}
async createComment(
userId: string,
postId: string,
dto: CommentDto,
): Promise<DiaryCommentResponseDto> {
await this.findReadablePost(userId, postId);
const comment = await this.comments.save(
this.comments.create({ postId, authorId: userId, text: dto.text }),
);
return DiaryCommentResponseDto.from(comment);
}
async updateComment(
userId: string,
commentId: string,
dto: CommentDto,
): Promise<DiaryCommentResponseDto> {
const comment = await this.findOwnComment(userId, commentId);
comment.text = dto.text;
return DiaryCommentResponseDto.from(await this.comments.save(comment));
}
async deleteComment(userId: string, commentId: string): Promise<void> {
const comment = await this.findOwnComment(userId, commentId);
await this.comments.delete({ id: comment.id });
}
// ── 아이 삭제 ─────────────────────────────────────────
/** 아이를 지우면 다이어리 대상에서만 뗀다 — 다이어리와 기록은 남긴다 (앱의 dropTarget) */
async onPetDeleting({
ownerId,
petId,
manager,
}: PetLifecycleEvent): Promise<void> {
await manager.query(
`UPDATE diaries SET target_ids = array_remove(target_ids, $2)
WHERE owner_id = $1 AND $2 = ANY(target_ids)`,
[ownerId, petId],
);
}
// ── 조회 도우미 ───────────────────────────────────────
private async findOwnedDiary(
ownerId: string,
diaryId: string,
): Promise<Diary> {
const diary = await this.diaries.findOneBy({ id: diaryId, ownerId });
if (!diary)
throw new BusinessException('RES_001', { context: { diaryId } });
return diary;
}
/** 볼 수 있는 기록 — 내 다이어리의 기록 */
private async findReadablePost(
userId: string,
postId: string,
): Promise<DiaryPost> {
const post = await this.posts.findOne({
where: { id: postId, diary: { ownerId: userId } },
});
if (!post) throw new BusinessException('RES_001', { context: { postId } });
return post;
}
/** 고칠 수 있는 기록 — 내 다이어리에 내가 쓴 기록 */
private async findOwnPost(
userId: string,
postId: string,
): Promise<DiaryPost> {
const post = await this.findReadablePost(userId, postId);
if (post.authorId !== userId) {
throw new BusinessException('AUTH_003', { context: { postId } });
}
return post;
}
/** 고칠 수 있는 댓글 — 내 다이어리에서 내가 쓴 댓글 */
private async findOwnComment(
userId: string,
commentId: string,
): Promise<DiaryComment> {
const comment = await this.comments.findOne({
where: { id: commentId, post: { diary: { ownerId: userId } } },
});
if (!comment) {
throw new BusinessException('RES_001', { context: { commentId } });
}
if (comment.authorId !== userId) {
throw new BusinessException('AUTH_003', { context: { commentId } });
}
return comment;
}
}
+230
View File
@@ -0,0 +1,230 @@
import {
ApiProperty,
ApiPropertyOptional,
OmitType,
PartialType,
} from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import {
ArrayMaxSize,
IsArray,
IsIn,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
MaxLength,
} from 'class-validator';
import { IsHexColor, IsIsoDate, IsTargetIdList } from '@/common/validators';
import { DIARY_LIMITS, DIARY_NOTICES, MOODS } from '../entities/diary.entity';
import type {
Diary,
DiaryComment,
DiaryNotice,
DiaryPost,
Mood,
} from '../entities/diary.entity';
const trim = ({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() : value;
/** 새 다이어리 */
export class CreateDiaryDto {
@ApiProperty({
description: `이름 (1~${DIARY_LIMITS.name}자)`,
example: '콩이의 하루',
})
@Transform(trim)
@IsString({ message: '다이어리 이름을 입력해 주세요.' })
@IsNotEmpty({ message: '다이어리 이름을 입력해 주세요.' })
@MaxLength(DIARY_LIMITS.name, {
message: `다이어리 이름은 ${DIARY_LIMITS.name}자 이하로 입력해 주세요.`,
})
name!: string;
@ApiProperty({
description: '누구의 다이어리인지 — 아이 id 또는 "self". 비어 있어도 된다',
type: [String],
})
@IsTargetIdList(DIARY_LIMITS.targets, 0)
targetIds!: string[];
@ApiProperty({ description: '책갈피 리본 색 (#RRGGBB)', example: '#FF7500' })
@IsHexColor()
ribbon!: string;
@ApiProperty({
description: '표지 사진이 없을 때 바탕색 (#RRGGBB) — 앱이 고른다',
example: '#FF8A5B',
})
@IsHexColor()
tint!: string;
@ApiPropertyOptional({
description: '표지 사진 파일 ID (POST /api/files 로 올린 이미지)',
nullable: true,
type: String,
})
@IsOptional()
@IsUUID('4', { message: '사진 정보가 올바르지 않습니다.' })
coverFileId?: string | null;
}
/** 다이어리 수정 — 보낸 값만 바꾼다. 바탕색(tint)은 만들 때 정한 값을 유지한다 */
export class UpdateDiaryDto extends PartialType(
OmitType(CreateDiaryDto, ['tint'] as const),
) {
@ApiPropertyOptional({
description: '끈 알림 목록 (기본은 모두 켜짐)',
enum: DIARY_NOTICES,
isArray: true,
})
@IsOptional()
@IsArray({ message: '알림 설정이 올바르지 않습니다.' })
@ArrayMaxSize(DIARY_NOTICES.length)
@IsIn(DIARY_NOTICES, {
each: true,
message: '알림 설정이 올바르지 않습니다.',
})
mutedNotices?: DiaryNotice[];
}
/** 기록 남기기 — 하루 한 번 */
export class CreatePostDto {
@ApiProperty({
description:
'기록한 날 (앱 기준 오늘, YYYY-MM-DD) — 같은 날 두 번이면 DIARY_002',
example: '2026-10-11',
})
@IsIsoDate()
date!: string;
@ApiProperty({ description: `내용 (최대 ${DIARY_LIMITS.postText}자)` })
@Transform(trim)
@IsString({ message: '내용을 입력해 주세요.' })
@IsNotEmpty({ message: '내용을 입력해 주세요.' })
@MaxLength(DIARY_LIMITS.postText, {
message: `내용은 ${DIARY_LIMITS.postText}자 이하로 입력해 주세요.`,
})
text!: string;
@ApiPropertyOptional({ enum: MOODS, nullable: true })
@IsOptional()
@IsIn(MOODS, { message: '기분이 올바르지 않습니다.' })
mood?: Mood | null;
@ApiPropertyOptional({
description: '사진 파일 ID',
nullable: true,
type: String,
})
@IsOptional()
@IsUUID('4', { message: '사진 정보가 올바르지 않습니다.' })
photoFileId?: string | null;
}
/** 기록 내용 고치기 */
export class UpdatePostDto {
@ApiProperty({ description: `내용 (최대 ${DIARY_LIMITS.postText}자)` })
@Transform(trim)
@IsString({ message: '내용을 입력해 주세요.' })
@IsNotEmpty({ message: '내용을 입력해 주세요.' })
@MaxLength(DIARY_LIMITS.postText, {
message: `내용은 ${DIARY_LIMITS.postText}자 이하로 입력해 주세요.`,
})
text!: string;
}
/** 댓글 쓰기·고치기 */
export class CommentDto {
@ApiProperty({ description: `댓글 (최대 ${DIARY_LIMITS.comment}자)` })
@Transform(trim)
@IsString({ message: '댓글을 입력해 주세요.' })
@IsNotEmpty({ message: '댓글을 입력해 주세요.' })
@MaxLength(DIARY_LIMITS.comment, {
message: `댓글은 ${DIARY_LIMITS.comment}자 이하로 입력해 주세요.`,
})
text!: string;
}
/** 다이어리 응답 (앱의 Diary) */
export class DiaryResponseDto {
@ApiProperty() id!: string;
@ApiProperty() name!: string;
@ApiProperty({ type: [String] }) targetIds!: string[];
@ApiProperty({ example: '#FF7500' }) ribbon!: string;
@ApiProperty({ example: '#FF8A5B' }) tint!: string;
@ApiProperty({
description: '표지 사진 파일 ID — GET /api/files/{id}',
nullable: true,
type: String,
})
coverFileId!: string | null;
@ApiProperty({ enum: DIARY_NOTICES, isArray: true })
mutedNotices!: DiaryNotice[];
@ApiProperty({ description: '만든 때 (ISO 8601 UTC)' })
createdAt!: string;
static from(diary: Diary): DiaryResponseDto {
return {
id: diary.id,
name: diary.name,
targetIds: diary.targetIds,
ribbon: diary.ribbon,
tint: diary.tint,
coverFileId: diary.coverFileId,
mutedNotices: diary.mutedNotices,
createdAt: diary.createdAt.toISOString(),
};
}
}
/** 기록 응답 (앱의 DiaryPost) */
export class DiaryPostResponseDto {
@ApiProperty() id!: string;
@ApiProperty() diaryId!: string;
@ApiProperty({ description: '기록한 사람 (회원 ID)' }) authorId!: string;
@ApiProperty({ description: '기록한 날 (YYYY-MM-DD)' }) date!: string;
@ApiProperty({ description: '쓴 때 (ISO 8601 UTC)' }) createdAt!: string;
@ApiProperty() text!: string;
@ApiProperty({ enum: MOODS, nullable: true }) mood!: Mood | null;
@ApiProperty({ nullable: true, type: String })
photoFileId!: string | null;
static from(post: DiaryPost): DiaryPostResponseDto {
return {
id: post.id,
diaryId: post.diaryId,
authorId: post.authorId,
date: post.date,
createdAt: post.createdAt.toISOString(),
text: post.text,
mood: post.mood,
photoFileId: post.photoFileId,
};
}
}
/** 댓글 응답 (앱의 DiaryComment) */
export class DiaryCommentResponseDto {
@ApiProperty() id!: string;
@ApiProperty() postId!: string;
@ApiProperty({ description: '쓴 사람 (회원 ID)' }) authorId!: string;
@ApiProperty() text!: string;
@ApiProperty({ description: '쓴 때 (ISO 8601 UTC)' }) createdAt!: string;
static from(comment: DiaryComment): DiaryCommentResponseDto {
return {
id: comment.id,
postId: comment.postId,
authorId: comment.authorId,
text: comment.text,
createdAt: comment.createdAt.toISOString(),
};
}
}
@@ -0,0 +1,166 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
Unique,
UpdateDateColumn,
} from 'typeorm';
import { StoredFile } from '@/modules/upload/entities/stored-file.entity';
import { User } from '@/modules/user/entities/user.entity';
/** 다이어리 규칙·입력 한도 — 앱의 diary.dart 상수와 맞춘다 */
export const DIARY_LIMITS = {
/** 책장에 꽂을 수 있는 다이어리 수 */
maxDiaries: 6,
name: 18,
targets: 20,
postText: 2000,
comment: 50,
} as const;
/** 다이어리별 알림 — 새 글 / 내 글에 새 댓글 / 오늘 기록 잊지 않게 (앱의 DiaryNotice) */
export const DIARY_NOTICES = ['newPost', 'newComment', 'reminder'] as const;
export type DiaryNotice = (typeof DIARY_NOTICES)[number];
/** 기록할 때 고르는 오늘의 기분 (앱의 Mood) */
export const MOODS = [
'happy',
'excited',
'calm',
'meh',
'sad',
'sick',
] as const;
export type Mood = (typeof MOODS)[number];
/** 다이어리 한 권 */
@Entity('diaries')
export class Diary {
@PrimaryGeneratedColumn('uuid')
id!: string;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'owner_id' })
owner?: User;
@Index()
@Column({ type: 'uuid' })
ownerId!: string;
@Column({ type: 'varchar', length: DIARY_LIMITS.name })
name!: string;
/** 누구의 다이어리인지 — 아이 id 또는 'self'. 아이를 지우면 그 아이만 뗀다(다이어리는 남긴다) */
@Column({ type: 'text', array: true, default: '{}' })
targetIds!: string[];
/** 책갈피 리본 색 `#RRGGBB` */
@Column({ type: 'varchar', length: 7 })
ribbon!: string;
/** 표지 사진이 없을 때 바탕색 `#RRGGBB` — 만들 때 앱이 고른다 */
@Column({ type: 'varchar', length: 7 })
tint!: string;
/** 표지 사진 — 참조 중인 파일은 지울 수 없게 둔다 (UploadService.releaseIfUnused) */
@ManyToOne(() => StoredFile, { nullable: true, onDelete: 'NO ACTION' })
@JoinColumn({ name: 'cover_file_id' })
coverFile?: StoredFile | null;
@Column({ type: 'uuid', nullable: true })
coverFileId!: string | null;
/** 끈 알림 (기본은 모두 켜짐) */
@Column({ type: 'text', array: true, default: '{}' })
mutedNotices!: DiaryNotice[];
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
}
/** 다이어리의 기록 한 건 — 한 사람이 한 다이어리에 하루 한 번 */
@Entity('diary_posts')
@Unique(['diaryId', 'authorId', 'date'])
export class DiaryPost {
@PrimaryGeneratedColumn('uuid')
id!: string;
@ManyToOne(() => Diary, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'diary_id' })
diary?: Diary;
@Index()
@Column({ type: 'uuid' })
diaryId!: string;
/** 기록한 사람 — 지금은 다이어리 주인. 공유 다이어리가 생기면 멤버가 된다 */
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'author_id' })
author?: User;
@Column({ type: 'uuid' })
authorId!: string;
/** 기록한 날 (앱 기준 날짜, YYYY-MM-DD) — 하루 한 번 쓰기와 달력에 쓴다 */
@Column({ type: 'date' })
date!: string;
@Column({ type: 'varchar', length: DIARY_LIMITS.postText })
text!: string;
@Column({ type: 'varchar', length: 10, nullable: true })
mood!: Mood | null;
@ManyToOne(() => StoredFile, { nullable: true, onDelete: 'NO ACTION' })
@JoinColumn({ name: 'photo_file_id' })
photoFile?: StoredFile | null;
@Column({ type: 'uuid', nullable: true })
photoFileId!: string | null;
/** 같은 날 기록끼리 순서를 정한다 */
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
}
/** 기록에 단 댓글 */
@Entity('diary_comments')
export class DiaryComment {
@PrimaryGeneratedColumn('uuid')
id!: string;
@ManyToOne(() => DiaryPost, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'post_id' })
post?: DiaryPost;
@Index()
@Column({ type: 'uuid' })
postId!: string;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'author_id' })
author?: User;
@Column({ type: 'uuid' })
authorId!: string;
@Column({ type: 'varchar', length: DIARY_LIMITS.comment })
text!: string;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
}
@@ -15,6 +15,7 @@ import {
} from '@nestjs/terminus';
import type { Response } from 'express';
import { Public } from '@/common/decorators/public.decorator';
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
// DB ping 타임아웃 — 기본값 1000ms 는 너무 짧다.
@@ -32,7 +33,9 @@ const DB_PING_TIMEOUT_MS = 5_000;
* - 실패 503: `{ status: 'error', error: { database: { status: 'down', message } }, ... }`
*
* 주기적으로 호출되므로 rate limit 대상에서도 제외한다 (한도를 소진시켜 실제 요청을 막으면 안 된다).
* 모니터링 도구는 로그인하지 않으므로 인증에서도 제외한다 (@Public).
*/
@Public()
@SkipThrottle()
@SkipTransform()
@ApiTags('Health')
@@ -0,0 +1,14 @@
import { formatCardCode, randomCardCode } from './card-code';
describe('명함 코드', () => {
it('7자리를 0000-000 형식으로 쓴다', () => {
expect(formatCardCode(4_281_170)).toBe('4281-170');
expect(formatCardCode(42)).toBe('0000-042');
});
it('무작위 코드도 같은 형식이다', () => {
for (let i = 0; i < 20; i += 1) {
expect(randomCardCode()).toMatch(/^\d{4}-\d{3}$/);
}
});
});
+17
View File
@@ -0,0 +1,17 @@
import { randomInt } from 'crypto';
/** 명함 코드 자릿수 — "0000-000" (앱이 보여 주는 형식) */
const CODE_DIGITS = 7;
const CODE_SPACE = 10 ** CODE_DIGITS;
const CODE_HEAD = 4;
/** 0 ~ 9,999,999 → "0000-000" */
export function formatCardCode(value: number): string {
const digits = String(value % CODE_SPACE).padStart(CODE_DIGITS, '0');
return `${digits.slice(0, CODE_HEAD)}-${digits.slice(CODE_HEAD)}`;
}
/** 새 명함 코드 — 추측하기 어렵게 무작위로 뽑는다 (겹치면 호출한 쪽이 다시 뽑는다) */
export function randomCardCode(): string {
return formatCardCode(randomInt(0, CODE_SPACE));
}
@@ -0,0 +1,171 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsBoolean,
IsIn,
IsOptional,
IsUUID,
Matches,
ValidateNested,
} from 'class-validator';
import { IsHexColor } from '@/common/validators';
import { SCRIM_TONES } from '../entities/pet-card.entity';
import type { PetCard, ScrimTone } from '../entities/pet-card.entity';
/**
* 앱에 들어 있는 배경 그림 경로 (assets/svg/card_bg/bg_1.svg 등) — 경로 탈출·다른 형식을 막는다.
* 앱 번들 안의 경로를 가리킬 뿐 서버가 이 파일을 내려주지 않으므로 svg 도 받는다.
*/
const ASSET_PATH_PATTERN =
/^assets\/[A-Za-z0-9_-]+(\/[A-Za-z0-9_-]+)*\.(png|jpg|jpeg|webp|svg)$/;
/** 명함 꾸미기 값 (앱의 CardStyle) — 배경은 색·그림·사진 중 하나만 */
export class CardStyleDto {
@ApiProperty({
description: '상단 그라데이션 첫 색 (#RRGGBB)',
example: '#FF9B6A',
})
@IsHexColor()
tint!: string;
@ApiProperty({
description: '상단 그라데이션 둘째 색 (#RRGGBB)',
example: '#FFC59B',
})
@IsHexColor()
tint2!: string;
@ApiPropertyOptional({
description: '배경색 (#RRGGBB) — 없으면 흰색',
nullable: true,
type: String,
})
@IsOptional()
@IsHexColor()
nameBg?: string | null;
@ApiPropertyOptional({
description: '앱에 들어 있는 배경 그림 경로 (assets/…)',
nullable: true,
type: String,
})
@IsOptional()
@Matches(ASSET_PATH_PATTERN, {
message: '배경 그림 정보가 올바르지 않습니다.',
})
nameBgAsset?: string | null;
@ApiPropertyOptional({
description: '배경 사진 파일 ID (POST /api/files 로 올린 이미지)',
nullable: true,
type: String,
})
@IsOptional()
@IsUUID('4', { message: '사진 정보가 올바르지 않습니다.' })
nameBgFileId?: string | null;
@ApiProperty({ description: '밝은 글자 (어두운 배경용)', example: false })
@IsBoolean()
textLight!: boolean;
@ApiProperty({ description: '글자 뒤 막 덮기', example: false })
@IsBoolean()
scrim!: boolean;
@ApiProperty({ enum: SCRIM_TONES, example: 'light' })
@IsIn(SCRIM_TONES, { message: '막 색이 올바르지 않습니다.' })
scrimTone!: ScrimTone;
}
/** 새 명함 */
export class CreatePetCardDto {
@ApiProperty({ description: '명함을 만들 아이 ID' })
@IsUUID('4', { message: '아이 정보가 올바르지 않습니다.' })
petId!: string;
@ApiProperty({ type: CardStyleDto })
@ValidateNested()
@Type(() => CardStyleDto)
style!: CardStyleDto;
}
/** 명함 꾸미기 바꾸기 — 꾸미기 값을 통째로 바꾼다 */
export class UpdatePetCardDto {
@ApiProperty({ type: CardStyleDto })
@ValidateNested()
@Type(() => CardStyleDto)
style!: CardStyleDto;
}
/** 대표 명함 지정 */
export class SetRepresentativeDto {
@ApiProperty({ description: '대표로 둘 명함 ID (그 아이의 명함이어야 한다)' })
@IsUUID('4', { message: '명함 정보가 올바르지 않습니다.' })
cardId!: string;
}
/** 명함 꾸미기 응답 */
export class CardStyleResponseDto {
@ApiProperty() tint!: string;
@ApiProperty() tint2!: string;
@ApiProperty({ nullable: true, type: String }) nameBg!: string | null;
@ApiProperty({ nullable: true, type: String }) nameBgAsset!: string | null;
@ApiProperty({ nullable: true, type: String }) nameBgFileId!: string | null;
@ApiProperty() textLight!: boolean;
@ApiProperty() scrim!: boolean;
@ApiProperty({ enum: SCRIM_TONES }) scrimTone!: ScrimTone;
}
/** 펫 명함 응답 (앱의 PetCard) */
export class PetCardResponseDto {
@ApiProperty() id!: string;
@ApiProperty() petId!: string;
@ApiProperty({ description: '명함 코드', example: '0428-117' })
code!: string;
@ApiProperty({ type: CardStyleResponseDto })
style!: CardStyleResponseDto;
@ApiProperty({ description: '아이의 대표 명함인지' })
isRepresentative!: boolean;
static from(card: PetCard): PetCardResponseDto {
return {
id: card.id,
petId: card.petId,
code: card.code,
style: {
tint: card.tint,
tint2: card.tint2,
nameBg: card.nameBg,
nameBgAsset: card.nameBgAsset,
nameBgFileId: card.nameBgFileId,
textLight: card.textLight,
scrim: card.scrim,
scrimTone: card.scrimTone,
},
isRepresentative: card.isRepresentative,
};
}
}
/**
* 꾸미기 DTO → 컬럼 값. 배경은 하나만 남긴다 — 사진 > 그림 > 색 순으로 우선한다
* (앱도 하나를 고르면 나머지를 비운다).
*/
export function toStyleColumns(style: CardStyleDto) {
const file = style.nameBgFileId ?? null;
const asset = file ? null : (style.nameBgAsset ?? null);
return {
tint: style.tint,
tint2: style.tint2,
nameBg: file || asset ? null : (style.nameBg ?? null),
nameBgAsset: asset,
nameBgFileId: file,
textLight: style.textLight,
scrim: style.scrim,
scrimTone: style.scrimTone,
};
}
@@ -0,0 +1,101 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { Pet } from '@/modules/pet/entities/pet.entity';
import { StoredFile } from '@/modules/upload/entities/stored-file.entity';
import { User } from '@/modules/user/entities/user.entity';
/** 한 사람이 만들 수 있는 펫 명함 수 — 앱의 kMaxPetCards */
export const MAX_PET_CARDS = 10;
/** 글자 뒤에 덮는 막의 색 (앱의 ScrimTone) */
export const SCRIM_TONES = ['light', 'dark'] as const;
export type ScrimTone = (typeof SCRIM_TONES)[number];
/**
* 펫 명함 — 어느 아이의 명함인지 + 꾸미기 값(앱의 CardStyle).
* 이름·품종 등 내용은 아이 정보를 그대로 쓰고 모양만 저장한다.
* 배경은 색(nameBg) · 앱에 들어 있는 그림(nameBgAsset) · 올린 사진(nameBgFileId) 중 하나만 쓴다.
*/
@Entity('pet_cards')
// 아이마다 대표 명함은 하나
@Index('UQ_pet_cards_representative', ['petId'], {
unique: true,
where: '"is_representative"',
})
export class PetCard {
@PrimaryGeneratedColumn('uuid')
id!: string;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'owner_id' })
owner?: User;
@Index()
@Column({ type: 'uuid' })
ownerId!: string;
/** 아이를 지우면 그 아이의 명함도 지운다 */
@ManyToOne(() => Pet, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'pet_id' })
pet?: Pet;
@Column({ type: 'uuid' })
petId!: string;
/** 명함 코드 "0000-000" — 서버가 발급한다. 명함 받기에서 이 코드로 찾는다 */
@Column({ type: 'varchar', length: 8, unique: true })
code!: string;
/** 카드 상단 그라데이션 두 색 `#RRGGBB` */
@Column({ type: 'varchar', length: 7 })
tint!: string;
@Column({ type: 'varchar', length: 7 })
tint2!: string;
/** 배경색 — null 이면 흰색 */
@Column({ type: 'varchar', length: 7, nullable: true })
nameBg!: string | null;
/** 앱에 들어 있는 배경 그림 경로 (assets/…) */
@Column({ type: 'varchar', length: 120, nullable: true })
nameBgAsset!: string | null;
/** 올린 배경 사진 — 참조 중인 파일은 지울 수 없게 둔다 (UploadService.releaseIfUnused) */
@ManyToOne(() => StoredFile, { nullable: true, onDelete: 'NO ACTION' })
@JoinColumn({ name: 'name_bg_file_id' })
nameBgFile?: StoredFile | null;
@Column({ type: 'uuid', nullable: true })
nameBgFileId!: string | null;
/** 밝은 글자 (어두운 배경용) */
@Column({ type: 'boolean', default: false })
textLight!: boolean;
/** 글자 뒤에 은은한 막을 덮을지 */
@Column({ type: 'boolean', default: false })
scrim!: boolean;
@Column({ type: 'varchar', length: 5, default: 'light' })
scrimTone!: ScrimTone;
/** 아이의 대표 명함인지 */
@Column({ type: 'boolean', default: false })
isRepresentative!: boolean;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
}
@@ -0,0 +1,18 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PetModule } from '@/modules/pet/pet.module';
import { UploadModule } from '@/modules/upload/upload.module';
import { PetCard } from './entities/pet-card.entity';
import { PetCardService } from './pet-card.service';
import {
PetCardsController,
RepresentativeCardController,
} from './pet-cards.controller';
@Module({
imports: [TypeOrmModule.forFeature([PetCard]), PetModule, UploadModule],
controllers: [PetCardsController, RepresentativeCardController],
providers: [PetCardService],
})
export class PetCardModule {}
@@ -0,0 +1,98 @@
import { DataSource, EntityManager, Repository } from 'typeorm';
import { PetService } from '@/modules/pet/pet.service';
import { UploadService } from '@/modules/upload/upload.service';
import { CardStyleDto, toStyleColumns } from './dto/pet-card.dto';
import { PetCard } from './entities/pet-card.entity';
import { PetCardService } from './pet-card.service';
const style = (overrides: Partial<CardStyleDto> = {}): CardStyleDto => ({
tint: '#FF9B6A',
tint2: '#FFC59B',
textLight: false,
scrim: false,
scrimTone: 'light',
...overrides,
});
describe('toStyleColumns', () => {
it('배경은 하나만 남긴다 — 사진 > 그림 > 색', () => {
expect(
toStyleColumns(
style({
nameBg: '#FFFFFF',
nameBgAsset: 'assets/a.png',
nameBgFileId: 'f',
}),
),
).toMatchObject({ nameBg: null, nameBgAsset: null, nameBgFileId: 'f' });
expect(
toStyleColumns(style({ nameBg: '#FFFFFF', nameBgAsset: 'assets/a.png' })),
).toMatchObject({ nameBg: null, nameBgAsset: 'assets/a.png' });
expect(toStyleColumns(style({ nameBg: '#FFFFFF' }))).toMatchObject({
nameBg: '#FFFFFF',
nameBgAsset: null,
nameBgFileId: null,
});
});
});
describe('PetCardService', () => {
const cards = { findOneBy: jest.fn(), update: jest.fn() };
const manager = { update: jest.fn(), find: jest.fn() };
const dataSource = {
transaction: jest.fn((work: (m: EntityManager) => Promise<unknown>) =>
work(manager as unknown as EntityManager),
),
};
const pets = { registerListener: jest.fn(), findOwned: jest.fn() };
const uploads = { assertOwnedImage: jest.fn(), releaseIfUnused: jest.fn() };
const service = new PetCardService(
cards as unknown as Repository<PetCard>,
dataSource as unknown as DataSource,
pets as unknown as PetService,
uploads as unknown as UploadService,
);
beforeEach(() => jest.clearAllMocks());
it('다른 아이의 명함은 대표로 정할 수 없다 (VAL_001)', async () => {
cards.findOneBy.mockResolvedValue({ id: 'c1', petId: 'pet-b' });
await expect(
service.setRepresentative('o', 'pet-a', 'c1'),
).rejects.toMatchObject({ code: 'VAL_001' });
expect(dataSource.transaction).not.toHaveBeenCalled();
});
it('대표를 바꾸면 그 아이의 기존 대표를 먼저 푼다', async () => {
cards.findOneBy.mockResolvedValue({ id: 'c1', petId: 'pet-a' });
await service.setRepresentative('o', 'pet-a', 'c1');
expect(manager.update).toHaveBeenNthCalledWith(
1,
PetCard,
{ petId: 'pet-a', isRepresentative: true },
{ isRepresentative: false },
);
expect(manager.update).toHaveBeenNthCalledWith(
2,
PetCard,
{ id: 'c1' },
{ isRepresentative: true },
);
});
it('아이를 지울 때 그 아이 명함의 배경 사진을 정리 대상으로 돌려준다', async () => {
manager.find.mockResolvedValue([{ nameBgFileId: 'bg-1' }]);
await expect(
service.onPetDeleting({
ownerId: 'o',
petId: 'pet-a',
manager: manager as unknown as EntityManager,
}),
).resolves.toEqual(['bg-1']);
});
});
@@ -0,0 +1,194 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { DataSource, IsNull, Not, Repository } from 'typeorm';
import { keysetPaginate, mapPage } from '@/common/dto/keyset-pagination';
import {
CursorPaginationQueryDto,
PaginatedResult,
} from '@/common/dto/pagination.dto';
import { BusinessException } from '@/common/errors/business.exception';
import {
PetLifecycleEvent,
PetLifecycleListener,
} from '@/modules/pet/pet-lifecycle';
import { PetService } from '@/modules/pet/pet.service';
import { UploadService } from '@/modules/upload/upload.service';
import { isUniqueViolation } from '@/shared/utils/db-error';
import { randomCardCode } from './card-code';
import {
CreatePetCardDto,
PetCardResponseDto,
UpdatePetCardDto,
toStyleColumns,
} from './dto/pet-card.dto';
import { MAX_PET_CARDS, PetCard } from './entities/pet-card.entity';
/** 명함 코드가 겹치면 다시 뽑는 횟수 — 천만 개 중에서 뽑으므로 거의 겹치지 않는다 */
const CODE_RETRY = 5;
// 펫 명함 — 만들기·꾸미기·삭제·대표 명함
@Injectable()
export class PetCardService implements OnModuleInit, PetLifecycleListener {
constructor(
@InjectRepository(PetCard)
private readonly cards: Repository<PetCard>,
@InjectDataSource()
private readonly dataSource: DataSource,
private readonly pets: PetService,
private readonly uploads: UploadService,
) {}
onModuleInit(): void {
this.pets.registerListener(this);
}
/** 내 명함 목록 (만든 순) — 추억 보관함의 아이 명함도 함께 온다 */
async list(
ownerId: string,
query: CursorPaginationQueryDto,
): Promise<PaginatedResult<PetCardResponseDto>> {
const qb = this.cards
.createQueryBuilder('card')
.where('card.owner_id = :ownerId', { ownerId });
const page = await keysetPaginate(
qb,
[
{
column: 'card.created_at',
cast: 'timestamptz',
read: (c) => c.createdAt.toISOString(),
},
{ column: 'card.id', cast: 'uuid', read: (c) => c.id },
],
query,
);
return mapPage(page, (c) => PetCardResponseDto.from(c));
}
/** 새 명함 — 최대 10장 (CARD_001). 코드는 서버가 발급한다 */
async create(
ownerId: string,
dto: CreatePetCardDto,
): Promise<PetCardResponseDto> {
await this.pets.findOwned(ownerId, dto.petId);
await this.uploads.assertOwnedImage(dto.style.nameBgFileId, ownerId);
for (let attempt = 1; ; attempt += 1) {
try {
return await this.insertCard(ownerId, dto);
} catch (error) {
// 코드가 겹쳤으면 다시 뽑는다 (그 밖의 유니크 위반은 없다)
if (!isUniqueViolation(error) || attempt >= CODE_RETRY) throw error;
}
}
}
async update(
ownerId: string,
cardId: string,
dto: UpdatePetCardDto,
): Promise<PetCardResponseDto> {
const card = await this.findOwnedCard(ownerId, cardId);
await this.uploads.assertOwnedImage(dto.style.nameBgFileId, ownerId);
const previousBg = card.nameBgFileId;
const saved = await this.cards.save(
this.cards.merge(card, toStyleColumns(dto.style)),
);
if (previousBg !== saved.nameBgFileId) {
await this.uploads.releaseIfUnused([previousBg]);
}
return PetCardResponseDto.from(saved);
}
/** 명함 삭제 — 대표 명함이었으면 대표 지정도 함께 사라진다 */
async remove(ownerId: string, cardId: string): Promise<void> {
const card = await this.findOwnedCard(ownerId, cardId);
await this.cards.delete({ id: card.id });
await this.uploads.releaseIfUnused([card.nameBgFileId]);
}
/** 아이의 대표 명함 정하기 — 그 아이의 명함이어야 한다 */
async setRepresentative(
ownerId: string,
petId: string,
cardId: string,
): Promise<void> {
const card = await this.findOwnedCard(ownerId, cardId);
if (card.petId !== petId) {
throw new BusinessException('VAL_001', {
context: { reason: 'card-of-other-pet', petId, cardId },
});
}
await this.dataSource.transaction(async (manager) => {
await manager.update(
PetCard,
{ petId, isRepresentative: true },
{ isRepresentative: false },
);
await manager.update(PetCard, { id: cardId }, { isRepresentative: true });
});
}
/** 대표 명함 지정 풀기 — 앱은 그 아이의 첫 명함을 보여 준다 */
async clearRepresentative(ownerId: string, petId: string): Promise<void> {
await this.pets.findOwned(ownerId, petId);
await this.cards.update(
{ petId, ownerId, isRepresentative: true },
{ isRepresentative: false },
);
}
/** 아이를 지우면 명함은 FK 로 함께 지워진다 — 배경 사진은 커밋 뒤 정리하도록 돌려준다 */
async onPetDeleting({
petId,
manager,
}: PetLifecycleEvent): Promise<Array<string | null>> {
const withPhoto = await manager.find(PetCard, {
where: { petId, nameBgFileId: Not(IsNull()) },
select: { nameBgFileId: true },
});
return withPhoto.map((c) => c.nameBgFileId);
}
private async insertCard(
ownerId: string,
dto: CreatePetCardDto,
): Promise<PetCardResponseDto> {
return this.dataSource.transaction(async (manager) => {
// 동시에 만들어 한도를 넘지 않도록 회원의 명함을 잠그고 센다
const existing = await manager.find(PetCard, {
where: { ownerId },
select: { id: true },
lock: { mode: 'pessimistic_write' },
});
if (existing.length >= MAX_PET_CARDS) {
throw new BusinessException('CARD_001', {
context: { count: existing.length },
});
}
const card = await manager.save(
manager.create(PetCard, {
ownerId,
petId: dto.petId,
code: randomCardCode(),
...toStyleColumns(dto.style),
isRepresentative: false,
}),
);
return PetCardResponseDto.from(card);
});
}
private async findOwnedCard(
ownerId: string,
cardId: string,
): Promise<PetCard> {
const card = await this.cards.findOneBy({ id: cardId, ownerId });
if (!card) throw new BusinessException('RES_001', { context: { cardId } });
return card;
}
}
@@ -0,0 +1,138 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Post,
Put,
Query,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiNoContentResponse,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import {
ApiErrorResponse,
ApiPaginatedResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import { CurrentUser } from '@/common/decorators/current-user.decorator';
import type { AuthUser } from '@/common/decorators/current-user.decorator';
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
import {
CursorPaginationQueryDto,
PaginatedResult,
} from '@/common/dto/pagination.dto';
import {
CreatePetCardDto,
PetCardResponseDto,
SetRepresentativeDto,
UpdatePetCardDto,
} from './dto/pet-card.dto';
import { PetCardService } from './pet-card.service';
// 펫 명함 — 라우팅과 입출력만 담당, 비즈니스 로직은 모두 service 위임
@ApiTags('PetCard')
@ApiBearerAuth()
@ApiErrorResponse('AUTH_001', '로그인 필요')
@Controller('pet-cards')
export class PetCardsController {
constructor(private readonly cards: PetCardService) {}
@Get()
@ApiOperation({ summary: '내 명함 목록 (만든 순, 커서 페이지네이션)' })
@ApiPaginatedResponse(PetCardResponseDto, '명함 목록')
@ApiErrorResponse('VAL_001', '페이지네이션 파라미터 오류')
list(
@CurrentUser() user: AuthUser,
@Query() query: CursorPaginationQueryDto,
): Promise<PaginatedResult<PetCardResponseDto>> {
return this.cards.list(user.userId, query);
}
@Post()
@ApiOperation({ summary: '새 명함 (최대 10장, 코드는 서버가 발급)' })
@ApiStandardResponse(PetCardResponseDto, {
status: 201,
description: '만든 명함',
})
@ApiErrorResponse('CARD_001', '명함이 가득 참 (최대 10장)')
@ApiErrorResponse('RES_001', '아이를 찾을 수 없음')
@ApiErrorResponse('VAL_001', '입력값 검증 실패')
create(
@CurrentUser() user: AuthUser,
@Body() dto: CreatePetCardDto,
): Promise<PetCardResponseDto> {
return this.cards.create(user.userId, dto);
}
@Put(':id')
@ApiOperation({ summary: '명함 꾸미기 바꾸기' })
@ApiStandardResponse(PetCardResponseDto, { description: '바꾼 명함' })
@ApiErrorResponse('RES_001', '명함을 찾을 수 없음')
@ApiErrorResponse('VAL_001', '입력값 검증 실패')
update(
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdatePetCardDto,
): Promise<PetCardResponseDto> {
return this.cards.update(user.userId, id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@SkipTransform() // 204 — 본문이 없다
@ApiOperation({ summary: '명함 삭제' })
@ApiNoContentResponse({ description: '삭제 완료' })
@ApiErrorResponse('RES_001', '명함을 찾을 수 없음')
async remove(
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
): Promise<void> {
await this.cards.remove(user.userId, id);
}
}
// 아이의 대표 명함 — 아이의 하위 리소스로 둔다
@ApiTags('PetCard')
@ApiBearerAuth()
@ApiErrorResponse('AUTH_001', '로그인 필요')
@Controller('pets')
export class RepresentativeCardController {
constructor(private readonly cards: PetCardService) {}
@Put(':petId/representative-card')
@HttpCode(HttpStatus.NO_CONTENT)
@SkipTransform() // 204 — 본문이 없다
@ApiOperation({ summary: '대표 명함 정하기' })
@ApiNoContentResponse({ description: '대표 명함으로 정함' })
@ApiErrorResponse('RES_001', '명함을 찾을 수 없음')
@ApiErrorResponse('VAL_001', '그 아이의 명함이 아님')
async set(
@CurrentUser() user: AuthUser,
@Param('petId', ParseUUIDPipe) petId: string,
@Body() dto: SetRepresentativeDto,
): Promise<void> {
await this.cards.setRepresentative(user.userId, petId, dto.cardId);
}
@Delete(':petId/representative-card')
@HttpCode(HttpStatus.NO_CONTENT)
@SkipTransform() // 204 — 본문이 없다
@ApiOperation({ summary: '대표 명함 지정 풀기' })
@ApiNoContentResponse({ description: '지정을 풀었음' })
@ApiErrorResponse('RES_001', '아이를 찾을 수 없음')
async clear(
@CurrentUser() user: AuthUser,
@Param('petId', ParseUUIDPipe) petId: string,
): Promise<void> {
await this.cards.clearRepresentative(user.userId, petId);
}
}
@@ -0,0 +1,211 @@
import {
ApiProperty,
ApiPropertyOptional,
OmitType,
PartialType,
} from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import {
ArrayMaxSize,
IsArray,
IsBoolean,
IsIn,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
IsUUID,
Max,
MaxLength,
Min,
ValidateIf,
} from 'class-validator';
import { IsHexColor, IsIsoDate, IsShortStringList } from '@/common/validators';
import { PET_GENDERS, PET_LIMITS, WEIGHT_UNITS } from '../entities/pet.entity';
import type { PetGender, WeightUnit } from '../entities/pet.entity';
/** 앞뒤 공백을 떼고, 비면 null — 선택 입력(혈액형·병원·메모)은 빈 칸을 "없음"으로 저장한다 */
const trimToNull = ({ value }: { value: unknown }) => {
if (typeof value !== 'string') return value;
const trimmed = value.trim();
return trimmed === '' ? null : trimmed;
};
const trim = ({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() : value;
/** 아이 등록 — 앱의 등록 화면 입력값 (PetInput) */
export class CreatePetDto {
@ApiProperty({
description:
'목록 띠·칩에 쓰는 고유색 (#RRGGBB) — 앱이 등록 순서대로 정한다',
example: '#FF9B6A',
})
@IsHexColor()
tint!: string;
@ApiProperty({
description: `이름 (최대 ${PET_LIMITS.name}자)`,
example: '콩이',
})
@Transform(trim)
@IsString({ message: '이름은 문자로 입력해 주세요.' })
@IsNotEmpty({ message: '이름을 입력해 주세요.' })
@MaxLength(PET_LIMITS.name, {
message: `이름은 ${PET_LIMITS.name}자 이하로 입력해 주세요.`,
})
name!: string;
@ApiProperty({ description: '대분류 (강아지·고양이 …)', example: '강아지' })
@IsString({ message: '종류를 골라 주세요.' })
@IsNotEmpty({ message: '종류를 골라 주세요.' })
@MaxLength(PET_LIMITS.species, { message: '종류가 올바르지 않습니다.' })
species!: string;
@ApiProperty({
description: '세부 품종 — 고르지 않았으면 빈 문자열',
example: '말티즈',
})
@Transform(trim)
@IsString({ message: '품종은 문자로 입력해 주세요.' })
@MaxLength(PET_LIMITS.breed, {
message: `품종은 ${PET_LIMITS.breed}자 이하로 입력해 주세요.`,
})
breed!: string;
@ApiProperty({ description: '성별', enum: PET_GENDERS, example: 'male' })
@IsIn(PET_GENDERS, { message: '성별을 골라 주세요.' })
gender!: PetGender;
@ApiProperty({ description: '중성화 여부', example: true })
@IsBoolean({ message: '중성화 여부를 골라 주세요.' })
neutered!: boolean;
@ApiProperty({ description: '생일 (YYYY-MM-DD)', example: '2021-09-14' })
@IsIsoDate()
birthDate!: string;
@ApiProperty({ description: '몸무게 (weightUnit 단위)', example: 3.2 })
@IsNumber(
{ allowNaN: false, allowInfinity: false },
{ message: '몸무게는 숫자로 입력해 주세요.' },
)
@Min(0, { message: '몸무게는 0 이상이어야 합니다.' })
@Max(PET_LIMITS.maxWeight, { message: '몸무게가 너무 큽니다.' })
weight!: number;
@ApiProperty({
description: '몸무게 단위',
enum: WEIGHT_UNITS,
example: 'kg',
})
@IsIn(WEIGHT_UNITS, { message: '몸무게 단위를 골라 주세요.' })
weightUnit!: WeightUnit;
@ApiPropertyOptional({
description: '사진 파일 ID (POST /api/files 로 올린 이미지). 없으면 null',
nullable: true,
type: String,
})
@IsOptional()
@IsUUID('4', { message: '사진 정보가 올바르지 않습니다.' })
photoFileId?: string | null;
@ApiPropertyOptional({
description: '동물등록번호',
nullable: true,
type: String,
})
@IsOptional()
@Transform(trimToNull)
@IsString()
@MaxLength(PET_LIMITS.microchip, { message: '동물등록번호가 너무 깁니다.' })
microchip?: string | null;
@ApiPropertyOptional({ description: '혈액형', nullable: true, type: String })
@IsOptional()
@Transform(trimToNull)
@IsString()
@MaxLength(PET_LIMITS.bloodType, { message: '혈액형이 너무 깁니다.' })
bloodType?: string | null;
@ApiPropertyOptional({
description: '다니는 병원',
nullable: true,
type: String,
})
@IsOptional()
@Transform(trimToNull)
@IsString()
@MaxLength(PET_LIMITS.vet, {
message: `병원 이름은 ${PET_LIMITS.vet}자 이하로 입력해 주세요.`,
})
vet?: string | null;
@ApiPropertyOptional({ description: '메모', nullable: true, type: String })
@IsOptional()
@Transform(trimToNull)
@IsString()
@MaxLength(PET_LIMITS.memo, {
message: `메모는 ${PET_LIMITS.memo}자 이하로 입력해 주세요.`,
})
memo?: string | null;
@ApiProperty({ description: '알레르기', type: [String], example: ['닭고기'] })
@IsShortStringList(PET_LIMITS.listItems, PET_LIMITS.listItemLength)
allergies!: string[];
@ApiProperty({ description: '현재 보유 질환', type: [String] })
@IsShortStringList(PET_LIMITS.listItems, PET_LIMITS.listItemLength)
conditions!: string[];
@ApiProperty({ description: '염려되는 질환', type: [String] })
@IsShortStringList(PET_LIMITS.listItems, PET_LIMITS.listItemLength)
watchConditions!: string[];
}
/**
* 아이 정보 수정 — 보낸 값만 바꾼다.
* 고유색은 앱이 정한 값이라 바꾸지 않고, 보관 기록은 /archive 로만 바꾼다.
*/
export class UpdatePetDto extends PartialType(
OmitType(CreatePetDto, ['tint'] as const),
) {}
/** 추억 보관함으로 옮기기 */
export class ArchivePetDto {
@ApiProperty({
description: '함께한 첫날 (YYYY-MM-DD)',
example: '2015-04-02',
})
@IsIsoDate()
from!: string;
@ApiProperty({ description: '마지막 날 (YYYY-MM-DD)', example: '2026-10-01' })
@IsIsoDate()
to!: string;
@ApiPropertyOptional({
description: '함께한 시간에 대한 메모',
nullable: true,
type: String,
})
@IsOptional()
@Transform(trimToNull)
@ValidateIf((dto: ArchivePetDto) => dto.memo !== null)
@IsString()
@MaxLength(PET_LIMITS.memo, {
message: `메모는 ${PET_LIMITS.memo}자 이하로 입력해 주세요.`,
})
memo?: string | null;
}
/** 우리 아이 순서 바꾸기 — [ids] 순서로 두고, 빠진 아이는 원래 순서대로 뒤에 둔다 */
export class ReorderPetsDto {
@ApiProperty({ description: '앞에서부터 놓을 아이 id', type: [String] })
@IsArray({ message: '순서 정보가 올바르지 않습니다.' })
@ArrayMaxSize(500, { message: '순서 정보가 올바르지 않습니다.' })
@IsUUID('4', { each: true, message: '순서 정보가 올바르지 않습니다.' })
ids!: string[];
}
@@ -0,0 +1,126 @@
import { ApiProperty } from '@nestjs/swagger';
import { PET_GENDERS, WEIGHT_UNITS } from '../entities/pet.entity';
import type { Pet, PetGender, WeightUnit } from '../entities/pet.entity';
/** 추억 보관함 기록 */
export class PetArchiveDto {
@ApiProperty({
description: '함께한 첫날 (YYYY-MM-DD)',
example: '2015-04-02',
})
from!: string;
@ApiProperty({ description: '마지막 날 (YYYY-MM-DD)', example: '2026-10-01' })
to!: string;
@ApiProperty({ description: '보관한 때 (ISO 8601 UTC)' })
archivedAt!: string;
@ApiProperty({ description: '메모', nullable: true, type: String })
memo!: string | null;
}
/** 반려동물 — 앱의 Pet 모델과 같은 모양 */
export class PetResponseDto {
@ApiProperty({ description: '아이 ID' })
id!: string;
@ApiProperty({ description: '고유색 (#RRGGBB)', example: '#FF9B6A' })
tint!: string;
@ApiProperty({ example: '콩이' })
name!: string;
@ApiProperty({ description: '대분류', example: '강아지' })
species!: string;
@ApiProperty({
description: '세부 품종 (없으면 빈 문자열)',
example: '말티즈',
})
breed!: string;
@ApiProperty({ enum: PET_GENDERS })
gender!: PetGender;
@ApiProperty()
neutered!: boolean;
@ApiProperty({ description: '생일 (YYYY-MM-DD)', example: '2021-09-14' })
birthDate!: string;
@ApiProperty({ example: 3.2 })
weight!: number;
@ApiProperty({ enum: WEIGHT_UNITS })
weightUnit!: WeightUnit;
@ApiProperty({
description: '사진 파일 ID — GET /api/files/{id}',
nullable: true,
type: String,
})
photoFileId!: string | null;
@ApiProperty({ nullable: true, type: String })
microchip!: string | null;
@ApiProperty({ nullable: true, type: String })
bloodType!: string | null;
@ApiProperty({ nullable: true, type: String })
vet!: string | null;
@ApiProperty({ nullable: true, type: String })
memo!: string | null;
@ApiProperty({ type: [String] })
allergies!: string[];
@ApiProperty({ type: [String] })
conditions!: string[];
@ApiProperty({ type: [String] })
watchConditions!: string[];
@ApiProperty({
description: '추억 보관함 기록 — 함께 지내는 아이는 null',
nullable: true,
type: PetArchiveDto,
})
archive!: PetArchiveDto | null;
static from(pet: Pet): PetResponseDto {
const { archiveFrom, archiveTo, archivedAt } = pet;
return {
id: pet.id,
tint: pet.tint,
name: pet.name,
species: pet.species,
breed: pet.breed,
gender: pet.gender,
neutered: pet.neutered,
birthDate: pet.birthDate,
weight: pet.weight,
weightUnit: pet.weightUnit,
photoFileId: pet.photoFileId,
microchip: pet.microchip,
bloodType: pet.bloodType,
vet: pet.vet,
memo: pet.memo,
allergies: pet.allergies,
conditions: pet.conditions,
watchConditions: pet.watchConditions,
archive:
archiveFrom !== null && archiveTo !== null && archivedAt !== null
? {
from: archiveFrom,
to: archiveTo,
archivedAt: archivedAt.toISOString(),
memo: pet.archiveMemo,
}
: null,
};
}
}
@@ -0,0 +1,133 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { StoredFile } from '@/modules/upload/entities/stored-file.entity';
import { User } from '@/modules/user/entities/user.entity';
/** 입력 한도 — 앱 화면에는 제한이 없어 넉넉하게 두고, 과도한 입력만 막는다 */
export const PET_LIMITS = {
name: 30,
species: 20,
breed: 60,
microchip: 30,
bloodType: 20,
vet: 60,
memo: 1000,
listItems: 50,
listItemLength: 30,
maxWeight: 100_000,
} as const;
export const PET_GENDERS = ['male', 'female'] as const;
export type PetGender = (typeof PET_GENDERS)[number];
export const WEIGHT_UNITS = ['kg', 'g'] as const;
export type WeightUnit = (typeof WEIGHT_UNITS)[number];
/** 등록한 반려동물 — 추억 보관함으로 옮긴 아이도 같은 테이블에 남는다(archive* 값이 채워진다) */
@Entity('pets')
export class Pet {
@PrimaryGeneratedColumn('uuid')
id!: string;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'owner_id' })
owner?: User;
@Index()
@Column({ type: 'uuid' })
ownerId!: string;
/** 우리 아이 순서 — 작은 값이 앞 */
@Column({ type: 'integer' })
sortOrder!: number;
/** 목록 띠·칩에 쓰는 아이의 고유색 `#RRGGBB` (앱이 등록 순서대로 정한다) */
@Column({ type: 'varchar', length: 7 })
tint!: string;
@Column({ type: 'varchar', length: PET_LIMITS.name })
name!: string;
/** 대분류 — 강아지·고양이 … */
@Column({ type: 'varchar', length: PET_LIMITS.species })
species!: string;
/** 세부 품종 — 고르지 않았으면 빈 문자열 */
@Column({ type: 'varchar', length: PET_LIMITS.breed, default: '' })
breed!: string;
@Column({ type: 'varchar', length: 6 })
gender!: PetGender;
@Column({ type: 'boolean' })
neutered!: boolean;
/** 생일 (시각 없음, YYYY-MM-DD) */
@Column({ type: 'date' })
birthDate!: string;
@Column({ type: 'double precision' })
weight!: number;
@Column({ type: 'varchar', length: 2 })
weightUnit!: WeightUnit;
/** 아이 사진 — 참조 중인 파일은 지울 수 없게 둔다 (UploadService.releaseIfUnused) */
@ManyToOne(() => StoredFile, { nullable: true, onDelete: 'NO ACTION' })
@JoinColumn({ name: 'photo_file_id' })
photoFile?: StoredFile | null;
@Column({ type: 'uuid', nullable: true })
photoFileId!: string | null;
@Column({ type: 'varchar', length: PET_LIMITS.microchip, nullable: true })
microchip!: string | null;
@Column({ type: 'varchar', length: PET_LIMITS.bloodType, nullable: true })
bloodType!: string | null;
@Column({ type: 'varchar', length: PET_LIMITS.vet, nullable: true })
vet!: string | null;
@Column({ type: 'varchar', length: PET_LIMITS.memo, nullable: true })
memo!: string | null;
@Column({ type: 'text', array: true, default: '{}' })
allergies!: string[];
/** 현재 보유 질환 */
@Column({ type: 'text', array: true, default: '{}' })
conditions!: string[];
/** 염려되는 질환 */
@Column({ type: 'text', array: true, default: '{}' })
watchConditions!: string[];
/** 추억 보관함 — 함께한 첫날·마지막 날·보관한 때·메모. 함께 지내는 아이는 모두 null */
@Column({ type: 'date', nullable: true })
archiveFrom!: string | null;
@Column({ type: 'date', nullable: true })
archiveTo!: string | null;
@Column({ type: 'timestamptz', nullable: true })
archivedAt!: Date | null;
@Column({ type: 'varchar', length: PET_LIMITS.memo, nullable: true })
archiveMemo!: string | null;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
}
+27
View File
@@ -0,0 +1,27 @@
import { EntityManager } from 'typeorm';
/** 아이에게 생긴 일 — 다른 도메인이 자기 기록을 맞춘다 */
export interface PetLifecycleEvent {
readonly ownerId: string;
readonly petId: string;
/** 아이를 바꾸는 트랜잭션 — 같은 트랜잭션에서 정리해야 반쯤 지워진 상태가 남지 않는다 */
readonly manager: EntityManager;
}
/**
* 아이의 삭제·보관을 따라 정리해야 하는 도메인(일정·다이어리·명함)이 구현한다.
* 각 모듈이 onModuleInit 에서 PetService.registerListener 로 등록한다 —
* 반려동물 모듈이 다른 도메인을 알지 않아도 되고(의존 방향: 다른 도메인 → 반려동물), 정리는 한 트랜잭션에 묶인다.
*/
export interface PetLifecycleListener {
/**
* 아이를 지우기 직전. 그 아이를 대상에서 떼는 등 정리를 한다.
* 정리하면서 더 쓰지 않게 된 파일 id 를 돌려주면 커밋한 뒤 지운다.
*/
onPetDeleting?(
event: PetLifecycleEvent,
): Promise<ReadonlyArray<string | null> | void>;
/** 아이를 추억 보관함으로 옮긴 직후 */
onPetArchived?(event: PetLifecycleEvent): Promise<void>;
}
+22
View File
@@ -0,0 +1,22 @@
import { reorderIds } from './pet-order';
describe('reorderIds', () => {
const current = ['a', 'b', 'c', 'd'];
it('요청한 순서로 앞에 두고 빠진 아이는 원래 순서대로 뒤에 둔다', () => {
expect(reorderIds(current, ['c', 'a'])).toEqual(['c', 'a', 'b', 'd']);
});
it('모르는 id 와 중복은 무시한다', () => {
expect(reorderIds(current, ['x', 'b', 'b', 'a'])).toEqual([
'b',
'a',
'c',
'd',
]);
});
it('빈 요청이면 순서가 그대로다', () => {
expect(reorderIds(current, [])).toEqual(current);
});
});
+15
View File
@@ -0,0 +1,15 @@
// 우리 아이 순서 — 부수효과 없는 순수 함수 (앱의 reorderPets 와 같은 규칙)
/**
* [requested] 순서로 줄 세운 id 목록. 빠진 아이(추억 보관함 등)는 원래 순서대로 뒤에 붙인다.
* [current] 에 없는 id 는 무시하고, 중복은 처음 나온 자리만 쓴다.
*/
export function reorderIds(
current: readonly string[],
requested: readonly string[],
): string[] {
const known = new Set(current);
const front = [...new Set(requested)].filter((id) => known.has(id));
const placed = new Set(front);
return [...front, ...current.filter((id) => !placed.has(id))];
}
+152
View File
@@ -0,0 +1,152 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
Put,
Query,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiNoContentResponse,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import {
ApiErrorResponse,
ApiPaginatedResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import { CurrentUser } from '@/common/decorators/current-user.decorator';
import type { AuthUser } from '@/common/decorators/current-user.decorator';
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
import {
CursorPaginationQueryDto,
PaginatedResult,
} from '@/common/dto/pagination.dto';
import {
ArchivePetDto,
CreatePetDto,
ReorderPetsDto,
UpdatePetDto,
} from './dto/pet-input.dto';
import { PetResponseDto } from './dto/pet-response.dto';
import { PetService } from './pet.service';
// 반려동물 — 라우팅과 입출력만 담당, 비즈니스 로직은 모두 service 위임
@ApiTags('Pet')
@ApiBearerAuth()
@ApiErrorResponse('AUTH_001', '로그인 필요')
@Controller('pets')
export class PetController {
constructor(private readonly petService: PetService) {}
@Get()
@ApiOperation({
summary: '우리 아이 목록 (커서 페이지네이션, 정한 순서대로)',
description:
'추억 보관함의 아이도 함께 온다 — archive 가 null 이 아니면 보관된 아이',
})
@ApiPaginatedResponse(PetResponseDto, '아이 목록')
@ApiErrorResponse('VAL_001', '페이지네이션 파라미터 오류')
list(
@CurrentUser() user: AuthUser,
@Query() query: CursorPaginationQueryDto,
): Promise<PaginatedResult<PetResponseDto>> {
return this.petService.list(user.userId, query);
}
@Post()
@ApiOperation({ summary: '아이 등록' })
@ApiStandardResponse(PetResponseDto, {
status: 201,
description: '등록한 아이',
})
@ApiErrorResponse('VAL_001', '입력값 검증 실패 · 내 이미지 파일이 아님')
create(
@CurrentUser() user: AuthUser,
@Body() dto: CreatePetDto,
): Promise<PetResponseDto> {
return this.petService.create(user.userId, dto);
}
@Put('order')
@HttpCode(HttpStatus.NO_CONTENT)
@SkipTransform() // 204 — 본문이 없다
@ApiOperation({
summary: '우리 아이 순서 바꾸기',
description: 'ids 순서로 앞에 두고, 빠진 아이는 원래 순서대로 뒤에 둔다',
})
@ApiNoContentResponse({ description: '순서 저장 완료' })
@ApiErrorResponse('VAL_001', '내 아이가 아닌 id 가 섞여 있음')
async reorder(
@CurrentUser() user: AuthUser,
@Body() dto: ReorderPetsDto,
): Promise<void> {
await this.petService.reorder(user.userId, dto);
}
@Patch(':id')
@ApiOperation({ summary: '아이 정보 수정 (보낸 값만)' })
@ApiStandardResponse(PetResponseDto, { description: '수정한 아이' })
@ApiErrorResponse('RES_001', '아이를 찾을 수 없음')
@ApiErrorResponse('VAL_001', '입력값 검증 실패')
update(
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdatePetDto,
): Promise<PetResponseDto> {
return this.petService.update(user.userId, id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@SkipTransform() // 204 — 본문이 없다
@ApiOperation({
summary: '아이 삭제',
description:
'명함은 함께 지워지고, 일정·다이어리에서는 그 아이만 대상에서 뗀다',
})
@ApiNoContentResponse({ description: '삭제 완료' })
@ApiErrorResponse('RES_001', '아이를 찾을 수 없음')
async remove(
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
): Promise<void> {
await this.petService.remove(user.userId, id);
}
@Put(':id/archive')
@ApiOperation({
summary: '추억 보관함으로 옮기기',
description: '정보·명함·다이어리는 그대로 두고, 일정에서는 그 아이를 뗀다',
})
@ApiStandardResponse(PetResponseDto, { description: '보관한 아이' })
@ApiErrorResponse('RES_001', '아이를 찾을 수 없음')
@ApiErrorResponse('VAL_001', '날짜 형식 오류 · 첫날이 마지막 날보다 늦음')
archive(
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: ArchivePetDto,
): Promise<PetResponseDto> {
return this.petService.archive(user.userId, id, dto);
}
@Delete(':id/archive')
@ApiOperation({ summary: '추억 보관함에서 다시 불러오기' })
@ApiStandardResponse(PetResponseDto, { description: '다시 불러온 아이' })
@ApiErrorResponse('RES_001', '아이를 찾을 수 없음')
restore(
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
): Promise<PetResponseDto> {
return this.petService.restore(user.userId, id);
}
}
+15
View File
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UploadModule } from '@/modules/upload/upload.module';
import { Pet } from './entities/pet.entity';
import { PetController } from './pet.controller';
import { PetService } from './pet.service';
@Module({
imports: [TypeOrmModule.forFeature([Pet]), UploadModule],
controllers: [PetController],
providers: [PetService],
exports: [PetService],
})
export class PetModule {}
+136
View File
@@ -0,0 +1,136 @@
import { DataSource, EntityManager, Repository } from 'typeorm';
import { BusinessException } from '@/common/errors/business.exception';
import { UploadService } from '@/modules/upload/upload.service';
import { Pet } from './entities/pet.entity';
import { PetLifecycleListener } from './pet-lifecycle';
import { PetService } from './pet.service';
const OWNER = 'owner-1';
const PET_ID = 'pet-1';
function pet(overrides: Partial<Pet> = {}): Pet {
return {
id: PET_ID,
ownerId: OWNER,
photoFileId: 'photo-1',
archiveFrom: null,
archiveTo: null,
archivedAt: null,
archiveMemo: null,
...overrides,
} as Pet;
}
describe('PetService', () => {
const manager = {
delete: jest.fn(),
save: jest.fn((v: unknown) => Promise.resolve(v)),
merge: jest.fn((_e: unknown, a: object, b: object) => ({ ...a, ...b })),
};
const dataSource = {
transaction: jest.fn((work: (m: EntityManager) => Promise<unknown>) =>
work(manager as unknown as EntityManager),
),
};
const repo = { findOneBy: jest.fn(), count: jest.fn() };
const uploads = { releaseIfUnused: jest.fn(), assertOwnedImage: jest.fn() };
let service: PetService;
beforeEach(() => {
jest.clearAllMocks();
service = new PetService(
repo as unknown as Repository<Pet>,
dataSource as unknown as DataSource,
uploads as unknown as UploadService,
);
});
it('아이를 지우면 리스너가 같은 트랜잭션에서 정리하고, 커밋 뒤 쓰지 않는 파일을 지운다', async () => {
repo.findOneBy.mockResolvedValue(pet());
const order: string[] = [];
const listener: PetLifecycleListener = {
onPetDeleting: jest.fn((event) => {
order.push('listener');
expect(event).toEqual({ ownerId: OWNER, petId: PET_ID, manager });
return Promise.resolve(['card-bg-1']);
}),
};
manager.delete.mockImplementation(() => {
order.push('delete');
return Promise.resolve({ affected: 1 });
});
uploads.releaseIfUnused.mockImplementation(() => {
order.push('release');
return Promise.resolve();
});
service.registerListener(listener);
await service.remove(OWNER, PET_ID);
expect(order).toEqual(['listener', 'delete', 'release']);
expect(uploads.releaseIfUnused).toHaveBeenCalledWith([
'photo-1',
'card-bg-1',
]);
});
it('남의 아이는 지울 수 없다 (RES_001)', async () => {
repo.findOneBy.mockResolvedValue(null);
await expect(service.remove(OWNER, PET_ID)).rejects.toMatchObject({
code: 'RES_001',
});
expect(dataSource.transaction).not.toHaveBeenCalled();
});
it('보관하면 리스너에 알린다', async () => {
repo.findOneBy.mockResolvedValue(pet());
const onPetArchived = jest.fn(() => Promise.resolve());
service.registerListener({ onPetArchived });
const result = await service.archive(OWNER, PET_ID, {
from: '2021-09-14',
to: '2026-10-01',
memo: null,
});
expect(onPetArchived).toHaveBeenCalledWith(
expect.objectContaining({ ownerId: OWNER, petId: PET_ID }),
);
expect(result.archive).toMatchObject({
from: '2021-09-14',
to: '2026-10-01',
});
});
it('보관 기간의 첫날이 마지막 날보다 늦으면 VAL_001', async () => {
await expect(
service.archive(OWNER, PET_ID, { from: '2026-10-02', to: '2026-10-01' }),
).rejects.toBeInstanceOf(BusinessException);
expect(repo.findOneBy).not.toHaveBeenCalled();
});
describe('대상 확인', () => {
it('나(self)만 있으면 DB 를 보지 않는다', async () => {
await service.assertOwnedTargets(OWNER, ['self']);
expect(repo.count).not.toHaveBeenCalled();
});
it('내 아이가 아닌 id 가 섞이면 VAL_001', async () => {
repo.count.mockResolvedValue(1);
await expect(
service.assertOwnedTargets(OWNER, ['self', 'pet-a', 'pet-b']),
).rejects.toMatchObject({ code: 'VAL_001' });
});
it('같은 아이가 두 번 있어도 한 번으로 센다', async () => {
repo.count.mockResolvedValue(1);
await expect(
service.assertOwnedTargets(OWNER, ['pet-a', 'pet-a']),
).resolves.toBeUndefined();
});
});
});
+242
View File
@@ -0,0 +1,242 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { DataSource, In, Repository } from 'typeorm';
import { keysetPaginate, mapPage } from '@/common/dto/keyset-pagination';
import {
CursorPaginationQueryDto,
PaginatedResult,
} from '@/common/dto/pagination.dto';
import { BusinessException } from '@/common/errors/business.exception';
import { SELF_TARGET_ID } from '@/common/validators';
import { UploadService } from '@/modules/upload/upload.service';
import {
ArchivePetDto,
CreatePetDto,
ReorderPetsDto,
UpdatePetDto,
} from './dto/pet-input.dto';
import { PetResponseDto } from './dto/pet-response.dto';
import { Pet } from './entities/pet.entity';
import { PetLifecycleListener } from './pet-lifecycle';
import { reorderIds } from './pet-order';
// 반려동물 — 등록·수정·삭제·순서·추억 보관함
@Injectable()
export class PetService {
private readonly logger = new Logger(PetService.name);
private readonly listeners: PetLifecycleListener[] = [];
constructor(
@InjectRepository(Pet)
private readonly pets: Repository<Pet>,
@InjectDataSource()
private readonly dataSource: DataSource,
private readonly uploads: UploadService,
) {}
/** 아이의 삭제·보관을 따라 정리할 도메인을 등록한다 (pet-lifecycle.ts) */
registerListener(listener: PetLifecycleListener): void {
this.listeners.push(listener);
}
/** 우리 아이 목록 — 정한 순서대로. 추억 보관함의 아이도 함께 온다(archive 값으로 구분) */
async list(
ownerId: string,
query: CursorPaginationQueryDto,
): Promise<PaginatedResult<PetResponseDto>> {
const qb = this.pets
.createQueryBuilder('pet')
.where('pet.owner_id = :ownerId', { ownerId });
const page = await keysetPaginate(
qb,
[
{ column: 'pet.sort_order', cast: 'integer', read: (p) => p.sortOrder },
{ column: 'pet.id', cast: 'uuid', read: (p) => p.id },
],
query,
);
return mapPage(page, (p) => PetResponseDto.from(p));
}
async create(ownerId: string, dto: CreatePetDto): Promise<PetResponseDto> {
await this.uploads.assertOwnedImage(dto.photoFileId, ownerId);
const last = await this.pets.findOne({
where: { ownerId },
order: { sortOrder: 'DESC' },
select: { sortOrder: true },
});
const pet = await this.pets.save(
this.pets.create({
...dto,
ownerId,
sortOrder: (last?.sortOrder ?? -1) + 1,
photoFileId: dto.photoFileId ?? null,
microchip: dto.microchip ?? null,
bloodType: dto.bloodType ?? null,
vet: dto.vet ?? null,
memo: dto.memo ?? null,
archiveFrom: null,
archiveTo: null,
archivedAt: null,
archiveMemo: null,
}),
);
return PetResponseDto.from(pet);
}
async update(
ownerId: string,
petId: string,
dto: UpdatePetDto,
): Promise<PetResponseDto> {
const pet = await this.findOwned(ownerId, petId);
const previousPhoto = pet.photoFileId;
if (dto.photoFileId !== undefined) {
await this.uploads.assertOwnedImage(dto.photoFileId, ownerId);
}
// 보낸 값만 바꾼다 (undefined 는 그대로)
const changes = Object.fromEntries(
Object.entries(dto).filter(([, value]) => value !== undefined),
) as Partial<Pet>;
const saved = await this.pets.save(this.pets.merge(pet, changes));
if (previousPhoto !== saved.photoFileId) {
await this.uploads.releaseIfUnused([previousPhoto]);
}
return PetResponseDto.from(saved);
}
/**
* 아이를 지운다 — 명함은 FK 로 함께 지워지고, 일정·다이어리는 등록된 리스너가 그 아이를 대상에서 뗀다.
* 정리로 쓰지 않게 된 사진은 커밋한 뒤 지운다.
*/
async remove(ownerId: string, petId: string): Promise<void> {
const pet = await this.findOwned(ownerId, petId);
const released = await this.dataSource.transaction(async (manager) => {
const files: Array<string | null> = [pet.photoFileId];
for (const listener of this.listeners) {
const more = await listener.onPetDeleting?.({
ownerId,
petId,
manager,
});
if (more) files.push(...more);
}
await manager.delete(Pet, { id: petId, ownerId });
return files;
});
await this.uploads.releaseIfUnused(released);
this.logger.log(`아이 삭제: ${petId} (회원 ${ownerId})`);
}
/** 추억 보관함으로 옮긴다 — 정보·명함·다이어리는 그대로, 일정은 리스너가 정리한다 */
async archive(
ownerId: string,
petId: string,
dto: ArchivePetDto,
): Promise<PetResponseDto> {
if (dto.from > dto.to) {
throw new BusinessException('VAL_001', {
context: { reason: 'archive-range', from: dto.from, to: dto.to },
});
}
const pet = await this.findOwned(ownerId, petId);
const saved = await this.dataSource.transaction(async (manager) => {
const archived = await manager.save(
manager.merge(Pet, pet, {
archiveFrom: dto.from,
archiveTo: dto.to,
archivedAt: new Date(),
archiveMemo: dto.memo ?? null,
}),
);
for (const listener of this.listeners) {
await listener.onPetArchived?.({ ownerId, petId, manager });
}
return archived;
});
return PetResponseDto.from(saved);
}
/** 추억 보관함에서 다시 불러온다 — 보관할 때 지운 일정은 돌아오지 않는다 */
async restore(ownerId: string, petId: string): Promise<PetResponseDto> {
const pet = await this.findOwned(ownerId, petId);
const saved = await this.pets.save(
this.pets.merge(pet, {
archiveFrom: null,
archiveTo: null,
archivedAt: null,
archiveMemo: null,
}),
);
return PetResponseDto.from(saved);
}
/** 순서 바꾸기 — 요청에 없는 아이는 원래 순서대로 뒤에 둔다 */
async reorder(ownerId: string, dto: ReorderPetsDto): Promise<void> {
await this.dataSource.transaction(async (manager) => {
const pets = await manager.find(Pet, {
where: { ownerId },
order: { sortOrder: 'ASC', id: 'ASC' },
select: { id: true, sortOrder: true },
// 동시에 순서를 바꾸면 한쪽 결과만 남게 잠근다
lock: { mode: 'pessimistic_write' },
});
const known = new Set(pets.map((p) => p.id));
const unknown = dto.ids.filter((id) => !known.has(id));
if (unknown.length > 0) {
throw new BusinessException('VAL_001', {
context: { reason: 'unknown-pet', petIds: unknown },
});
}
const order = reorderIds(
pets.map((p) => p.id),
dto.ids,
);
const current = new Map(pets.map((p) => [p.id, p.sortOrder]));
for (const [index, id] of order.entries()) {
if (current.get(id) !== index) {
await manager.update(Pet, { id }, { sortOrder: index });
}
}
});
}
/**
* 일정·다이어리 대상 확인 — 모두 "나(self)" 이거나 내 아이여야 한다. 하나라도 아니면 VAL_001.
* 추억 보관함의 아이도 내 아이로 본다 (보관된 아이의 다이어리를 고칠 수 있다).
*/
async assertOwnedTargets(
ownerId: string,
targetIds: readonly string[],
): Promise<void> {
const petIds = [...new Set(targetIds)].filter(
(id) => id !== SELF_TARGET_ID,
);
if (petIds.length === 0) return;
const found = await this.pets.count({
where: { ownerId, id: In(petIds) },
});
if (found !== petIds.length) {
throw new BusinessException('VAL_001', {
context: { reason: 'unknown-target', targetIds },
});
}
}
/** 내 아이 하나 — 남의 아이나 없는 아이는 똑같이 RES_001 */
async findOwned(ownerId: string, petId: string): Promise<Pet> {
const pet = await this.pets.findOneBy({ id: petId, ownerId });
if (!pet) throw new BusinessException('RES_001', { context: { petId } });
return pet;
}
}
@@ -0,0 +1,282 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import {
ArrayMaxSize,
ArrayMinSize,
IsArray,
IsIn,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
Max,
MaxLength,
Min,
ValidateNested,
} from 'class-validator';
import {
IsHexColor,
IsHourMinute,
IsIsoDate,
IsTargetIdList,
} from '@/common/validators';
import {
EVENT_CATEGORIES,
EVENT_KINDS,
SCHEDULE_LIMITS,
} from '../entities/schedule-details';
import type {
EventCategory,
EventKind,
ScheduleDetails,
} from '../entities/schedule-details';
import { REPEAT_MODES, REPEAT_UNITS } from '../entities/repeat-rule.entity';
import type {
RepeatMode,
RepeatRule,
RepeatRuleCompletion,
RepeatUnit,
} from '../entities/repeat-rule.entity';
import type { ScheduleEvent } from '../entities/schedule-event.entity';
const trim = ({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() : value;
/** 앞뒤 공백을 떼고, 비면 null */
const trimToNull = ({ value }: { value: unknown }) => {
if (typeof value !== 'string') return value;
const trimmed = value.trim();
return trimmed === '' ? null : trimmed;
};
/** 일정 내용 — 날짜 일정과 반복 규칙이 같이 쓴다 (앱의 EventDetails) */
export class EventDetailsDto {
@ApiProperty({
description: `일정 이름 (최대 ${SCHEDULE_LIMITS.title}자)`,
example: '심장사상충 약',
})
@Transform(trim)
@IsString({ message: '일정 이름을 입력해 주세요.' })
@IsNotEmpty({ message: '일정 이름을 입력해 주세요.' })
@MaxLength(SCHEDULE_LIMITS.title, {
message: `일정 이름은 ${SCHEDULE_LIMITS.title}자 이하로 입력해 주세요.`,
})
title!: string;
@ApiProperty({ enum: EVENT_KINDS, example: 'general' })
@IsIn(EVENT_KINDS, { message: '일정 종류가 올바르지 않습니다.' })
kind!: EventKind;
@ApiProperty({ enum: EVENT_CATEGORIES, example: 'med' })
@IsIn(EVENT_CATEGORIES, { message: '일정 분류가 올바르지 않습니다.' })
category!: EventCategory;
@ApiProperty({
description: '대상 — 아이 id 또는 "self"(나). 1개 이상',
type: [String],
example: ['self'],
})
@IsTargetIdList(SCHEDULE_LIMITS.targets)
targetIds!: string[];
@ApiPropertyOptional({
description: '시작 시각 HH:mm — 없으면(null) 하루 종일',
nullable: true,
type: String,
example: '08:00',
})
@IsOptional()
@IsHourMinute()
start?: string | null;
@ApiPropertyOptional({
description: '종료 시각 HH:mm (선택)',
nullable: true,
type: String,
})
@IsOptional()
@IsHourMinute()
end?: string | null;
@ApiPropertyOptional({ description: '장소', nullable: true, type: String })
@IsOptional()
@Transform(trimToNull)
@IsString()
@MaxLength(SCHEDULE_LIMITS.place, {
message: `장소는 ${SCHEDULE_LIMITS.place}자 이하로 입력해 주세요.`,
})
place?: string | null;
@ApiPropertyOptional({
description: '라벨 색 #RRGGBB — 없으면 분류 기본 색',
nullable: true,
type: String,
})
@IsOptional()
@IsHexColor()
labelColor?: string | null;
}
/** 새 날짜 일정 하나 */
export class NewEventDto extends EventDetailsDto {
@ApiProperty({ description: '날짜 (YYYY-MM-DD)', example: '2026-10-11' })
@IsIsoDate()
date!: string;
}
/** 날짜 일정 여러 건 만들기 — 기간 설정은 날짜마다 한 건씩 */
export class CreateEventsDto {
@ApiProperty({
description: `만들 일정 (1~${SCHEDULE_LIMITS.eventsPerRequest}건 — 더 많으면 나눠 보낸다)`,
type: [NewEventDto],
})
@IsArray({ message: '일정 정보가 올바르지 않습니다.' })
@ArrayMinSize(1, { message: '일정 정보가 올바르지 않습니다.' })
@ArrayMaxSize(SCHEDULE_LIMITS.eventsPerRequest, {
message: `한 번에 ${SCHEDULE_LIMITS.eventsPerRequest}건까지 만들 수 있습니다.`,
})
@ValidateNested({ each: true })
@Type(() => NewEventDto)
events!: NewEventDto[];
}
/** 반복 주기 — "2주마다" (앱의 RepeatSpec) */
export class RepeatSpecDto {
@ApiProperty({ enum: REPEAT_MODES, example: 'every' })
@IsIn(REPEAT_MODES, { message: '반복 방식이 올바르지 않습니다.' })
mode!: RepeatMode;
@ApiProperty({ enum: REPEAT_UNITS, example: 'week' })
@IsIn(REPEAT_UNITS, { message: '반복 단위가 올바르지 않습니다.' })
unit!: RepeatUnit;
@ApiProperty({
description: `주기 N (1~${SCHEDULE_LIMITS.maxRepeatN})`,
example: 2,
})
@IsInt({ message: '반복 주기는 정수로 입력해 주세요.' })
@Min(1, { message: '반복 주기는 1 이상이어야 합니다.' })
@Max(SCHEDULE_LIMITS.maxRepeatN, {
message: `반복 주기는 ${SCHEDULE_LIMITS.maxRepeatN} 이하여야 합니다.`,
})
n!: number;
}
/** 반복 규칙 내용 바꾸기 — 주기와 내용을 통째로 바꾼다 */
export class UpdateRuleDto {
@ApiProperty({ type: RepeatSpecDto })
@ValidateNested()
@Type(() => RepeatSpecDto)
spec!: RepeatSpecDto;
@ApiProperty({ type: EventDetailsDto })
@ValidateNested()
@Type(() => EventDetailsDto)
details!: EventDetailsDto;
}
/** 반복 규칙 만들기 */
export class CreateRuleDto extends UpdateRuleDto {
@ApiProperty({
description: '반복을 시작하는 날 (YYYY-MM-DD)',
example: '2026-10-11',
})
@IsIsoDate()
from!: string;
}
/** 일정 내용 응답 */
export class EventDetailsResponseDto {
@ApiProperty() title!: string;
@ApiProperty({ enum: EVENT_KINDS }) kind!: EventKind;
@ApiProperty({ enum: EVENT_CATEGORIES }) category!: EventCategory;
@ApiProperty({ type: [String] }) targetIds!: string[];
@ApiProperty({ nullable: true, type: String }) start!: string | null;
@ApiProperty({ nullable: true, type: String }) end!: string | null;
@ApiProperty({ nullable: true, type: String }) place!: string | null;
@ApiProperty({ nullable: true, type: String }) labelColor!: string | null;
static from(details: ScheduleDetails): EventDetailsResponseDto {
return {
title: details.title,
kind: details.kind,
category: details.category,
targetIds: details.targetIds,
start: details.startTime,
end: details.endTime,
place: details.place,
labelColor: details.labelColor,
};
}
}
/** 날짜 일정 응답 */
export class ScheduleEventResponseDto extends EventDetailsResponseDto {
@ApiProperty() id!: string;
@ApiProperty({ description: '날짜 (YYYY-MM-DD)' })
date!: string;
@ApiProperty({ description: '완료 표시' })
isDone!: boolean;
static fromEvent(event: ScheduleEvent): ScheduleEventResponseDto {
return {
id: event.id,
date: event.date,
isDone: event.isDone,
...EventDetailsResponseDto.from(event.details),
};
}
}
/** 반복 규칙 응답 (앱의 RepeatRule) */
export class RepeatRuleResponseDto {
@ApiProperty() id!: string;
@ApiProperty({ description: '반복을 시작한 날 (YYYY-MM-DD)' })
from!: string;
@ApiProperty({ type: RepeatSpecDto })
spec!: RepeatSpecDto;
@ApiProperty({ type: EventDetailsResponseDto })
details!: EventDetailsResponseDto;
static from(rule: RepeatRule): RepeatRuleResponseDto {
return {
id: rule.id,
from: rule.fromDate,
spec: { mode: rule.mode, unit: rule.unit, n: rule.n },
details: EventDetailsResponseDto.from(rule.details),
};
}
}
/** 반복 일정의 날짜별 완료 표시 응답 */
export class RuleCompletionResponseDto {
@ApiProperty() ruleId!: string;
@ApiProperty({ description: '완료한 날 (YYYY-MM-DD)' })
date!: string;
static from(completion: RepeatRuleCompletion): RuleCompletionResponseDto {
return { ruleId: completion.ruleId, date: completion.date };
}
}
/** 일정 내용 DTO → 컬럼 값 */
export function toDetailsColumns(dto: EventDetailsDto): ScheduleDetails {
return {
title: dto.title,
kind: dto.kind,
category: dto.category,
targetIds: [...new Set(dto.targetIds)],
startTime: dto.start ?? null,
// 하루 종일 일정에는 종료 시각을 두지 않는다
endTime: dto.start ? (dto.end ?? null) : null,
place: dto.place ?? null,
labelColor: dto.labelColor ?? null,
};
}
@@ -0,0 +1,81 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryColumn,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { User } from '@/modules/user/entities/user.entity';
import { ScheduleDetails } from './schedule-details';
/** 반복 방식 — 매일 / 평일만 / 주말만 / N일·N주·N개월마다 (앱의 RepeatPattern) */
export const REPEAT_MODES = ['daily', 'weekday', 'weekend', 'every'] as const;
export type RepeatMode = (typeof REPEAT_MODES)[number];
/** every 의 주기 단위 (앱의 RepeatUnit) */
export const REPEAT_UNITS = ['day', 'week', 'month'] as const;
export type RepeatUnit = (typeof REPEAT_UNITS)[number];
/**
* 끝나는 날 없이 반복되는 일정 — 날짜를 미리 만들지 않고 규칙으로 보관한다.
* 어느 날에 놓이는지는 앱이 계산한다 (앱의 RepeatSpec.matches).
*/
@Entity('repeat_rules')
export class RepeatRule {
@PrimaryGeneratedColumn('uuid')
id!: string;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'owner_id' })
owner?: User;
@Index()
@Column({ type: 'uuid' })
ownerId!: string;
/** 반복을 시작한 날 (YYYY-MM-DD) */
@Column({ type: 'date' })
fromDate!: string;
@Column({ type: 'varchar', length: 10 })
mode!: RepeatMode;
@Column({ type: 'varchar', length: 5 })
unit!: RepeatUnit;
/** N — every 의 주기 (1~31) */
@Column({ type: 'smallint' })
n!: number;
@Column(() => ScheduleDetails, { prefix: false })
details!: ScheduleDetails;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
}
/** 반복 일정의 날짜별 완료 표시 — 규칙을 지우면 함께 지운다 */
@Entity('repeat_rule_completions')
export class RepeatRuleCompletion {
@PrimaryColumn({ type: 'uuid' })
ruleId!: string;
@ManyToOne(() => RepeatRule, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'rule_id' })
rule?: RepeatRule;
/** 완료한 날 (YYYY-MM-DD) */
@PrimaryColumn({ type: 'date' })
date!: string;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
}
@@ -0,0 +1,61 @@
import { Column } from 'typeorm';
/** 입력 한도 — 앱 화면에는 제한이 없어 넉넉하게 두고, 과도한 입력만 막는다 */
export const SCHEDULE_LIMITS = {
title: 50,
place: 60,
targets: 20,
/** 한 번에 만드는 일정 수 — 기간 설정은 앱이 나눠 보낸다 */
eventsPerRequest: 500,
maxRepeatN: 31,
} as const;
/** 예약 일정(병원·미용 등 날짜가 정해진 약속) / 일반 일정(데일리 케어) */
export const EVENT_KINDS = ['reservation', 'general'] as const;
export type EventKind = (typeof EVENT_KINDS)[number];
/** 일정 분류 — 앱의 EventCategory 와 같은 이름 */
export const EVENT_CATEGORIES = [
'hospital',
'vaccine',
'grooming',
'walk',
'food',
'med',
'etc',
] as const;
export type EventCategory = (typeof EVENT_CATEGORIES)[number];
/**
* 일정 내용 — 날짜 일정과 반복 규칙이 같은 컬럼을 쓴다 (앱의 EventDetails).
* 엔티티에 `@Column(() => ScheduleDetails, { prefix: false })` 로 끼워 넣는다.
*/
export class ScheduleDetails {
@Column({ type: 'varchar', length: SCHEDULE_LIMITS.title })
title!: string;
@Column({ type: 'varchar', length: 12 })
kind!: EventKind;
@Column({ type: 'varchar', length: 12 })
category!: EventCategory;
/** 대상 — 아이 id 또는 'self'. 아이를 지우거나 보관하면 그 아이만 뗀다 */
@Column({ type: 'text', array: true })
targetIds!: string[];
/** 시작 시각 `HH:mm` — null 이면 하루 종일 */
@Column({ type: 'varchar', length: 5, nullable: true })
startTime!: string | null;
/** 종료 시각 `HH:mm` (선택) */
@Column({ type: 'varchar', length: 5, nullable: true })
endTime!: string | null;
@Column({ type: 'varchar', length: SCHEDULE_LIMITS.place, nullable: true })
place!: string | null;
/** 직접 고른 라벨 색 `#RRGGBB` — null 이면 분류 기본 색 */
@Column({ type: 'varchar', length: 7, nullable: true })
labelColor!: string | null;
}
@@ -0,0 +1,45 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { User } from '@/modules/user/entities/user.entity';
import { ScheduleDetails } from './schedule-details';
/** 달력에 놓인 일정 하나 (날짜가 정해진 일정) */
@Entity('schedule_events')
@Index(['ownerId', 'date'])
export class ScheduleEvent {
@PrimaryGeneratedColumn('uuid')
id!: string;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'owner_id' })
owner?: User;
@Column({ type: 'uuid' })
ownerId!: string;
/** 날짜 (시각 없음, YYYY-MM-DD) */
@Column({ type: 'date' })
date!: string;
@Column(() => ScheduleDetails, { prefix: false })
details!: ScheduleDetails;
/** 완료 표시 */
@Column({ type: 'boolean', default: false })
isDone!: boolean;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
}
@@ -0,0 +1,174 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Post,
Put,
Query,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiNoContentResponse,
ApiOperation,
ApiParam,
ApiTags,
} from '@nestjs/swagger';
import {
ApiErrorResponse,
ApiPaginatedResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import { CurrentUser } from '@/common/decorators/current-user.decorator';
import type { AuthUser } from '@/common/decorators/current-user.decorator';
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
import {
CursorPaginationQueryDto,
PaginatedResult,
} from '@/common/dto/pagination.dto';
import { BusinessException } from '@/common/errors/business.exception';
import { isValidIsoDate } from '@/shared/utils/iso-date';
import {
CreateRuleDto,
RepeatRuleResponseDto,
RuleCompletionResponseDto,
UpdateRuleDto,
} from './dto/schedule.dto';
import { ScheduleService } from './schedule.service';
/** 경로의 날짜(YYYY-MM-DD)를 확인한다 — 틀리면 VAL_001 */
function parseDateParam(date: string): string {
if (!isValidIsoDate(date)) {
throw new BusinessException('VAL_001', {
context: { reason: 'invalid-date', date },
});
}
return date;
}
// 반복 일정 — 라우팅과 입출력만 담당, 비즈니스 로직은 모두 service 위임
@ApiTags('Schedule')
@ApiBearerAuth()
@ApiErrorResponse('AUTH_001', '로그인 필요')
@Controller('repeat-rules')
export class RepeatRulesController {
constructor(private readonly schedule: ScheduleService) {}
@Get()
@ApiOperation({ summary: '반복 일정 목록 (만든 순, 커서 페이지네이션)' })
@ApiPaginatedResponse(RepeatRuleResponseDto, '반복 일정 목록')
@ApiErrorResponse('VAL_001', '페이지네이션 파라미터 오류')
list(
@CurrentUser() user: AuthUser,
@Query() query: CursorPaginationQueryDto,
): Promise<PaginatedResult<RepeatRuleResponseDto>> {
return this.schedule.listRules(user.userId, query);
}
@Post()
@ApiOperation({ summary: '반복 일정 만들기 (끝나는 날 없음)' })
@ApiStandardResponse(RepeatRuleResponseDto, {
status: 201,
description: '만든 반복 일정',
})
@ApiErrorResponse('VAL_001', '입력값 검증 실패 · 내 아이가 아닌 대상')
create(
@CurrentUser() user: AuthUser,
@Body() dto: CreateRuleDto,
): Promise<RepeatRuleResponseDto> {
return this.schedule.createRule(user.userId, dto);
}
@Put(':id')
@ApiOperation({ summary: '반복 주기·내용 바꾸기 (시작한 날은 그대로)' })
@ApiStandardResponse(RepeatRuleResponseDto, { description: '바꾼 반복 일정' })
@ApiErrorResponse('RES_001', '반복 일정을 찾을 수 없음')
@ApiErrorResponse('VAL_001', '입력값 검증 실패')
update(
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateRuleDto,
): Promise<RepeatRuleResponseDto> {
return this.schedule.updateRule(user.userId, id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@SkipTransform() // 204 — 본문이 없다
@ApiOperation({ summary: '반복 일정 삭제 (규칙 전체·완료 표시 함께)' })
@ApiNoContentResponse({ description: '삭제 완료' })
@ApiErrorResponse('RES_001', '반복 일정을 찾을 수 없음')
async remove(
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
): Promise<void> {
await this.schedule.deleteRule(user.userId, id);
}
@Put(':id/completions/:date')
@HttpCode(HttpStatus.NO_CONTENT)
@SkipTransform() // 204 — 본문이 없다
@ApiOperation({ summary: '그날 완료 표시' })
@ApiParam({ name: 'date', description: 'YYYY-MM-DD', example: '2026-10-11' })
@ApiNoContentResponse({ description: '완료로 표시함' })
@ApiErrorResponse('RES_001', '반복 일정을 찾을 수 없음')
@ApiErrorResponse('VAL_001', '날짜 형식 오류')
async complete(
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
@Param('date') date: string,
): Promise<void> {
await this.schedule.setRuleDone(
user.userId,
id,
parseDateParam(date),
true,
);
}
@Delete(':id/completions/:date')
@HttpCode(HttpStatus.NO_CONTENT)
@SkipTransform() // 204 — 본문이 없다
@ApiOperation({ summary: '그날 완료 표시 풀기' })
@ApiParam({ name: 'date', description: 'YYYY-MM-DD', example: '2026-10-11' })
@ApiNoContentResponse({ description: '완료 표시를 풀었음' })
@ApiErrorResponse('RES_001', '반복 일정을 찾을 수 없음')
@ApiErrorResponse('VAL_001', '날짜 형식 오류')
async uncomplete(
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
@Param('date') date: string,
): Promise<void> {
await this.schedule.setRuleDone(
user.userId,
id,
parseDateParam(date),
false,
);
}
}
// 반복 일정의 날짜별 완료 표시 목록 — 앱이 처음 불러올 때 한 번 읽는다
@ApiTags('Schedule')
@ApiBearerAuth()
@ApiErrorResponse('AUTH_001', '로그인 필요')
@Controller('repeat-rule-completions')
export class RepeatRuleCompletionsController {
constructor(private readonly schedule: ScheduleService) {}
@Get()
@ApiOperation({ summary: '반복 일정 완료 표시 목록 (커서 페이지네이션)' })
@ApiPaginatedResponse(RuleCompletionResponseDto, '완료 표시 목록')
@ApiErrorResponse('VAL_001', '페이지네이션 파라미터 오류')
list(
@CurrentUser() user: AuthUser,
@Query() query: CursorPaginationQueryDto,
): Promise<PaginatedResult<RuleCompletionResponseDto>> {
return this.schedule.listCompletions(user.userId, query);
}
}
@@ -0,0 +1,129 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Post,
Put,
Query,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiNoContentResponse,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import {
ApiErrorResponse,
ApiPaginatedResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import { CurrentUser } from '@/common/decorators/current-user.decorator';
import type { AuthUser } from '@/common/decorators/current-user.decorator';
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
import {
CursorPaginationQueryDto,
PaginatedResult,
} from '@/common/dto/pagination.dto';
import {
CreateEventsDto,
EventDetailsDto,
ScheduleEventResponseDto,
} from './dto/schedule.dto';
import { ScheduleService } from './schedule.service';
// 날짜 일정 — 라우팅과 입출력만 담당, 비즈니스 로직은 모두 service 위임
@ApiTags('Schedule')
@ApiBearerAuth()
@ApiErrorResponse('AUTH_001', '로그인 필요')
@Controller('schedule-events')
export class ScheduleEventsController {
constructor(private readonly schedule: ScheduleService) {}
@Get()
@ApiOperation({ summary: '날짜 일정 목록 (날짜순, 커서 페이지네이션)' })
@ApiPaginatedResponse(ScheduleEventResponseDto, '날짜 일정 목록')
@ApiErrorResponse('VAL_001', '페이지네이션 파라미터 오류')
list(
@CurrentUser() user: AuthUser,
@Query() query: CursorPaginationQueryDto,
): Promise<PaginatedResult<ScheduleEventResponseDto>> {
return this.schedule.listEvents(user.userId, query);
}
@Post()
@ApiOperation({
summary: '날짜 일정 만들기 (여러 건)',
description:
'기간 설정은 날짜·회차마다 한 건씩 보낸다. 하나라도 틀리면 아무것도 만들지 않는다',
})
@ApiStandardResponse(ScheduleEventResponseDto, {
status: 201,
description: '만든 일정 (보낸 순서대로)',
isArray: true,
})
@ApiErrorResponse('VAL_001', '입력값 검증 실패 · 내 아이가 아닌 대상')
create(
@CurrentUser() user: AuthUser,
@Body() dto: CreateEventsDto,
): Promise<ScheduleEventResponseDto[]> {
return this.schedule.createEvents(user.userId, dto);
}
@Put(':id')
@ApiOperation({ summary: '일정 내용 바꾸기 (날짜·완료 표시는 그대로)' })
@ApiStandardResponse(ScheduleEventResponseDto, { description: '바꾼 일정' })
@ApiErrorResponse('RES_001', '일정을 찾을 수 없음')
@ApiErrorResponse('VAL_001', '입력값 검증 실패')
update(
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: EventDetailsDto,
): Promise<ScheduleEventResponseDto> {
return this.schedule.updateEvent(user.userId, id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@SkipTransform() // 204 — 본문이 없다
@ApiOperation({ summary: '일정 삭제' })
@ApiNoContentResponse({ description: '삭제 완료' })
@ApiErrorResponse('RES_001', '일정을 찾을 수 없음')
async remove(
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
): Promise<void> {
await this.schedule.deleteEvent(user.userId, id);
}
@Put(':id/completion')
@HttpCode(HttpStatus.NO_CONTENT)
@SkipTransform() // 204 — 본문이 없다
@ApiOperation({ summary: '완료 표시' })
@ApiNoContentResponse({ description: '완료로 표시함' })
@ApiErrorResponse('RES_001', '일정을 찾을 수 없음')
async complete(
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
): Promise<void> {
await this.schedule.setEventDone(user.userId, id, true);
}
@Delete(':id/completion')
@HttpCode(HttpStatus.NO_CONTENT)
@SkipTransform() // 204 — 본문이 없다
@ApiOperation({ summary: '완료 표시 풀기' })
@ApiNoContentResponse({ description: '완료 표시를 풀었음' })
@ApiErrorResponse('RES_001', '일정을 찾을 수 없음')
async uncomplete(
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
): Promise<void> {
await this.schedule.setEventDone(user.userId, id, false);
}
}
@@ -0,0 +1,29 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PetModule } from '@/modules/pet/pet.module';
import {
RepeatRule,
RepeatRuleCompletion,
} from './entities/repeat-rule.entity';
import { ScheduleEvent } from './entities/schedule-event.entity';
import {
RepeatRuleCompletionsController,
RepeatRulesController,
} from './repeat-rules.controller';
import { ScheduleEventsController } from './schedule-events.controller';
import { ScheduleService } from './schedule.service';
@Module({
imports: [
TypeOrmModule.forFeature([ScheduleEvent, RepeatRule, RepeatRuleCompletion]),
PetModule,
],
controllers: [
ScheduleEventsController,
RepeatRulesController,
RepeatRuleCompletionsController,
],
providers: [ScheduleService],
})
export class ScheduleModule {}
@@ -0,0 +1,105 @@
import { EntityManager, Repository } from 'typeorm';
import { PetService } from '@/modules/pet/pet.service';
import { EventDetailsDto, toDetailsColumns } from './dto/schedule.dto';
import {
RepeatRule,
RepeatRuleCompletion,
} from './entities/repeat-rule.entity';
import { ScheduleEvent } from './entities/schedule-event.entity';
import { ScheduleService } from './schedule.service';
const details = (
overrides: Partial<EventDetailsDto> = {},
): EventDetailsDto => ({
title: '산책',
kind: 'general',
category: 'walk',
targetIds: ['self'],
...overrides,
});
describe('toDetailsColumns', () => {
it('선택 값이 없으면 null 로 저장한다', () => {
expect(toDetailsColumns(details())).toEqual({
title: '산책',
kind: 'general',
category: 'walk',
targetIds: ['self'],
startTime: null,
endTime: null,
place: null,
labelColor: null,
});
});
it('하루 종일 일정(시작 시각 없음)에는 종료 시각을 두지 않는다', () => {
expect(toDetailsColumns(details({ end: '09:00' })).endTime).toBeNull();
expect(
toDetailsColumns(details({ start: '08:00', end: '09:00' })).endTime,
).toBe('09:00');
});
it('같은 대상이 두 번 오면 한 번만 남긴다', () => {
expect(
toDetailsColumns(details({ targetIds: ['self', 'self'] })).targetIds,
).toEqual(['self']);
});
});
describe('ScheduleService', () => {
const events = { findOneBy: jest.fn(), delete: jest.fn(), update: jest.fn() };
const rules = { findOneBy: jest.fn(), delete: jest.fn() };
const completions = { delete: jest.fn() };
const pets = { registerListener: jest.fn(), assertOwnedTargets: jest.fn() };
const service = new ScheduleService(
events as unknown as Repository<ScheduleEvent>,
rules as unknown as Repository<RepeatRule>,
completions as unknown as Repository<RepeatRuleCompletion>,
pets as unknown as PetService,
);
beforeEach(() => jest.clearAllMocks());
it('시작할 때 아이 삭제·보관 리스너로 등록한다', () => {
service.onModuleInit();
expect(pets.registerListener).toHaveBeenCalledWith(service);
});
it('아이를 지우면 두 일정 테이블에서 그 아이를 떼고, 대상이 빈 일정을 지운다', async () => {
const query = jest
.fn<Promise<unknown[]>, [string, unknown[]]>()
.mockResolvedValue([]);
const manager = { query } as unknown as EntityManager;
await service.onPetDeleting({ ownerId: 'o', petId: 'p', manager });
const sql = query.mock.calls.map(([text]) => text);
expect(sql).toHaveLength(4);
expect(sql[0]).toMatch(
/UPDATE schedule_events SET target_ids = array_remove/,
);
expect(sql[1]).toMatch(/DELETE FROM schedule_events .*cardinality/);
expect(sql[2]).toMatch(/UPDATE repeat_rules/);
expect(sql[3]).toMatch(/DELETE FROM repeat_rules/);
// 값은 항상 매개변수로 넘긴다
expect(query.mock.calls[0][1]).toEqual(['o', 'p']);
});
it('남의 일정은 지울 수 없다 (RES_001)', async () => {
events.delete.mockResolvedValue({ affected: 0 });
await expect(service.deleteEvent('o', 'e')).rejects.toMatchObject({
code: 'RES_001',
});
});
it('남의 반복 일정에는 완료 표시를 할 수 없다 (RES_001)', async () => {
rules.findOneBy.mockResolvedValue(null);
await expect(
service.setRuleDone('o', 'r', '2026-10-11', false),
).rejects.toMatchObject({ code: 'RES_001' });
expect(completions.delete).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,281 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { EntityManager, Repository } from 'typeorm';
import { keysetPaginate, mapPage } from '@/common/dto/keyset-pagination';
import {
CursorPaginationQueryDto,
PaginatedResult,
} from '@/common/dto/pagination.dto';
import { BusinessException } from '@/common/errors/business.exception';
import {
PetLifecycleEvent,
PetLifecycleListener,
} from '@/modules/pet/pet-lifecycle';
import { PetService } from '@/modules/pet/pet.service';
import {
CreateEventsDto,
CreateRuleDto,
EventDetailsDto,
RepeatRuleResponseDto,
RuleCompletionResponseDto,
ScheduleEventResponseDto,
UpdateRuleDto,
toDetailsColumns,
} from './dto/schedule.dto';
import {
RepeatRule,
RepeatRuleCompletion,
} from './entities/repeat-rule.entity';
import { ScheduleEvent } from './entities/schedule-event.entity';
/** 대상(target_ids)을 가진 일정 테이블 */
const TARGET_TABLES = ['schedule_events', 'repeat_rules'] as const;
// 일정 — 날짜 일정·반복 규칙·완료 표시. 어느 날에 무엇이 놓이는지는 앱이 계산한다.
@Injectable()
export class ScheduleService implements OnModuleInit, PetLifecycleListener {
constructor(
@InjectRepository(ScheduleEvent)
private readonly events: Repository<ScheduleEvent>,
@InjectRepository(RepeatRule)
private readonly rules: Repository<RepeatRule>,
@InjectRepository(RepeatRuleCompletion)
private readonly completions: Repository<RepeatRuleCompletion>,
private readonly pets: PetService,
) {}
onModuleInit(): void {
this.pets.registerListener(this);
}
// ── 날짜 일정 ───────────────────────────────────────────
async listEvents(
ownerId: string,
query: CursorPaginationQueryDto,
): Promise<PaginatedResult<ScheduleEventResponseDto>> {
const qb = this.events
.createQueryBuilder('event')
.where('event.owner_id = :ownerId', { ownerId });
const page = await keysetPaginate(
qb,
[
{ column: 'event.date', cast: 'date', read: (e) => e.date },
{ column: 'event.id', cast: 'uuid', read: (e) => e.id },
],
query,
);
return mapPage(page, (e) => ScheduleEventResponseDto.fromEvent(e));
}
/** 여러 건을 한 번에 만든다 (기간 설정) — 하나라도 틀리면 아무것도 만들지 않는다 */
async createEvents(
ownerId: string,
dto: CreateEventsDto,
): Promise<ScheduleEventResponseDto[]> {
await this.pets.assertOwnedTargets(
ownerId,
dto.events.flatMap((e) => e.targetIds),
);
const saved = await this.events.save(
dto.events.map((e) =>
this.events.create({
ownerId,
date: e.date,
details: toDetailsColumns(e),
isDone: false,
}),
),
);
return saved.map((e) => ScheduleEventResponseDto.fromEvent(e));
}
/** 일정 내용 바꾸기 — 날짜와 완료 표시는 그대로 둔다 */
async updateEvent(
ownerId: string,
eventId: string,
dto: EventDetailsDto,
): Promise<ScheduleEventResponseDto> {
const event = await this.findOwnedEvent(ownerId, eventId);
await this.pets.assertOwnedTargets(ownerId, dto.targetIds);
event.details = toDetailsColumns(dto);
return ScheduleEventResponseDto.fromEvent(await this.events.save(event));
}
async deleteEvent(ownerId: string, eventId: string): Promise<void> {
const result = await this.events.delete({ id: eventId, ownerId });
if (!result.affected) {
throw new BusinessException('RES_001', { context: { eventId } });
}
}
async setEventDone(
ownerId: string,
eventId: string,
done: boolean,
): Promise<void> {
const result = await this.events.update(
{ id: eventId, ownerId },
{ isDone: done },
);
if (!result.affected) {
throw new BusinessException('RES_001', { context: { eventId } });
}
}
// ── 반복 규칙 ───────────────────────────────────────────
async listRules(
ownerId: string,
query: CursorPaginationQueryDto,
): Promise<PaginatedResult<RepeatRuleResponseDto>> {
const qb = this.rules
.createQueryBuilder('rule')
.where('rule.owner_id = :ownerId', { ownerId });
const page = await keysetPaginate(
qb,
[
{
column: 'rule.created_at',
cast: 'timestamptz',
read: (r) => r.createdAt.toISOString(),
},
{ column: 'rule.id', cast: 'uuid', read: (r) => r.id },
],
query,
);
return mapPage(page, (r) => RepeatRuleResponseDto.from(r));
}
async createRule(
ownerId: string,
dto: CreateRuleDto,
): Promise<RepeatRuleResponseDto> {
await this.pets.assertOwnedTargets(ownerId, dto.details.targetIds);
const rule = await this.rules.save(
this.rules.create({
ownerId,
fromDate: dto.from,
...dto.spec,
details: toDetailsColumns(dto.details),
}),
);
return RepeatRuleResponseDto.from(rule);
}
/** 주기·내용 바꾸기 — 시작한 날과 날짜별 완료 표시는 그대로 둔다 */
async updateRule(
ownerId: string,
ruleId: string,
dto: UpdateRuleDto,
): Promise<RepeatRuleResponseDto> {
const rule = await this.findOwnedRule(ownerId, ruleId);
await this.pets.assertOwnedTargets(ownerId, dto.details.targetIds);
Object.assign(rule, dto.spec, { details: toDetailsColumns(dto.details) });
return RepeatRuleResponseDto.from(await this.rules.save(rule));
}
/** 반복 일정 삭제 — 규칙 전체와 날짜별 완료 표시가 함께 지워진다 */
async deleteRule(ownerId: string, ruleId: string): Promise<void> {
const result = await this.rules.delete({ id: ruleId, ownerId });
if (!result.affected) {
throw new BusinessException('RES_001', { context: { ruleId } });
}
}
async listCompletions(
ownerId: string,
query: CursorPaginationQueryDto,
): Promise<PaginatedResult<RuleCompletionResponseDto>> {
const qb = this.completions
.createQueryBuilder('done')
.innerJoin('done.rule', 'rule')
.where('rule.owner_id = :ownerId', { ownerId });
const page = await keysetPaginate(
qb,
[
{ column: 'done.rule_id', cast: 'uuid', read: (c) => c.ruleId },
{ column: 'done.date', cast: 'date', read: (c) => c.date },
],
query,
);
return mapPage(page, (c) => RuleCompletionResponseDto.from(c));
}
/** 반복 일정의 그날 완료 표시 — 이미 같은 값이면 그대로 둔다 */
async setRuleDone(
ownerId: string,
ruleId: string,
date: string,
done: boolean,
): Promise<void> {
await this.findOwnedRule(ownerId, ruleId);
if (done) {
await this.completions
.createQueryBuilder()
.insert()
.values({ ruleId, date })
.orIgnore()
.execute();
} else {
await this.completions.delete({ ruleId, date });
}
}
// ── 아이 삭제·보관 ─────────────────────────────────────
/** 아이를 지우면 일정에서 그 아이만 뗀다 — 대상이 남지 않은 일정은 지운다 (앱의 dropTarget) */
async onPetDeleting(event: PetLifecycleEvent): Promise<void> {
await this.dropTarget(event);
}
/** 보관한 아이의 일정도 지운다 — 다시 불러와도 돌아오지 않는다 */
async onPetArchived(event: PetLifecycleEvent): Promise<void> {
await this.dropTarget(event);
}
private async dropTarget({
ownerId,
petId,
manager,
}: PetLifecycleEvent): Promise<void> {
// 테이블 이름은 아래 상수만 쓴다 (외부 입력을 SQL 에 넣지 않는다)
for (const table of TARGET_TABLES) {
await this.removeTargetFrom(manager, table, ownerId, petId);
}
}
private async removeTargetFrom(
manager: EntityManager,
table: string,
ownerId: string,
petId: string,
): Promise<void> {
await manager.query(
`UPDATE ${table} SET target_ids = array_remove(target_ids, $2)
WHERE owner_id = $1 AND $2 = ANY(target_ids)`,
[ownerId, petId],
);
await manager.query(
`DELETE FROM ${table} WHERE owner_id = $1 AND cardinality(target_ids) = 0`,
[ownerId],
);
}
private async findOwnedEvent(
ownerId: string,
eventId: string,
): Promise<ScheduleEvent> {
const event = await this.events.findOneBy({ id: eventId, ownerId });
if (!event)
throw new BusinessException('RES_001', { context: { eventId } });
return event;
}
private async findOwnedRule(
ownerId: string,
ruleId: string,
): Promise<RepeatRule> {
const rule = await this.rules.findOneBy({ id: ruleId, ownerId });
if (!rule) throw new BusinessException('RES_001', { context: { ruleId } });
return rule;
}
}
@@ -1,12 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
/** 업로드 결과 — 저장 경로(절대경로)는 노출하지 않는다 */
import { StoredFile } from '../entities/stored-file.entity';
/** 업로드 결과 — 저장 경로(절대경로)와 저장 파일명은 노출하지 않는다 */
export class UploadResponseDto {
@ApiProperty({
description: '서버가 생성한 파일 식별자 (저장 파일명)',
example: 'b1f0c2e4-8a3d-4c7e-9f21-0d5a6b7c8e9f.png',
description: '파일 ID — 아이 사진 등 다른 기록에서 이 값으로 참조한다',
example: 'b1f0c2e4-8a3d-4c7e-9f21-0d5a6b7c8e9f',
})
fileName!: string;
id!: string;
@ApiProperty({
description: '업로드 당시 원본 파일명',
@@ -19,4 +21,13 @@ export class UploadResponseDto {
@ApiProperty({ description: '파일 크기 (바이트)', example: 20480 })
size!: number;
static from(file: StoredFile): UploadResponseDto {
return {
id: file.id,
originalName: file.originalName,
mimeType: file.mimeType,
size: file.size,
};
}
}
@@ -0,0 +1,47 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { User } from '@/modules/user/entities/user.entity';
/**
* 업로드한 파일의 메타데이터. 실제 파일은 UPLOAD_DIR 에 [storedName] 으로 저장된다.
* 다른 엔티티는 이 id 를 참조하며, 참조가 남아 있는 파일은 FK 때문에 지워지지 않는다.
*/
@Entity('files')
export class StoredFile {
@PrimaryGeneratedColumn('uuid')
id!: string;
/** 올린 사람 — 회원이 탈퇴하면 파일 기록도 함께 지운다 */
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'owner_id' })
owner?: User;
@Index()
@Column({ type: 'uuid' })
ownerId!: string;
/** 서버가 만든 저장 파일명 (UUID + 검증된 확장자) — 경로 조립에는 이 값만 쓴다 */
@Column({ type: 'varchar', length: 64, unique: true })
storedName!: string;
/** 올릴 때의 원본 파일명 — 표시용 메타데이터일 뿐 경로에 쓰지 않는다 */
@Column({ type: 'varchar', length: 255 })
originalName!: string;
@Column({ type: 'varchar', length: 100 })
mimeType!: string;
@Column({ type: 'integer' })
size!: number;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
}
@@ -1,4 +1,9 @@
import { allowedExtensionList, isAllowedFile } from './upload.config';
import {
allowedExtensionList,
isAllowedFile,
isImageMimeType,
matchesFileSignature,
} from './upload.config';
describe('파일 업로드 검증', () => {
it('확장자와 MIME 이 모두 맞으면 통과한다', () => {
@@ -28,3 +33,48 @@ describe('파일 업로드 검증', () => {
}
});
});
describe('파일 시그니처 검사', () => {
const png = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0x0d,
]);
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0, 0x10, 0x4a, 0x46]);
const webp = Buffer.from('RIFF\u0000\u0000\u0000\u0000WEBP', 'latin1');
it('내용이 형식과 맞으면 통과한다', () => {
expect(matchesFileSignature('image/png', png)).toBe(true);
expect(matchesFileSignature('image/jpeg', jpeg)).toBe(true);
expect(matchesFileSignature('image/webp', webp)).toBe(true);
expect(matchesFileSignature('image/gif', Buffer.from('GIF89a......'))).toBe(
true,
);
expect(
matchesFileSignature('application/pdf', Buffer.from('%PDF-1.7 ...')),
).toBe(true);
});
it('이름·MIME 이 이미지여도 내용이 HTML 이면 거부한다', () => {
const html = Buffer.from('<html><script>');
expect(matchesFileSignature('image/png', html)).toBe(false);
expect(matchesFileSignature('image/jpeg', html)).toBe(false);
});
it('다른 형식의 내용이면 거부한다', () => {
expect(matchesFileSignature('image/png', jpeg)).toBe(false);
});
it('허용 목록에 없는 MIME 은 거부한다', () => {
expect(matchesFileSignature('text/html', Buffer.from('<html>'))).toBe(
false,
);
});
});
describe('사진 형식', () => {
it('허용된 이미지 형식만 사진으로 쓴다', () => {
expect(isImageMimeType('image/png')).toBe(true);
expect(isImageMimeType('application/pdf')).toBe(false);
expect(isImageMimeType('image/svg+xml')).toBe(false);
});
});
@@ -42,6 +42,56 @@ export function isAllowedFile(originalName: string, mimeType: string): boolean {
return allowedExtensions.includes(extname(originalName).toLowerCase());
}
/** 파일 앞부분에서 형식을 판별하는 데 읽는 바이트 수 */
export const SIGNATURE_BYTES = 12;
/**
* 형식별 파일 시그니처(매직 바이트) — 확장자·MIME 은 클라이언트가 정하므로 내용으로 한 번 더 확인한다.
* 이름이 .png 인데 내용이 HTML 인 파일을 여기서 걸러낸다 (.claude/rules/security.md 의 알려진 한계).
*/
const FILE_SIGNATURES: ReadonlyMap<string, (head: Buffer) => boolean> = new Map(
[
[
'image/jpeg',
(h: Buffer) => h.subarray(0, 3).equals(Buffer.from([0xff, 0xd8, 0xff])),
],
[
'image/png',
(h: Buffer) =>
h
.subarray(0, 8)
.equals(
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
),
],
[
'image/gif',
(h: Buffer) =>
['GIF87a', 'GIF89a'].includes(h.subarray(0, 6).toString('latin1')),
],
[
'image/webp',
(h: Buffer) =>
h.subarray(0, 4).toString('latin1') === 'RIFF' &&
h.subarray(8, 12).toString('latin1') === 'WEBP',
],
[
'application/pdf',
(h: Buffer) => h.subarray(0, 5).toString('latin1') === '%PDF-',
],
],
);
/** 파일 앞부분([head])이 [mimeType] 형식의 시그니처와 맞는지 */
export function matchesFileSignature(mimeType: string, head: Buffer): boolean {
return FILE_SIGNATURES.get(mimeType)?.(head) ?? false;
}
/** 사진으로 쓸 수 있는 형식인지 — 아이·프로필·다이어리 사진은 이미지만 받는다 */
export function isImageMimeType(mimeType: string): boolean {
return mimeType.startsWith('image/') && ALLOWED_FILE_TYPES.has(mimeType);
}
/** 사용자에게 안내할 허용 확장자 목록 */
export function allowedExtensionList(): string {
return [...ALLOWED_FILE_TYPES.values()].flat().join(', ');
@@ -1,23 +1,43 @@
import {
Controller,
Get,
Header,
Param,
ParseUUIDPipe,
Post,
StreamableFile,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiBody, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
ApiBearerAuth,
ApiBody,
ApiConsumes,
ApiOkResponse,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import {
ApiErrorResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import { CurrentUser } from '@/common/decorators/current-user.decorator';
import type { AuthUser } from '@/common/decorators/current-user.decorator';
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
import { BusinessException } from '@/common/errors/business.exception';
import { UploadResponseDto } from './dto/upload-response.dto';
import { UploadService } from './upload.service';
// 파일 업로드 샘플 — 새 업로드 엔드포인트는 이 패턴을 복제한다.
// 검증(형식·크기)과 안전한 파일명 생성은 UploadModule 의 multer 설정이 담당한다.
/** 파일은 id 별로 내용이 바뀌지 않으므로 오래 캐시해도 된다 (로그인한 본인만 받으므로 private) */
const FILE_CACHE_CONTROL = 'private, max-age=31536000, immutable';
// 파일 업로드·내려받기 — 새 업로드 엔드포인트는 이 패턴을 복제한다.
// 검증(형식·크기)과 안전한 파일명 생성은 UploadModule 의 multer 설정이,
// 내용(시그니처) 검사와 소유자 기록은 UploadService 가 담당한다.
@ApiTags('Upload')
@ApiBearerAuth()
@Controller('files')
export class UploadController {
constructor(private readonly uploadService: UploadService) {}
@@ -41,7 +61,11 @@ export class UploadController {
})
@ApiErrorResponse('FILE_001', '허용되지 않는 파일 형식')
@ApiErrorResponse('FILE_002', '파일 크기 초과')
upload(@UploadedFile() file?: Express.Multer.File): UploadResponseDto {
@ApiErrorResponse('AUTH_001', '로그인 필요')
upload(
@CurrentUser() user: AuthUser,
@UploadedFile() file?: Express.Multer.File,
): Promise<UploadResponseDto> {
if (!file) {
// 파일 없이 요청한 경우 — multer 는 오류를 내지 않으므로 여기서 막는다
throw new BusinessException('VAL_001', {
@@ -49,6 +73,27 @@ export class UploadController {
});
}
return this.uploadService.toResponse(file);
return this.uploadService.register(file, user.userId);
}
@Get(':id')
@SkipTransform() // 파일 본문 — { success, data } 로 감싸면 깨진다
@Header('Cache-Control', FILE_CACHE_CONTROL)
@ApiOperation({ summary: '내 파일 내려받기' })
@ApiOkResponse({ description: '파일 본문 (Content-Type 은 서버가 정한다)' })
@ApiErrorResponse('RES_001', '파일이 없거나 내 파일이 아님')
async download(
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
): Promise<StreamableFile> {
const file = await this.uploadService.download(id, user.userId);
// 업로드 디렉터리를 정적으로 서빙하지 않는다 — 형식은 서버 기록으로 정하고,
// 브라우저가 열어 실행하지 않도록 attachment 로 내려준다 (.claude/rules/security.md)
return new StreamableFile(file.stream, {
type: file.mimeType,
length: file.size,
disposition: `attachment; filename="${file.downloadName}"`,
});
}
}
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { MulterModule } from '@nestjs/platform-express';
import { TypeOrmModule } from '@nestjs/typeorm';
import { randomUUID } from 'crypto';
import { diskStorage } from 'multer';
import { mkdirSync } from 'fs';
@@ -11,6 +12,7 @@ import {
UPLOAD_DIR,
isAllowedFile,
} from './upload.config';
import { StoredFile } from './entities/stored-file.entity';
import { UploadController } from './upload.controller';
import { UploadService } from './upload.service';
@@ -19,6 +21,7 @@ mkdirSync(UPLOAD_DIR, { recursive: true });
@Module({
imports: [
TypeOrmModule.forFeature([StoredFile]),
MulterModule.register({
storage: diskStorage({
destination: UPLOAD_DIR,
+134 -14
View File
@@ -1,36 +1,156 @@
import { Injectable, Logger } from '@nestjs/common';
import { unlink } from 'fs/promises';
import { InjectRepository } from '@nestjs/typeorm';
import { createReadStream } from 'fs';
import { open, unlink } from 'fs/promises';
import { join } from 'path';
import { Readable } from 'stream';
import { Repository } from 'typeorm';
import { UPLOAD_DIR } from './upload.config';
import { BusinessException } from '@/common/errors/business.exception';
import { isForeignKeyViolation } from '@/shared/utils/db-error';
import { StoredFile } from './entities/stored-file.entity';
import {
SIGNATURE_BYTES,
UPLOAD_DIR,
isImageMimeType,
matchesFileSignature,
} from './upload.config';
import { UploadResponseDto } from './dto/upload-response.dto';
// 업로드된 파일의 메타데이터를 다루는 계층.
// 저장 자체는 multer(diskStorage)가 수행하며, 여기서는 그 결과를 정리한다.
/** 내려줄 파일 — 컨트롤러가 StreamableFile 로 감싼다 */
export interface FileDownload {
readonly stream: Readable;
readonly mimeType: string;
readonly size: number;
readonly downloadName: string;
}
// 업로드된 파일의 기록과 실제 파일을 다루는 계층.
// 저장 자체는 multer(diskStorage)가 수행하며, 여기서는 내용 검사·기록·정리를 맡는다.
@Injectable()
export class UploadService {
private readonly logger = new Logger(UploadService.name);
/** multer 가 저장한 파일 정보를 응답 DTO 로 변환한다 */
toResponse(file: Express.Multer.File): UploadResponseDto {
constructor(
@InjectRepository(StoredFile)
private readonly files: Repository<StoredFile>,
) {}
/** multer 가 저장한 파일의 내용을 확인하고 [ownerId] 의 파일로 기록한다 */
async register(
file: Express.Multer.File,
ownerId: string,
): Promise<UploadResponseDto> {
if (!(await this.hasValidSignature(file))) {
await this.unlinkQuietly(file.filename);
throw new BusinessException('FILE_001', {
context: { mimeType: file.mimetype, reason: 'signature-mismatch' },
});
}
const saved = await this.files.save(
this.files.create({
ownerId,
storedName: file.filename,
originalName: file.originalname,
mimeType: file.mimetype,
size: file.size,
}),
);
this.logger.log(
`파일 업로드 완료: ${file.filename} (${file.size} bytes, ${file.mimetype})`,
`파일 업로드 완료: ${saved.id} (${file.size} bytes, ${file.mimetype})`,
);
return UploadResponseDto.from(saved);
}
/** 내 파일을 내려받는다 — 남의 파일이나 없는 파일은 똑같이 RES_001 */
async download(fileId: string, ownerId: string): Promise<FileDownload> {
const file = await this.files.findOneBy({ id: fileId, ownerId });
if (!file) {
throw new BusinessException('RES_001', { context: { fileId } });
}
return {
fileName: file.filename,
// 원본 파일명은 표시용 메타데이터일 뿐이며 경로 조립에 사용하지 않는다
originalName: file.originalname,
mimeType: file.mimetype,
stream: createReadStream(join(UPLOAD_DIR, file.storedName)),
mimeType: file.mimeType,
size: file.size,
// 원본 이름은 경로·헤더 조립에 쓰지 않는다 — 서버가 만든 이름으로 내려준다
downloadName: file.storedName,
};
}
/**
* 저장된 파일을 삭제한다.
* 사진으로 쓸 파일인지 확인한다 — 내 파일이고 이미지여야 한다.
* null 은 "사진 없음" 이라 그대로 통과시킨다.
*/
async assertOwnedImage(
fileId: string | null | undefined,
ownerId: string,
): Promise<void> {
if (fileId === null || fileId === undefined) return;
const file = await this.files.findOneBy({ id: fileId, ownerId });
if (!file || !isImageMimeType(file.mimeType)) {
throw new BusinessException('VAL_001', {
context: { fileId, reason: 'not-owned-image' },
});
}
}
/**
* 더 이상 쓰지 않는 파일을 지운다 (사진을 바꾸거나 기록을 지운 뒤).
* 다른 기록이 아직 참조하면 FK 가 막으므로 그대로 둔다 — 같은 파일을 두 곳에서 써도 안전하다.
*/
async releaseIfUnused(fileIds: ReadonlyArray<string | null>): Promise<void> {
for (const id of new Set(fileIds)) {
if (id === null) continue;
const file = await this.files.findOneBy({ id });
if (!file) continue;
try {
await this.files.delete({ id });
} catch (error) {
if (isForeignKeyViolation(error)) continue;
throw error;
}
await this.unlinkQuietly(file.storedName);
}
}
/** 회원의 저장 파일 이름 — 탈퇴할 때 기록을 지운 뒤 실제 파일을 정리하려고 미리 받아 둔다 */
async storedNamesOf(ownerId: string): Promise<string[]> {
const files = await this.files.find({
where: { ownerId },
select: { storedName: true },
});
return files.map((f) => f.storedName);
}
/**
* 저장된 파일을 디스크에서 지운다. 실패해도 요청은 성공으로 끝낸다 —
* 가리키는 기록이 없는 파일은 다시 내려줄 길이 없다. 로그만 남긴다.
* 파일명은 서버가 생성한 값만 받아야 한다 — 외부 입력을 그대로 넘기면 경로 탈출 위험이 있다.
*/
async remove(fileName: string): Promise<void> {
await unlink(join(UPLOAD_DIR, fileName));
async unlinkQuietly(storedName: string): Promise<void> {
try {
await unlink(join(UPLOAD_DIR, storedName));
} catch (error) {
this.logger.warn(
`업로드 파일 삭제 실패: ${storedName} (${error instanceof Error ? error.message : String(error)})`,
);
}
}
private async hasValidSignature(file: Express.Multer.File): Promise<boolean> {
const handle = await open(join(UPLOAD_DIR, file.filename), 'r');
try {
const head = Buffer.alloc(SIGNATURE_BYTES);
const { bytesRead } = await handle.read(head, 0, SIGNATURE_BYTES, 0);
return matchesFileSignature(file.mimetype, head.subarray(0, bytesRead));
} finally {
await handle.close();
}
}
}
@@ -1,20 +0,0 @@
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
// 사용자 생성 DTO 샘플 — 모든 검증 메시지는 한국어, Swagger 메타 필수
export class CreateUserDto {
@ApiProperty({ description: '사용자 이메일', example: 'user@example.com' })
@IsEmail({}, { message: '올바른 이메일 형식을 입력해 주세요.' })
@IsNotEmpty({ message: '이메일은 필수 입력 항목입니다.' })
email!: string;
@ApiProperty({ description: '비밀번호 (최소 8자)', example: 'password123' })
@IsString()
@MinLength(8, { message: '비밀번호는 최소 8자 이상이어야 합니다.' })
password!: string;
@ApiProperty({ description: '사용자 이름', example: '홍길동' })
@IsString()
@IsNotEmpty({ message: '이름은 필수 입력 항목입니다.' })
name!: string;
}
@@ -0,0 +1,41 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import {
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
MaxLength,
ValidateIf,
} from 'class-validator';
import { NICKNAME_MAX_LENGTH } from '../entities/user.entity';
/** 내 프로필 수정 — 보낸 값만 바꾼다 */
export class UpdateMeDto {
@ApiPropertyOptional({
description: `닉네임 (1~${NICKNAME_MAX_LENGTH}자, 앞뒤 공백 제거)`,
example: '콩이 보호자',
})
// 보내지 않으면 그대로 둔다. null 은 받지 않는다 (닉네임을 지우는 기능은 없다)
@ValidateIf((dto: UpdateMeDto) => dto.nickname !== undefined)
@Transform(({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() : value,
)
@IsString({ message: '닉네임은 문자로 입력해 주세요.' })
@IsNotEmpty({ message: '닉네임을 입력해 주세요.' })
@MaxLength(NICKNAME_MAX_LENGTH, {
message: `닉네임은 ${NICKNAME_MAX_LENGTH}자 이하로 입력해 주세요.`,
})
nickname?: string;
@ApiPropertyOptional({
description:
'프로필 사진 파일 ID (POST /api/files 로 올린 이미지). null 이면 사진을 지운다',
nullable: true,
type: String,
})
@IsOptional()
@IsUUID('4', { message: '사진 정보가 올바르지 않습니다.' })
photoFileId?: string | null;
}
@@ -1,13 +1,50 @@
import { ApiProperty } from '@nestjs/swagger';
/** 사용자 응답 DTO — Swagger 스키마와 클라이언트 타입의 기준이 된다. */
import { SOCIAL_PROVIDERS } from '../entities/social-account.entity';
import type { SocialProvider } from '../entities/social-account.entity';
import { User } from '../entities/user.entity';
/** 내 정보 응답 — 앱 마이페이지·인사말에 쓴다 */
export class UserResponseDto {
@ApiProperty({ description: '사용자 ID', example: 1 })
id!: number;
@ApiProperty({
description: '회원 ID',
example: '0b6f3c4e-2a1d-4f5e-8c9b-7a6d5e4f3c2b',
})
id!: string;
@ApiProperty({ description: '사용자 이메일', example: 'user@example.com' })
email!: string;
@ApiProperty({
description: '닉네임 — 모르면 null (앱은 "집사" 로 부른다)',
example: '콩이 보호자',
nullable: true,
type: String,
})
nickname!: string | null;
@ApiProperty({ description: '사용자 이름', example: '홍길동' })
name!: string;
@ApiProperty({
description:
'프로필 사진 파일 ID — GET /api/files/{id} 로 받는다. 없으면 null',
nullable: true,
type: String,
})
photoFileId!: string | null;
@ApiProperty({
description: '로그인한 소셜 계정 종류',
enum: SOCIAL_PROVIDERS,
example: 'kakao',
})
provider!: SocialProvider;
@ApiProperty({ description: '가입 시각 (ISO 8601 UTC)' })
createdAt!: string;
static from(user: User, provider: SocialProvider): UserResponseDto {
return {
id: user.id,
nickname: user.nickname,
photoFileId: user.photoFileId,
provider,
createdAt: user.createdAt.toISOString(),
};
}
}
@@ -0,0 +1,52 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
Unique,
} from 'typeorm';
import { User } from '@/modules/user/entities/user.entity';
/** 지원하는 소셜 로그인 제공자 — 앱(flutter SocialProvider)의 이름과 같다 */
export const SOCIAL_PROVIDERS = ['naver', 'kakao', 'google', 'apple'] as const;
export type SocialProvider = (typeof SOCIAL_PROVIDERS)[number];
/** 제공자 확인을 마친 소셜 계정 */
export interface VerifiedSocialAccount {
readonly provider: SocialProvider;
/** 제공자 쪽 사용자 고유 id */
readonly subject: string;
/** 제공자가 알려 준 이름 — 처음 가입할 때 닉네임으로 쓴다. 모르면 null */
readonly nickname: string | null;
}
/** 소셜 계정 — (제공자, 제공자 쪽 사용자 id) 하나가 회원 한 명이다 */
@Entity('social_accounts')
@Unique(['provider', 'subject'])
export class SocialAccount {
@PrimaryGeneratedColumn('uuid')
id!: string;
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user?: User;
@Index()
@Column({ type: 'uuid' })
userId!: string;
@Column({ type: 'varchar', length: 10 })
provider!: SocialProvider;
/** 제공자가 발급한 사용자 고유 id — 이메일 등 다른 개인정보는 저장하지 않는다 */
@Column({ type: 'varchar', length: 255 })
subject!: string;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
}
@@ -0,0 +1,42 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { StoredFile } from '@/modules/upload/entities/stored-file.entity';
/** 닉네임 최대 길이 — 앱 프로필 편집 화면과 맞춘다 */
export const NICKNAME_MAX_LENGTH = 20;
/** 회원 — 소셜 계정(SocialAccount)으로 가입한다 */
@Entity('users')
export class User {
@PrimaryGeneratedColumn('uuid')
id!: string;
/** 앱에서 부르는 이름 — 소셜 계정 이름으로 시작하고 앱에서 바꿀 수 있다. 모르면 null */
@Column({ type: 'varchar', length: NICKNAME_MAX_LENGTH, nullable: true })
nickname!: string | null;
/**
* 프로필 사진. 참조 중인 파일은 지울 수 없게 둔다(NO ACTION) —
* UploadService.releaseIfUnused 가 이 제약으로 "아직 쓰는 파일"을 가린다.
*/
@ManyToOne(() => StoredFile, { nullable: true, onDelete: 'NO ACTION' })
@JoinColumn({ name: 'photo_file_id' })
photoFile?: StoredFile | null;
@Column({ type: 'uuid', nullable: true })
photoFileId!: string | null;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
}
+41 -34
View File
@@ -1,59 +1,66 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Post,
Query,
HttpCode,
HttpStatus,
Patch,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
ApiBearerAuth,
ApiNoContentResponse,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import {
ApiErrorResponse,
ApiStandardResponse,
} from '@/common/decorators/api-standard-response.decorator';
import { CursorPaginationQueryDto } from '@/common/dto/pagination.dto';
import { UserService } from './user.service';
import { CreateUserDto } from './dto/create-user.dto';
import { CurrentUser } from '@/common/decorators/current-user.decorator';
import type { AuthUser } from '@/common/decorators/current-user.decorator';
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
import { UpdateMeDto } from './dto/update-me.dto';
import { UserResponseDto } from './dto/user-response.dto';
import { UserService } from './user.service';
// 사용자 도메인 컨트롤러 샘플 — 라우팅과 입출력만 담당, 비즈니스 로직은 모두 service 위임
// 내 정보 — 라우팅과 입출력만 담당, 비즈니스 로직은 모두 service 위임
@ApiTags('User')
@ApiBearerAuth()
@Controller('users')
@ApiErrorResponse('AUTH_001', '로그인 필요')
@Controller('users/me')
export class UserController {
constructor(private readonly userService: UserService) {}
@Get()
@ApiOperation({ summary: '사용자 목록 조회 (커서 페이지네이션)' })
@ApiStandardResponse(UserResponseDto, {
status: 200,
description: '사용자 목록 조회 성공',
isArray: true,
})
@ApiErrorResponse('VAL_001', '페이지네이션 파라미터 오류')
findMany(@Query() query: CursorPaginationQueryDto) {
return this.userService.findMany(query.limit, query.cursor);
@ApiOperation({ summary: '내 정보 조회' })
@ApiStandardResponse(UserResponseDto, { description: '내 정보 조회 성공' })
getMe(@CurrentUser() user: AuthUser): Promise<UserResponseDto> {
return this.userService.getMe(user.userId);
}
@Get(':id')
@ApiOperation({ summary: '사용자 단건 조회' })
@ApiStandardResponse(UserResponseDto, { description: '사용자 조회 성공' })
@ApiErrorResponse('RES_001', '사용자를 찾을 수 없음')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.userService.findOne(id);
@Patch()
@ApiOperation({ summary: '내 프로필 수정 (닉네임·사진)' })
@ApiStandardResponse(UserResponseDto, { description: '수정한 내 정보' })
@ApiErrorResponse('VAL_001', '입력값 검증 실패 · 내 이미지 파일이 아님')
updateMe(
@CurrentUser() user: AuthUser,
@Body() dto: UpdateMeDto,
): Promise<UserResponseDto> {
return this.userService.updateMe(user.userId, dto);
}
@Post()
@ApiOperation({ summary: '사용자 생성' })
@ApiStandardResponse(UserResponseDto, {
status: 201,
description: '사용자 생성 성공',
@Delete()
@HttpCode(HttpStatus.NO_CONTENT)
@SkipTransform() // 204 — 본문이 없다
@ApiOperation({
summary: '회원탈퇴',
description:
'회원과 모든 기록(아이·일정·다이어리·명함·사진)을 지운다. 되돌릴 수 없다.',
})
@ApiErrorResponse('VAL_001', '입력값 검증 실패')
@ApiErrorResponse('BIZ_001', '이메일 중복 등 비즈니스 규칙 위반')
create(@Body() dto: CreateUserDto) {
return this.userService.create(dto);
@ApiNoContentResponse({ description: '탈퇴 완료' })
async withdraw(@CurrentUser() user: AuthUser): Promise<void> {
await this.userService.withdraw(user.userId);
}
}
+6
View File
@@ -1,8 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UploadModule } from '@/modules/upload/upload.module';
import { SocialAccount } from './entities/social-account.entity';
import { User } from './entities/user.entity';
import { UserController } from './user.controller';
import { UserService } from './user.service';
@Module({
imports: [TypeOrmModule.forFeature([User, SocialAccount]), UploadModule],
controllers: [UserController],
providers: [UserService],
exports: [UserService],
+120 -43
View File
@@ -1,58 +1,135 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, Repository } from 'typeorm';
import { BusinessException } from '@/common/errors/business.exception';
import {
PaginatedResult,
toPaginatedResult,
} from '@/common/dto/pagination.dto';
import { CreateUserDto } from './dto/create-user.dto';
import { UploadService } from '@/modules/upload/upload.service';
import { isUniqueViolation } from '@/shared/utils/db-error';
import { UpdateMeDto } from './dto/update-me.dto';
import { UserResponseDto } from './dto/user-response.dto';
import {
SocialAccount,
VerifiedSocialAccount,
} from './entities/social-account.entity';
import { NICKNAME_MAX_LENGTH, User } from './entities/user.entity';
// 비즈니스 로직 계층 샘플 — 실제 구현 시 Repository 주입하여 데이터 접근
/** 가입·로그인 결과 */
export interface SignedInUser {
readonly user: UserResponseDto;
readonly isNewUser: boolean;
}
// 회원 — 가입(소셜 계정 연결)·내 정보·탈퇴
@Injectable()
export class UserService {
// 임시 인메모리 저장소 — TypeORM Repository 도입 시 교체
private readonly users = new Map<number, UserResponseDto>();
private readonly logger = new Logger(UserService.name);
findOne(id: number): UserResponseDto {
const user = this.users.get(id);
if (!user) {
// 사용자 노출 실패는 항상 에러 코드와 함께 던진다.
throw new BusinessException('RES_001', { context: { userId: id } });
}
return user;
constructor(
@InjectRepository(User)
private readonly users: Repository<User>,
@InjectRepository(SocialAccount)
private readonly accounts: Repository<SocialAccount>,
private readonly dataSource: DataSource,
private readonly uploads: UploadService,
) {}
/** 액세스 토큰의 회원이 아직 있는지 — 탈퇴한 회원의 토큰은 만료 전이라도 거절한다 */
exists(userId: string): Promise<boolean> {
return this.users.existsBy({ id: userId });
}
/** 커서 기반 목록 조회 샘플 — 목록 API 는 항상 페이지네이션을 적용한다. */
findMany(limit: number, cursor?: string): PaginatedResult<UserResponseDto> {
const afterId = cursor ? Number(cursor) : 0;
const sorted = [...this.users.values()].sort((a, b) => a.id - b.id);
const page = sorted.filter((user) => user.id > afterId).slice(0, limit);
const last = page.at(-1);
const hasNext = last ? sorted.some((user) => user.id > last.id) : false;
/** 소셜 계정으로 회원을 찾고, 처음이면 가입시킨다 */
async findOrCreateBySocial(
account: VerifiedSocialAccount,
): Promise<SignedInUser> {
const found = await this.findBySocial(account);
if (found) return { user: found, isNewUser: false };
return toPaginatedResult(page, {
hasNext,
nextCursor: hasNext && last ? String(last.id) : null,
total: sorted.length,
});
}
create(dto: CreateUserDto): UserResponseDto {
const duplicated = [...this.users.values()].some(
(user) => user.email === dto.email,
);
if (duplicated) {
// 도메인 고유 코드가 필요하면 error-codes.ts 의 DOMAIN_ERROR_CODES 에 등록 후 사용한다.
throw new BusinessException('BIZ_001', {
message: '이미 가입된 이메일입니다. 다른 이메일을 사용해 주세요.',
context: { email: dto.email },
try {
const user = await this.dataSource.transaction(async (manager) => {
const created = await manager.save(
manager.create(User, {
nickname: account.nickname?.slice(0, NICKNAME_MAX_LENGTH) || null,
photoFileId: null,
}),
);
await manager.save(
manager.create(SocialAccount, {
userId: created.id,
provider: account.provider,
subject: account.subject,
}),
);
return created;
});
this.logger.log(`회원 가입: ${user.id} (${account.provider})`);
return {
user: UserResponseDto.from(user, account.provider),
isNewUser: true,
};
} catch (error) {
// 같은 계정으로 동시에 가입하면 유니크 제약에 걸린다 — 먼저 만들어진 회원으로 로그인한다
if (!isUniqueViolation(error)) throw error;
const raced = await this.findBySocial(account);
if (!raced) throw error;
return { user: raced, isNewUser: false };
}
}
async getMe(userId: string): Promise<UserResponseDto> {
const [user, account] = await Promise.all([
this.users.findOneBy({ id: userId }),
this.accounts.findOne({ where: { userId }, order: { createdAt: 'ASC' } }),
]);
if (!user || !account) {
throw new BusinessException('RES_001', { context: { userId } });
}
return UserResponseDto.from(user, account.provider);
}
async updateMe(userId: string, dto: UpdateMeDto): Promise<UserResponseDto> {
const user = await this.users.findOneBy({ id: userId });
if (!user) throw new BusinessException('RES_001', { context: { userId } });
const previousPhoto = user.photoFileId;
if (dto.photoFileId !== undefined) {
await this.uploads.assertOwnedImage(dto.photoFileId, userId);
user.photoFileId = dto.photoFileId;
}
if (dto.nickname !== undefined) user.nickname = dto.nickname;
await this.users.save(user);
if (previousPhoto !== user.photoFileId) {
await this.uploads.releaseIfUnused([previousPhoto]);
}
return this.getMe(userId);
}
/**
* 회원탈퇴 — 회원과 그 회원의 모든 기록(아이·일정·다이어리·명함·파일 기록)을 지운다.
* 기록은 FK(ON DELETE CASCADE)로 함께 지워지고, 실제 파일은 그 뒤에 디스크에서 정리한다.
*/
async withdraw(userId: string): Promise<void> {
const storedNames = await this.uploads.storedNamesOf(userId);
const result = await this.users.delete({ id: userId });
if (!result.affected) {
throw new BusinessException('RES_001', { context: { userId } });
}
const id = this.users.size + 1;
const user: UserResponseDto = { id, email: dto.email, name: dto.name };
this.users.set(id, user);
return user;
for (const name of storedNames) await this.uploads.unlinkQuietly(name);
this.logger.log(`회원 탈퇴: ${userId} (파일 ${storedNames.length}개 정리)`);
}
private async findBySocial(
account: VerifiedSocialAccount,
): Promise<UserResponseDto | null> {
const found = await this.accounts.findOne({
where: { provider: account.provider, subject: account.subject },
relations: { user: true },
});
return found?.user
? UserResponseDto.from(found.user, found.provider)
: null;
}
}
+13 -2
View File
@@ -1,6 +1,7 @@
import { join } from 'path';
import { DataSourceOptions } from 'typeorm';
import { SnakeNamingStrategy } from '@/shared/config/snake-naming.strategy';
import { parsePositiveInt } from '@/shared/utils/env';
/**
@@ -30,6 +31,15 @@ export function isProductionEnv(): boolean {
return process.env.NODE_ENV === 'production';
}
/**
* 기동할 때 미적용 마이그레이션을 실행할지.
* 운영과 테스트(e2e — 빈 DB 에서 시작한다)는 자동으로 실행하고,
* 개발은 `migration:run` 으로 직접 적용한다 (생성한 SQL 을 확인한 뒤 적용하도록).
*/
function shouldRunMigrations(): boolean {
return isProductionEnv() || process.env.NODE_ENV === 'test';
}
/**
* 스키마 자동 동기화 사용 여부.
*
@@ -69,9 +79,10 @@ export function buildDatabaseOptions(): DataSourceOptions {
password: process.env.DB_PASSWORD ?? DEV_DB_DEFAULTS.password,
database: process.env.DB_NAME ?? DEV_DB_DEFAULTS.database,
synchronize: shouldSynchronize(),
// 운영 배포 시 미적용 마이그레이션을 자동 실행한다
migrationsRun: isProduction,
migrationsRun: shouldRunMigrations(),
logging: !isProduction,
// 컬럼은 snake_case, 엔티티 속성은 camelCase
namingStrategy: new SnakeNamingStrategy(),
extra: { ...POOL_OPTIONS },
entities: paths.entities,
migrations: paths.migrations,
@@ -12,6 +12,7 @@ const prod = (extra: Record<string, string> = {}) => ({
DB_USER: 'app_user',
DB_PASSWORD: 'a-real-secret',
DB_NAME: 'project_db',
JWT_SECRET: 'a-production-secret-that-is-long-enough-123',
...extra,
});
@@ -27,6 +28,18 @@ describe('validateEnv', () => {
).toThrow(/PORT 는 양의 정수/);
});
it('토큰 만료 기간 형식이 틀리면 실패한다', () => {
expect(() =>
validateEnv({
NODE_ENV: 'development',
JWT_ACCESS_EXPIRES_IN: '1시간',
}),
).toThrow(/JWT_ACCESS_EXPIRES_IN 는 15m · 1h · 14d/);
expect(() =>
validateEnv({ NODE_ENV: 'development', JWT_REFRESH_EXPIRES_IN: '14d' }),
).not.toThrow();
});
it('빈 문자열은 통과한다 (compose 가 미정의 변수를 빈 값으로 주입한다)', () => {
expect(() =>
validateEnv({ NODE_ENV: 'development', THROTTLE_LIMIT: '' }),
@@ -39,6 +52,15 @@ describe('validateEnv', () => {
expect(() => validateEnv(prod())).not.toThrow();
});
it('JWT_SECRET 이 없거나 짧으면 실패한다', () => {
expect(() => validateEnv(omit(prod(), 'JWT_SECRET'))).toThrow(
/JWT_SECRET 가 설정되지 않았습니다/,
);
expect(() => validateEnv(prod({ JWT_SECRET: 'short-secret' }))).toThrow(
/JWT_SECRET 는 32자 이상/,
);
});
it('필수 DB 설정이 없으면 실패한다', () => {
expect(() => validateEnv(omit(prod(), 'DB_PASSWORD'))).toThrow(
/DB_PASSWORD 가 설정되지 않았습니다/,
@@ -7,6 +7,8 @@
* `ConfigModule.forRoot({ validate })` 에 연결되어 있다 (app.module.ts).
*/
import { parseDurationMs } from '@/shared/utils/duration';
/** 운영 배포 전에 반드시 교체해야 하는 예시 값들 — `*.example` 에서 복사한 흔적 */
const PLACEHOLDER_PATTERNS = [/change[-_]?me/i, /^password$/i, /^secret$/i];
@@ -16,6 +18,17 @@ const REQUIRED_IN_PRODUCTION = [
'DB_USER',
'DB_PASSWORD',
'DB_NAME',
// 비워 두면 개발용 고정 키로 서명하게 된다 — 운영에서는 기동을 막는다 (modules/auth/auth.config.ts)
'JWT_SECRET',
] as const;
/** 운영 JWT 서명 키 최소 길이 — 짧은 키는 무차별 대입으로 풀린다 */
const JWT_SECRET_MIN_LENGTH = 32;
/** 값이 있다면 `15m`·`1h`·`14d` 형식이어야 하는 키 */
const DURATION_KEYS = [
'JWT_ACCESS_EXPIRES_IN',
'JWT_REFRESH_EXPIRES_IN',
] as const;
/** 값이 있다면 양의 정수여야 하는 키 */
@@ -95,6 +108,16 @@ export function validateEnv(
}
}
// 기간 형식 — 틀리면 토큰 만료가 조용히 기본값이 되므로 기동 시점에 막는다
for (const key of DURATION_KEYS) {
const value = readString(config, key);
if (value !== undefined && parseDurationMs(value) === null) {
errors.push(
`${key} 는 15m · 1h · 14d 같은 기간 형식이어야 합니다. (현재 값: "${value}")`,
);
}
}
if (isProduction) {
// 2. 운영 필수값 — 없으면 기동 실패
for (const key of REQUIRED_IN_PRODUCTION) {
@@ -113,6 +136,13 @@ export function validateEnv(
}
}
const jwtSecret = readString(config, 'JWT_SECRET');
if (jwtSecret !== undefined && jwtSecret.length < JWT_SECRET_MIN_LENGTH) {
errors.push(
`JWT_SECRET 는 ${JWT_SECRET_MIN_LENGTH}자 이상이어야 합니다. (운영 환경)`,
);
}
// 4. 공개 주소가 로컬 주소·예시 도메인으로 남은 채 배포되는 것을 막는다
const allowLocalhostUrl =
readString(config, ALLOW_LOCALHOST_URL_KEY) === 'true';
@@ -0,0 +1,55 @@
import { DefaultNamingStrategy, NamingStrategyInterface } from 'typeorm';
import { snakeCase } from '@/shared/utils/case';
/**
* 엔티티 속성(camelCase)을 DB 컬럼(snake_case)으로 바꾸는 이름 규칙.
* Postgres 는 대문자가 섞인 이름을 따옴표로 감싸야 해서, 컬럼은 snake_case 로 통일한다.
* 응답(JSON)은 엔티티 속성 이름 그대로 camelCase 다 (.claude/rules/api-design.md).
*/
export class SnakeNamingStrategy
extends DefaultNamingStrategy
implements NamingStrategyInterface
{
override tableName(className: string, customName?: string): string {
return customName ?? snakeCase(className);
}
override columnName(
propertyName: string,
customName: string | undefined,
embeddedPrefixes: string[],
): string {
const prefix = embeddedPrefixes.map((p) => `${snakeCase(p)}_`).join('');
return prefix + (customName ?? snakeCase(propertyName));
}
override relationName(propertyName: string): string {
return snakeCase(propertyName);
}
override joinColumnName(
relationName: string,
referencedColumnName: string,
): string {
return snakeCase(`${relationName}_${referencedColumnName}`);
}
override joinTableName(
firstTableName: string,
secondTableName: string,
firstPropertyName: string,
): string {
return snakeCase(
`${firstTableName}_${firstPropertyName.replace(/\./g, '_')}_${secondTableName}`,
);
}
override joinTableColumnName(
tableName: string,
propertyName: string,
columnName?: string,
): string {
return snakeCase(`${tableName}_${columnName ?? propertyName}`);
}
}
+21
View File
@@ -0,0 +1,21 @@
import { snakeCase } from './case';
describe('snakeCase', () => {
it('camelCase 를 snake_case 로 바꾼다', () => {
expect(snakeCase('photoFileId')).toBe('photo_file_id');
expect(snakeCase('createdAt')).toBe('created_at');
});
it('PascalCase 와 연속 대문자도 단어 단위로 나눈다', () => {
expect(snakeCase('RefreshToken')).toBe('refresh_token');
expect(snakeCase('HTTPServer')).toBe('http_server');
});
it('이미 snake_case 면 그대로 둔다', () => {
expect(snakeCase('user_id')).toBe('user_id');
});
it('점·하이픈은 밑줄로 바꾼다 (임베디드 접두어)', () => {
expect(snakeCase('archive.from')).toBe('archive_from');
});
});
+13
View File
@@ -0,0 +1,13 @@
// 문자열 표기 변환 — 부수효과 없는 순수 함수
/**
* camelCase · PascalCase 를 snake_case 로 바꾼다.
* DB 컬럼·테이블 이름에 쓴다 (예: `photoFileId` → `photo_file_id`, `HTTPServer` → `http_server`).
*/
export function snakeCase(value: string): string {
return value
.replace(/([a-z\d])([A-Z])/g, '$1_$2')
.replace(/([A-Z]+)([A-Z][a-z\d]+)/g, '$1_$2')
.replace(/[\s.-]+/g, '_')
.toLowerCase();
}

Some files were not shown because too many files have changed in this diff Show More