Skip to content

feat: 회원 탈퇴 및 비밀번호 변경 API 추가와 정책 반영 - #150

Merged
sangwon02 merged 17 commits into
mainfrom
feat/58-account-withdrawal
Aug 8, 2026
Merged

feat: 회원 탈퇴 및 비밀번호 변경 API 추가와 정책 반영#150
sangwon02 merged 17 commits into
mainfrom
feat/58-account-withdrawal

Conversation

@sangwon02

@sangwon02 sangwon02 commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

🔗 관련 이슈 (Related Issue)

Closes #58

📝 작업 내용

신규 API 2개

메서드 경로 설명
DELETE /api/v1/users/me 회원 탈퇴
PATCH /api/v1/auth/password 비밀번호 변경

회원 탈퇴

물리 삭제 대신 deleted_at 기록과 개인정보 익명화로 처리합니다. 프로젝트·공고·피드백이 유저를 FK로 참조하고 있어 행을 지우면 이력이 끊깁니다.

emailnicknameNOT NULL이라 NULL로 비울 수 없어, id를 섞은 고유 값(withdrawn_{id}@slatto.invalid, 탈퇴한 사용자_{id})으로 덮습니다. 이 방식으로 스키마 변경 없이 익명화와 유니크 제약을 동시에 만족하며, 원래 이메일이 사라지므로 같은 주소로 재가입할 수 있습니다.

포트폴리오와 작성한 공고도 함께 내려갑니다. 작성자가 없는 공고가 목록에 남으면 지원해도 응답받을 수 없기 때문입니다. 비밀번호가 설정된 계정은 탈퇴 시 비밀번호를 재확인합니다.

비밀번호 변경

로그인 상태에서 현재 비밀번호를 확인하고 바꿉니다. 비밀번호를 잊어 인증번호로 재설정하는 POST /auth/password/reset과는 다른 경로입니다. 유출을 가정해 세션을 끊는 재설정과 달리, 본인이 로그인한 상태이므로 토큰을 새로 발급해 그대로 이어 쓰게 합니다.

탈퇴 유저 접근 차단

탈퇴해도 이미 발급된 액세스 토큰은 만료까지 서명 검증을 통과해 최대 1시간 API가 열려 있었습니다. 인증 필터에서 삭제 여부를 확인하도록 했습니다.

정책 문서 반영

기획 정책 문서와 대조해 어긋난 값을 맞췄습니다.

항목 이전 변경
공고 필수 항목 9개 전부 필수 제목·모집파트·상세내용·연락처만
공고 제목 최대 100자 5~50자
마감된 공고 수정 가능 차단
프로필 이미지 용량 10MB 2MB
이름·닉네임 1~20자, 특수문자 허용 2~20자, 특수문자 불가
소개 제한 없음 200자

공고 컬럼이 모두 nullable이라 필수 완화에도 스키마 변경이 없었습니다.

기타

CORS 허용 오리진에 프론트 개발 주소(Vite 5173, Vercel dev 프리뷰)를 추가했습니다.

✅ PR 체크리스트

  • PR 제목은 커밋 컨벤션을 따랐습니다.
  • 관련 이슈를 연결했습니다.
  • 변경 사항에 대한 테스트를 진행했습니다.

Summary by CodeRabbit

  • 새 기능

    • 로그인한 사용자가 현재 비밀번호를 확인한 뒤 비밀번호를 변경할 수 있습니다.
    • 회원 탈퇴 기능이 추가되었습니다. 탈퇴 시 관련 콘텐츠와 토큰이 정리됩니다.
    • 탈퇴한 계정은 더 이상 인증되지 않습니다.
  • 개선

    • 닉네임, 자기소개, 비밀번호 및 채용 공고 입력 검증이 강화되었습니다.
    • 마감된 공고의 수정 제한과 제목 길이 기준이 적용됩니다.
    • 프로필 이미지 최대 업로드 용량이 2MB로 변경되었습니다.
    • 로컬 및 프리뷰 환경의 접속 허용 범위가 확대되었습니다.
  • 문서

    • 채용 공고 작성·수정 API 안내가 보완되었습니다.

- Vite 기본 포트(5173)와 Vercel dev 프리뷰 주소 추가
- 이 목록은 CookieCsrfProtectionFilter 의 신뢰 출처로도 쓰인다
- DELETE /api/v1/users/me 추가, 동의하지 않으면 400
- 물리 삭제 대신 deleted_at 기록과 개인정보 익명화로 처리한다.
  연관 데이터가 유저를 FK 로 참조하고 있어 행을 지우면 이력이 끊긴다
- email 과 nickname 이 NOT NULL 이라 NULL 대신 id 를 섞은 고유 값으로 덮는다.
  email 유니크 제약을 피하면서 원래 주소가 사라져 같은 이메일로 재가입할 수 있다
- 포트폴리오 일괄 soft delete, 리프레시 토큰 삭제, 쿠키 만료
탈퇴해도 이미 발급된 액세스 토큰은 만료까지 서명 검증을 통과해 그 시간 동안
API 가 열려 있었다. 인증 필터에서 삭제 여부를 확인해 인증을 세우지 않도록 한다.
인증된 요청마다 PK 조회가 한 번 늘어난다.
- PATCH /api/v1/auth/password 추가, 로그인 상태에서만 호출한다
- 현재 비밀번호를 확인한다. 세션이 탈취된 상태에서 비밀번호까지 바뀌면
  계정을 통째로 빼앗기므로 생략할 수 없다
- 비밀번호가 없는 소셜 전용 계정은 거부하고 비밀번호 찾기로 유도한다
- 기존과 같은 비밀번호는 거부한다
- 변경 성공 시 리프레시 토큰을 새로 발급한다. 유출을 가정해 세션을 끊는
  비밀번호 재설정과 달리 본인이 로그인한 상태라 끊을 이유가 없다
- 탈퇴 시 익명화, 유저 행 보존, 포트폴리오 동반 삭제, 이메일 해제, 토큰 삭제 검증
- 비밀번호 변경 시 현재 비밀번호 검증, 소셜 전용 계정 거부, 동일 비밀번호 거부 검증
- 비밀번호 검증은 목이 아닌 실제 BCrypt 로 확인한다
- 활성 유저는 인증을 세우고 탈퇴 유저는 세우지 않는지 검증
- 토큰이 없거나 유효하지 않으면 유저 조회를 건너뛰는지 확인해
  불필요한 쿼리가 늘지 않도록 고정한다
작성자가 없는 공고가 목록에 남으면 지원자가 응답받을 수 없는 곳에 지원하게 된다.
포트폴리오와 같은 기준으로 공고도 soft delete 한다.

내 지원 목록 조회가 공고의 deleted_at 을 함께 확인하므로, 해당 공고에 지원했던
이력은 목록에서 빠진다. 지원 데이터 자체는 남는다.
정책 문서 기준으로 맞춘다.

- 필수는 제목, 모집 파트, 상세 내용, 연락처만 남긴다. 카테고리·영상 길이·지역·
  촬영 기간·급여·마감일은 선택으로 연다. 컬럼이 모두 nullable 이라 스키마 변경은 없다
- 공고 제목을 5~50자로 조정한다
- 마감일을 비우면 수동으로 마감할 때까지 모집이 유지된다
- 마감된 공고는 내용을 수정할 수 없다. 다만 상태 변경이 같은 API 를 쓰므로
  다시 모집중으로 되돌리는 요청은 통과시킨다
- 프로필 이미지 제한을 10MB 에서 2MB 로 낮추고 허용 형식을 jpg·jpeg·png·gif 로 조정
- 이름과 닉네임을 특수문자 없이 2~20자로 검증
- 소개는 200자 이하로 제한
- 탈퇴 시 비밀번호를 재확인한다. 세션이 탈취된 상태에서 탈퇴까지 되면
  계정을 통째로 지워버릴 수 있다. 비밀번호가 없는 소셜 전용 계정은
  확인할 값이 없어 필수로 걸지 않는다
정책 문서는 gif 를 포함하지만 기존 구현의 webp 를 유지한다.
용량 제한 2MB 조정은 그대로 둔다.
BCryptPasswordEncoder.matches 는 raw 가 null 이면 IllegalArgumentException 을
던져 서버 오류로 나갔다. 비밀번호가 설정된 계정이 값을 보내지 않은 경우도
불일치와 같은 401 로 응답한다.
@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 2999e946-e1d8-4e76-8326-7d6a13f3847f

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

비밀번호 변경과 회원 탈퇴 API가 추가되었습니다. 탈퇴 시 개인정보를 익명화하고 연관 데이터를 소프트 삭제합니다. 공고 및 회원 입력 검증을 강화하고, 탈퇴 사용자의 JWT 인증을 차단합니다. 관련 통합 테스트를 추가했습니다.

Changes

계정 관리 및 도메인 검증

Layer / File(s) Summary
비밀번호 변경 흐름
src/main/java/com/slatto/domain/auth/..., src/test/java/com/slatto/domain/auth/...
인증된 사용자의 PATCH /password API를 추가했습니다. 현재 비밀번호 검증, 새 비밀번호 형식 검증, 토큰 재발급, 관련 오류 코드와 테스트를 추가했습니다.
회원 탈퇴 및 연관 데이터 정리
src/main/java/com/slatto/domain/user/..., src/test/java/com/slatto/domain/user/service/...
DELETE 탈퇴 API를 추가했습니다. 동의와 비밀번호를 검증하고, 사용자 정보를 익명화하며 포트폴리오·공고를 소프트 삭제하고 refresh token을 삭제합니다.
공고 입력 및 마감 상태 규칙
src/main/java/com/slatto/domain/recruitment/...
공고 제목과 입력 필드 검증을 변경했습니다. 마감 공고의 수정 제한과 작성자 공고 일괄 소프트 삭제를 추가하고 Swagger 설명을 갱신했습니다.
입력 검증 및 인증 상태 처리
.env.example, src/main/java/com/slatto/domain/auth/dto/..., src/main/java/com/slatto/domain/user/..., src/main/java/com/slatto/global/security/..., src/test/java/com/slatto/global/security/...
이름·닉네임·소개·이미지 입력 제한을 변경했습니다. Vite 및 Vercel 오리진을 CORS 예시에 추가하고, 탈퇴 사용자의 JWT 인증을 차단했습니다.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

  • SLAT-TO/SLATE-TO-BE#18: 인증 컨트롤러, 인증 서비스, 사용자 엔티티와 JWT 흐름을 함께 변경합니다.
  • SLAT-TO/SLATE-TO-BE#142: 비밀번호 인증 흐름과 관련된 동일한 인증 구성 요소를 변경합니다.

Suggested labels: feature, fix

Suggested reviewers: guingguing, kohseoyoung, young0206

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 26.92% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed 제목이 회원 탈퇴와 비밀번호 변경 API 추가라는 PR의 주요 변경 사항을 명확하게 요약합니다.
Description check ✅ Passed 관련 이슈, 작업 내용, 체크리스트를 모두 포함하며 API와 정책 변경 및 테스트 내용을 구체적으로 설명합니다.
Linked Issues check ✅ Passed 이슈 #58의 탈퇴, 익명화, 토큰 차단, 비밀번호 변경, 오류 처리 및 테스트 요구 사항을 변경 사항이 충족합니다.
Out of Scope Changes check ✅ Passed 정책 변경, API 문서화, 테스트 및 CORS 수정은 PR objectives에 포함되어 있어 범위를 벗어나지 않습니다.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

프로필 이미지 업로드가 PUT 이라 preflight 단계에서 차단되고 있었다.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
src/test/java/com/slatto/domain/auth/service/PasswordChangeTest.java (1)

103-114: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

리프레시 토큰 재발급 결과를 검증하세요.

changesPasswordEmailAuthResult를 무시합니다. 따라서 비밀번호 해시만 변경되고 새 액세스 토큰 또는 리프레시 토큰이 반환되지 않아도 테스트가 통과합니다. 성공 결과의 accessTokenrefreshToken을 검증하세요.

수정 예시
-		authService.changePassword(emailUserId, CURRENT_PASSWORD, NEW_PASSWORD);
+		AuthService.EmailAuthResult result =
+			authService.changePassword(emailUserId, CURRENT_PASSWORD, NEW_PASSWORD);
 		entityManager.flush();
 		entityManager.clear();
 
+		assertThat(result.accessToken()).isEqualTo("access-token");
+		assertThat(result.refreshToken()).isEqualTo("refresh-token");
+
 		String stored = userRepository.findById(emailUserId).orElseThrow().getPassword();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/test/java/com/slatto/domain/auth/service/PasswordChangeTest.java` around
lines 103 - 114, Update changesPassword to capture the EmailAuthResult returned
by authService.changePassword and assert that the successful result contains
non-empty accessToken and refreshToken values, while preserving the existing
password-hash assertions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@src/main/java/com/slatto/domain/recruitment/repository/RecruitmentRepository.java`:
- Around line 21-31: Update softDeleteAllByWriterId to protect deletedAt against
concurrent recruitment edits by adding database-level locking or an
optimistic-lock/check condition that serializes the soft-delete and update
paths. Ensure the bulk update cannot leave a subsequently flushed entity
restoring deletedAt to null, while preserving the existing writerId and
deletedAt-is-null filtering.

In `@src/main/java/com/slatto/domain/recruitment/service/RecruitmentService.java`:
- Around line 111-115: Update the closed-recruitment validation around isClosed
and RecruitmentStatus.RECRUITING to calculate the effective deadline for the
requested update, using the existing deadline when the request omits one. Reject
the transition unless that final deadline is today or later, while preserving
the existing allowance for valid future-dated RECRUITING transitions and
blocking other edits to closed recruitments.

In `@src/main/java/com/slatto/domain/user/service/UserService.java`:
- Around line 293-297: Update the withdrawal flow in UserService around
user.withdraw() to capture the managed profile-image storage key before the URL
is cleared, then schedule deletion of that object only after the transaction
commits. Reuse the existing storage deletion and transaction-event mechanisms,
and extend the withdrawal integration test to verify the profile image object is
deleted after commit.

---

Nitpick comments:
In `@src/test/java/com/slatto/domain/auth/service/PasswordChangeTest.java`:
- Around line 103-114: Update changesPassword to capture the EmailAuthResult
returned by authService.changePassword and assert that the successful result
contains non-empty accessToken and refreshToken values, while preserving the
existing password-hash assertions.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: dcd59751-77ac-4b65-80b9-72e179db10a4

📥 Commits

Reviewing files that changed from the base of the PR and between 6586eaf and df50b7a.

📒 Files selected for processing (25)
  • .env.example
  • src/main/java/com/slatto/domain/auth/controller/AuthController.java
  • src/main/java/com/slatto/domain/auth/dto/EmailSignupRequest.java
  • src/main/java/com/slatto/domain/auth/dto/PasswordChangeRequest.java
  • src/main/java/com/slatto/domain/auth/exception/AuthErrorCode.java
  • src/main/java/com/slatto/domain/auth/service/AuthService.java
  • src/main/java/com/slatto/domain/recruitment/controller/RecruitmentController.java
  • src/main/java/com/slatto/domain/recruitment/dto/RecruitmentCreateRequest.java
  • src/main/java/com/slatto/domain/recruitment/dto/RecruitmentUpdateRequest.java
  • src/main/java/com/slatto/domain/recruitment/exception/RecruitmentErrorCode.java
  • src/main/java/com/slatto/domain/recruitment/repository/RecruitmentRepository.java
  • src/main/java/com/slatto/domain/recruitment/service/RecruitmentService.java
  • src/main/java/com/slatto/domain/user/controller/UserController.java
  • src/main/java/com/slatto/domain/user/dto/UserOnboardingRequest.java
  • src/main/java/com/slatto/domain/user/dto/UserProfileUpdateRequest.java
  • src/main/java/com/slatto/domain/user/dto/UserWithdrawRequest.java
  • src/main/java/com/slatto/domain/user/entity/Users.java
  • src/main/java/com/slatto/domain/user/exception/UserErrorCode.java
  • src/main/java/com/slatto/domain/user/repository/UserPortfolioRepository.java
  • src/main/java/com/slatto/domain/user/service/UserService.java
  • src/main/java/com/slatto/global/security/JwtAuthenticationFilter.java
  • src/test/java/com/slatto/domain/auth/service/PasswordChangeTest.java
  • src/test/java/com/slatto/domain/user/service/PortfolioUpdatedAtTest.java
  • src/test/java/com/slatto/domain/user/service/UserWithdrawTest.java
  • src/test/java/com/slatto/global/security/JwtAuthenticationFilterTest.java

Comment on lines +21 to +31
// 작성자가 탈퇴하면 공고도 함께 내린다. 연락받을 사람이 없는 공고가 목록에 남으면
// 지원자가 응답 없는 공고에 지원하게 된다.
// clearAutomatically 는 쓰지 않는다. 같은 트랜잭션에 로딩된 엔티티가 detach 된다.
@Modifying
@Query("""
update Recruitment r
set r.deletedAt = :deletedAt
where r.writer.id = :writerId
and r.deletedAt is null
""")
int softDeleteAllByWriterId(@Param("writerId") Long writerId, @Param("deletedAt") LocalDateTime deletedAt);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 8 \
  '`@Version`|`@DynamicUpdate`|`@Lock`|softDeleteAllByWriterId|findActiveWithWriterById|deletedAt' \
  src/main/java/com/slatto/domain/recruitment \
  src/main/java/com/slatto/domain/user

Repository: SLAT-TO/SLATE-TO-BE

Length of output: 50375


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '== files ==\n'
git ls-files 'src/main/java/com/slatto/domain/recruitment/*' 'src/main/java/com/slatto/domain/recruitment/**/*.java' | sort

printf '\n== Recruitment.java base annotations and updated_at mapping ==\n'
sed -n '1,120p' src/main/java/com/slatto/domain/recruitment/entity/Recruitment.java

printf '\n== RecruitmentService update path relevant lines ==\n'
sed -n '99,150p' src/main/java/com/slatto/domain/recruitment/service/RecruitmentService.java

printf '\n== repository select/update query patterns ==\n'
rg -n --glob '*.java' '`@Lock`|findActiveWithWriterById|softDeleteAllByWriterId|update Recruitment|set (r|\w+)\.' src/main/java/com/slatto -C 3

Repository: SLAT-TO/SLATE-TO-BE

Length of output: 21131


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '== BaseEntity.java candidates ==\n'
git ls-files | rg 'src/main/java/.*/BaseEntity|src/main/java/.*/entity/BaseEntity\.java$' || true

printf '\n== Recruitment.update implementation ==\n'
sed -n '150,195p' src/main/java/com/slatto/domain/recruitment/entity/Recruitment.java

printf '\n== User update/delete persistence mappings around entities ==\n'
rg -n '`@Entity`|`@Table`|updated_at|lastUpdated|createdAt|deleted_at|BaseEntity' src/main/java/com/slatto/domain/common src/main/java/com/slatto/domain/user src/main/java/com/slatto/domain/project -g '*.java' | head -n 200

printf '\n== Base entity files ==\n'
for f in $(git ls-files | rg 'BaseEntity\.java$'); do
  echo "--- $f"
  sed -n '1,140p' "$f"
done

Repository: SLAT-TO/SLATE-TO-BE

Length of output: 10805


동시 탈퇴와 공고 수정의 race를 막기 위해 deletedAt도 함께 잠금하세요.

@DynamicUpdate는 비dirty한 컬럼을 exclude하지만, 벌크 UPDATE 이후 같은 트랜잭션에서 엔티티를 load하고 수정하면 flush 때 deletedAt이 null로 다시 덮어써질 수 있습니다. recruitment에 적어도 DB 잠금이나 deletedAt를 가진 optimistic lock/check 조건을 포함해 두 경로를 직렬화하거나 soft delete 조건을 보호하세요.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/slatto/domain/recruitment/repository/RecruitmentRepository.java`
around lines 21 - 31, Update softDeleteAllByWriterId to protect deletedAt
against concurrent recruitment edits by adding database-level locking or an
optimistic-lock/check condition that serializes the soft-delete and update
paths. Ensure the bulk update cannot leave a subsequently flushed entity
restoring deletedAt to null, while preserving the existing writerId and
deletedAt-is-null filtering.

Comment on lines +111 to +115
// 마감된 공고는 내용을 수정할 수 없다. 다만 다시 모집중으로 되돌리는 요청은 통과시킨다.
// 전면 차단하면 상태 변경도 같은 API 를 쓰므로 수동 마감을 취소할 방법이 사라진다.
if (isClosed(recruitment) && request.getStatus() != RecruitmentStatus.RECRUITING) {
throw new BaseException(RecruitmentErrorCode.RECRUITMENT_CLOSED_NOT_EDITABLE);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

자동 마감 공고의 재개 조건을 보완해야 합니다.

현재 조건은 request.getStatus() == RecruitmentStatus.RECRUITING이면 deadline을 확인하지 않습니다. 자동 마감 공고에 status=RECRUITING만 보내거나 과거 deadline을 보내도 검증을 통과할 수 있습니다. 이후 recruitment.update(...)가 같은 요청의 내용 변경을 반영하므로, src/main/java/com/slatto/domain/recruitment/controller/RecruitmentController.java Line 129-134의 정책을 우회할 수 있습니다.

RECRUITING 전환 시 적용될 최종 deadline을 계산하세요. 기존 deadline을 유지하는 경우에도 그 날짜가 오늘보다 과거이면 거부하세요.

수정 예시
+        LocalDate today = recruitmentConverter.currentDate();
+        LocalDate effectiveDeadline = request.getDeadline() != null
+            ? request.getDeadline()
+            : recruitment.getDeadline();
+
-        if (isClosed(recruitment) && request.getStatus() != RecruitmentStatus.RECRUITING) {
+        if (isClosed(recruitment)
+            && (request.getStatus() != RecruitmentStatus.RECRUITING
+            || (effectiveDeadline != null && effectiveDeadline.isBefore(today)))) {
             throw new BaseException(RecruitmentErrorCode.RECRUITMENT_CLOSED_NOT_EDITABLE);
         }

자동 마감 공고에서 status=RECRUITING과 오늘 이후의 deadline을 함께 보내는 경우, 그리고 deadline을 생략하거나 과거로 보내는 경우를 각각 테스트하세요.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 마감된 공고는 내용을 수정할 수 없다. 다만 다시 모집중으로 되돌리는 요청은 통과시킨다.
// 전면 차단하면 상태 변경도 같은 API 를 쓰므로 수동 마감을 취소할 방법이 사라진다.
if (isClosed(recruitment) && request.getStatus() != RecruitmentStatus.RECRUITING) {
throw new BaseException(RecruitmentErrorCode.RECRUITMENT_CLOSED_NOT_EDITABLE);
}
// 마감된 공고는 내용을 수정할 수 없다. 다만 다시 모집중으로 되돌리는 요청은 통과시킨다.
// 전면 차단하면 상태 변경도 같은 API 를 쓰므로 수동 마감을 취소할 방법이 사라진다.
LocalDate today = recruitmentConverter.currentDate();
LocalDate effectiveDeadline = request.getDeadline() != null
? request.getDeadline()
: recruitment.getDeadline();
if (isClosed(recruitment)
&& (request.getStatus() != RecruitmentStatus.RECRUITING
|| (effectiveDeadline != null && effectiveDeadline.isBefore(today)))) {
throw new BaseException(RecruitmentErrorCode.RECRUITMENT_CLOSED_NOT_EDITABLE);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/slatto/domain/recruitment/service/RecruitmentService.java`
around lines 111 - 115, Update the closed-recruitment validation around isClosed
and RecruitmentStatus.RECRUITING to calculate the effective deadline for the
requested update, using the existing deadline when the request omits one. Reject
the transition unless that final deadline is today or later, while preserving
the existing allowance for valid future-dated RECRUITING transitions and
blocking other edits to closed recruitments.

Comment on lines +293 to +297
userPortfolioRepository.softDeleteAllByUserId(userId, withdrawnAt);
recruitmentRepository.softDeleteAllByWriterId(userId, withdrawnAt);
refreshTokenRepository.deleteByUser(user);

user.withdraw(withdrawnAt);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

탈퇴 후 프로필 이미지 객체를 삭제해야 합니다.

user.withdraw()profileImageUrl만 null로 변경합니다. 외부 스토리지의 기존 객체는 삭제하지 않습니다. 기존 공개 URL을 가진 사용자는 탈퇴 후에도 이미지를 조회할 수 있습니다.

user.withdraw() 전에 관리 대상 스토리지 키를 추출하고, 트랜잭션 커밋 후 객체를 삭제하십시오. 객체 삭제도 탈퇴 통합 테스트로 검증하십시오.

수정 예시
 LocalDateTime withdrawnAt = LocalDateTime.now();
+String profileImageStorageKey = extractManagedStorageKey(user.getProfileImageUrl());

 userPortfolioRepository.softDeleteAllByUserId(userId, withdrawnAt);
 recruitmentRepository.softDeleteAllByWriterId(userId, withdrawnAt);
 refreshTokenRepository.deleteByUser(user);

 user.withdraw(withdrawnAt);
+registerPreviousFileDeletionAfterCommit(profileImageStorageKey);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/slatto/domain/user/service/UserService.java` around lines
293 - 297, Update the withdrawal flow in UserService around user.withdraw() to
capture the managed profile-image storage key before the URL is cleared, then
schedule deletion of that object only after the transaction commits. Reuse the
existing storage deletion and transaction-event mechanisms, and extend the
withdrawal integration test to verify the profile image object is deleted after
commit.

- 마감 공고 재개 조건 보완. status 만 RECRUITING 으로 보내도 적용될 마감일이
  과거면 여전히 마감이라 거부한다. 이전에는 같은 요청에 실린 내용 변경까지
  반영돼 마감 공고 수정 금지가 우회됐다
- 탈퇴 시 프로필 이미지 객체를 스토리지에서 삭제한다. URL 만 비우면 기존
  공개 URL 을 아는 사람이 탈퇴 후에도 사진을 조회할 수 있었다
- 위 두 건과 마감 공고 내용 수정 차단을 테스트로 고정
.env.example 은 로컬 개발용 템플릿이라 Vercel 프리뷰 주소가 들어갈 자리가 아니다.
외부 오리진이 로컬 API 를 호출할 수 없어 값으로서 의미가 없다.
배포 환경에 넣어야 하는 값이라는 안내와, 목록을 덮어쓰지 말라는 주의만 남긴다.
@sangwon02
sangwon02 merged commit 764826a into main Aug 8, 2026
2 checks passed
@guingguing
guingguing deleted the feat/58-account-withdrawal branch August 12, 2026 15:35
@guingguing guingguing added the feature 새로운 기능 추가 label Aug 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature 새로운 기능 추가

Projects

None yet

Development

Successfully merging this pull request may close these issues.

FEAT: 회원 탈퇴 및 비밀번호 변경 API 구현

3 participants