Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
f3dc95d
chore: CORS 허용 오리진에 프론트 개발 주소 추가
sangwon02 Aug 8, 2026
f943630
feat: 회원 탈퇴 API 추가
sangwon02 Aug 8, 2026
e5fe86d
feat: 탈퇴 유저의 잔존 액세스 토큰 차단
sangwon02 Aug 8, 2026
41648b8
feat: 비밀번호 변경 API 추가
sangwon02 Aug 8, 2026
4648067
test: 회원 탈퇴와 비밀번호 변경 검증 추가
sangwon02 Aug 8, 2026
b89adee
test: 탈퇴 유저 접근 차단 검증 추가
sangwon02 Aug 8, 2026
42ce328
feat: 탈퇴 시 작성한 공고도 함께 내림
sangwon02 Aug 8, 2026
1717967
fix: 공고 등록 필수 항목 완화 및 마감 공고 수정 차단
sangwon02 Aug 8, 2026
e0e6823
fix: 마이페이지 정책 값 반영 및 탈퇴 시 비밀번호 재인증 추가
sangwon02 Aug 8, 2026
4daaafc
fix: 프로필 이미지 허용 형식을 기존 목록으로 되돌림
sangwon02 Aug 8, 2026
d304244
docs: 변경된 공고 등록·수정 정책과 탈퇴 재인증을 API 설명에 반영
sangwon02 Aug 8, 2026
c4e89a1
fix: 탈퇴 시 비밀번호 미전달을 500 대신 401 로 처리
sangwon02 Aug 8, 2026
df50b7a
docs: 온보딩·프로필 수정·이미지 업로드 입력 규칙을 API 설명에 반영
sangwon02 Aug 8, 2026
bb04463
fix: CORS 허용 메서드에 PUT 추가
sangwon02 Aug 8, 2026
944fb44
fix: 코드 리뷰 지적 사항 반영
sangwon02 Aug 8, 2026
23342f8
chore: CORS 예시에서 배포 전용 오리진 제거
sangwon02 Aug 8, 2026
9c64472
Revert "fix: CORS 허용 메서드에 PUT 추가"
sangwon02 Aug 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ COOKIE_SAME_SITE=Lax

# CORS 허용 오리진. 콤마로 구분
# POST /api/v1/auth/refresh의 CSRF 방어에도 쓰인다. 이 목록에 없는 오리진의 재발급 요청은 403으로 차단된다
CORS_ALLOWED_ORIGINS=http://localhost:3000
# 프론트 로컬은 Next.js 3000, Vite 5173
# 배포 환경에는 프론트 운영 주소와 Vercel 프리뷰 주소를 넣는다. 프리뷰는 브랜치마다 주소가 달라 개별 등록이 필요하다
# 값을 고칠 때는 기존 목록을 덮어쓰지 말고 콤마로 덧붙여야 한다. 목록 전체를 교체하는 변수다
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173

# 인증번호 발송용 Gmail SMTP 계정
# MAIL_PASSWORD는 계정 비밀번호가 아니라 2단계 인증을 켠 뒤 발급한 16자리 앱 비밀번호다
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import com.slatto.domain.auth.dto.EmailVerificationConfirmResponse;
import com.slatto.domain.auth.dto.EmailVerificationSendRequest;
import com.slatto.domain.auth.dto.EmailVerificationSendResponse;
import com.slatto.domain.auth.dto.PasswordChangeRequest;
import com.slatto.domain.auth.dto.PasswordResetRequest;
import com.slatto.domain.auth.service.AuthService;
import com.slatto.domain.auth.service.EmailVerificationService;
Expand All @@ -23,8 +24,10 @@
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
Expand Down Expand Up @@ -197,6 +200,32 @@ public ApiResponse<EmailVerificationConfirmResponse> confirmEmailVerificationCod
return ApiResponse.success(CommonSuccessCode.OK, response);
}

@Operation(
summary = "비밀번호 변경",
description = """
로그인한 상태에서 현재 비밀번호를 확인하고 새 비밀번호로 바꾼다.

비밀번호를 잊어버려 인증번호로 재설정하는 `POST /auth/password/reset` 과는 다른 경로다.
변경 성공 시 리프레시 토큰을 새로 발급한다.

구글로만 가입해 비밀번호가 없는 계정은 이 API 로 설정할 수 없다. 비밀번호 찾기를 이용한다.
"""
)
@PatchMapping("/password")
public ResponseEntity<ApiResponse<EmailAuthResponse>> changePassword(
@AuthenticationPrincipal Long userId,
@Valid @RequestBody PasswordChangeRequest request
) {
AuthService.EmailAuthResult result = authService.changePassword(
userId, request.currentPassword(), request.newPassword()
);

return ResponseEntity
.ok()
.header(HttpHeaders.SET_COOKIE, refreshTokenCookie(result))
.body(ApiResponse.success(CommonSuccessCode.OK, toEmailAuthResponse(result)));
}

@Operation(
summary = "비밀번호 재설정",
description = """
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@
public record EmailSignupRequest(

@NotBlank(message = "이름은 필수입니다.")
@Size(min = 1, max = 20, message = "이름은 1자 이상 20자 이하로 입력해야 합니다.")
@Pattern(
regexp = "^(?=.*\\S)[가-힣a-zA-Z0-9 ]{2,20}$",
message = "이름은 특수문자 없이 2자 이상 20자 이하로 입력해야 합니다."
)
String name,

@NotBlank(message = "이메일은 필수입니다.")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.slatto.domain.auth.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;

public record PasswordChangeRequest(

@NotBlank(message = "현재 비밀번호는 필수입니다.")
String currentPassword,

@NotBlank(message = "새 비밀번호는 필수입니다.")
@Pattern(
regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[^A-Za-z0-9]).{8,64}$",
message = "비밀번호는 영문·숫자·특수문자를 포함해 8자 이상 64자 이하로 입력해야 합니다."
)
String newPassword
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,12 @@ public enum AuthErrorCode implements BaseCode {
SIGNUP_SOCIAL_ACCOUNT_EXISTS(HttpStatus.CONFLICT, "AUTH_SIGNUP_SOCIAL409", "구글 계정으로 가입된 이메일입니다. 구글 로그인을 이용해 주세요."),

// 이메일 미존재·비밀번호 불일치·소셜 전용 계정을 모두 같은 응답으로 처리한다. 이메일 열거 방지다.
LOGIN_FAILED(HttpStatus.UNAUTHORIZED, "AUTH_LOGIN401", "이메일 또는 비밀번호가 올바르지 않습니다.");
LOGIN_FAILED(HttpStatus.UNAUTHORIZED, "AUTH_LOGIN401", "이메일 또는 비밀번호가 올바르지 않습니다."),

// 로그인한 본인이 호출하는 경로라 사유를 구분해도 새어 나갈 정보가 없다.
CURRENT_PASSWORD_MISMATCH(HttpStatus.UNAUTHORIZED, "AUTH_PASSWORD401", "현재 비밀번호가 올바르지 않습니다."),
PASSWORD_NOT_SET(HttpStatus.BAD_REQUEST, "AUTH_PASSWORD_NOT_SET400", "비밀번호가 설정되지 않은 계정입니다. 비밀번호 찾기를 이용해 주세요."),
PASSWORD_UNCHANGED(HttpStatus.BAD_REQUEST, "AUTH_PASSWORD_UNCHANGED400", "새 비밀번호가 기존 비밀번호와 같습니다.");

private final HttpStatus httpStatus;
private final String code;
Expand Down
26 changes: 26 additions & 0 deletions src/main/java/com/slatto/domain/auth/service/AuthService.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import com.slatto.domain.user.repository.UserRepository;
import com.slatto.global.config.properties.FrontendProperties;
import com.slatto.global.exception.BaseException;
import com.slatto.global.response.code.CommonErrorCode;
import com.slatto.global.security.JwtTokenProvider;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
Expand Down Expand Up @@ -134,6 +135,31 @@ public EmailAuthResult login(String email, String rawPassword) {
return toEmailAuthResult(user);
}

// 재설정과 달리 리프레시 토큰을 새로 발급한다. 본인이 로그인한 상태에서 바꾸는 것이라
// 세션을 끊을 이유가 없다. 유출을 가정하는 resetPassword 와 여기가 갈리는 지점이다.
@Transactional
public EmailAuthResult changePassword(Long userId, String currentPassword, String newRawPassword) {
Users user = userRepository.findByIdAndDeletedAtIsNull(userId)
.orElseThrow(() -> new BaseException(CommonErrorCode.NOT_FOUND));

if (!user.hasPassword()) {
throw new BaseException(AuthErrorCode.PASSWORD_NOT_SET);
}

// 세션이 탈취된 상황에서 비밀번호까지 바꿀 수 있으면 계정을 통째로 빼앗긴다.
if (!passwordEncoder.matches(currentPassword, user.getPassword())) {
throw new BaseException(AuthErrorCode.CURRENT_PASSWORD_MISMATCH);
}

if (passwordEncoder.matches(newRawPassword, user.getPassword())) {
throw new BaseException(AuthErrorCode.PASSWORD_UNCHANGED);
}

user.changePassword(passwordEncoder.encode(newRawPassword));

return toEmailAuthResult(user);
}

@Transactional
public void resetPassword(String email, String newRawPassword) {
emailVerificationService.consumeVerified(email, VerificationPurpose.PASSWORD_RESET);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,15 @@ public class RecruitmentController {

private final RecruitmentService recruitmentService;

@Operation(summary = "구인구직 공고 작성")
@Operation(
summary = "구인구직 공고 작성",
description = """
필수는 `title`, `recruitPart`, `description`, `contact` 네 개다.
`category`, `lengthType`, `location`, `shootingPeriod`, `pay`, `deadline` 은 비워도 등록된다.

`title` 은 5~50자다. `deadline` 을 비우면 수동으로 마감할 때까지 모집이 유지된다.
"""
)
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public ApiResponse<RecruitmentDetailResponse> createRecruitment(
Expand Down Expand Up @@ -113,7 +121,19 @@ public ApiResponse<RecruitmentDetailResponse> getRecruitment(
return ApiResponse.success(CommonSuccessCode.OK, response);
}

@Operation(summary = "구인구직 공고 수정")
@Operation(
summary = "구인구직 공고 수정",
description = """
전달한 항목만 부분 수정된다.

**마감된 공고는 내용을 수정할 수 없다.** 마감일이 지났거나 수동 마감한 공고에
내용 수정을 요청하면 `RECRUITMENT_CLOSED_EDIT400` 이 반환된다.

다만 `status` 를 `RECRUITING` 으로 보내는 요청은 통과한다. 상태 변경이 같은 API 라
전면 차단하면 마감을 되돌릴 방법이 없어지기 때문이다. 마감일이 지나 자동 마감된
공고를 되살리려면 `deadline` 도 함께 보내야 한다.
"""
)
@PatchMapping("/{recruitmentId}")
public ApiResponse<RecruitmentDetailResponse> updateRecruitment(
@AuthenticationPrincipal Long currentUserId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import jakarta.validation.constraints.FutureOrPresent;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.AccessLevel;
import lombok.Getter;
Expand All @@ -19,29 +20,27 @@
public class RecruitmentCreateRequest {

@NotBlank(message = "공고 제목은 필수입니다.")
@Size(max = 100, message = "공고 제목은 최대 100자까지 입력할 수 있습니다.")
@Size(min = 5, max = 50, message = "공고 제목은 5자 이상 50자 이하로 입력해야 합니다.")
private String title;

@NotNull(message = "카테고리는 필수입니다.")
private CategoryName category;

@NotNull(message = "영상 길이 유형은 필수입니다.")
private LengthType lengthType;

@NotNull(message = "모집 파트는 필수입니다.")
private RoleName recruitPart;

@NotNull(message = "지역은 필수입니다.")
private RegionName location;

@NotBlank(message = "촬영 기간은 필수입니다.")
@Pattern(regexp = "(?s).*\\S.*", message = "촬영 기간은 공백일 수 없습니다.")
@Size(max = 50, message = "촬영 기간은 최대 50자까지 입력할 수 있습니다.")
private String shootingPeriod;

@NotBlank(message = "급여는 필수입니다.")
@Pattern(regexp = "(?s).*\\S.*", message = "급여는 공백일 수 없습니다.")
@Size(max = 50, message = "급여는 최대 50자까지 입력할 수 있습니다.")
private String pay;

// 지원자가 연락할 방법이 없으면 공고가 성립하지 않아 선택으로 열지 않는다.
@NotBlank(message = "연락처는 필수입니다.")
@Size(max = 100, message = "연락처는 최대 100자까지 입력할 수 있습니다.")
private String contact;
Expand All @@ -50,7 +49,7 @@ public class RecruitmentCreateRequest {
@Size(max = 2000, message = "상세 내용은 최대 2000자까지 입력할 수 있습니다.")
private String description;

@NotNull(message = "마감일은 필수입니다.")
// 마감일을 비우면 수동으로 마감할 때까지 모집이 유지된다.
@FutureOrPresent(message = "마감일은 오늘 또는 그 이후여야 합니다.")
private LocalDate deadline;
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
public class RecruitmentUpdateRequest {

@Pattern(regexp = "(?s).*\\S.*", message = "공고 제목은 공백일 수 없습니다.")
@Size(max = 100, message = "공고 제목은 100자 이하여야 합니다.")
@Size(min = 5, max = 50, message = "공고 제목은 5자 이상 50자 이하여야 합니다.")
private String title;

private CategoryName category;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ public enum RecruitmentErrorCode implements BaseCode {
RECRUITMENT_CLOSED(HttpStatus.BAD_REQUEST, "RECRUITMENT_CLOSED400", "마감된 공고에는 지원할 수 없습니다."),
RECRUITMENT_SELF_APPLICATION(HttpStatus.BAD_REQUEST, "RECRUITMENT_SELF400", "본인이 작성한 공고에는 지원할 수 없습니다."),
APPLICATION_ALREADY_APPLIED(HttpStatus.CONFLICT, "APPLICATION409", "이미 지원한 공고입니다."),
APPLICATION_ALREADY_HANDLED(HttpStatus.BAD_REQUEST, "APPLICATION_ALREADY_HANDLED400", "이미 수락 또는 거절 처리된 지원입니다.");
APPLICATION_ALREADY_HANDLED(HttpStatus.BAD_REQUEST, "APPLICATION_ALREADY_HANDLED400", "이미 수락 또는 거절 처리된 지원입니다."),
RECRUITMENT_CLOSED_NOT_EDITABLE(HttpStatus.BAD_REQUEST, "RECRUITMENT_CLOSED_EDIT400", "마감된 공고는 수정할 수 없습니다.");

private final HttpStatus httpStatus;
private final String code;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,24 @@
import org.springframework.data.repository.query.Param;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;

public interface RecruitmentRepository extends JpaRepository<Recruitment, Long> {

// 작성자가 탈퇴하면 공고도 함께 내린다. 연락받을 사람이 없는 공고가 목록에 남으면
// 지원자가 응답 없는 공고에 지원하게 된다.
// 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);
Comment on lines +21 to +31

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.


Optional<Recruitment> findByIdAndDeletedAtIsNull(Long id);

boolean existsByIdAndDeletedAtIsNull(Long id);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import com.slatto.domain.recruitment.enums.RecruitmentApplicationStatus;
import com.slatto.domain.recruitment.enums.RecruitmentSortType;
import com.slatto.domain.recruitment.enums.RecruitmentStatus;
import com.slatto.domain.recruitment.exception.RecruitmentErrorCode;
import com.slatto.domain.recruitment.repository.RecruitmentApplicationRepository;
import com.slatto.domain.recruitment.repository.RecruitmentBookmarkRepository;
import com.slatto.domain.recruitment.repository.RecruitmentRepository;
Expand Down Expand Up @@ -107,6 +108,12 @@ public RecruitmentDetailResponse updateRecruitment(

validateWriter(recruitment, currentUserId);

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

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.


recruitment.update(
request.getTitle(),
request.getCategory(),
Expand Down Expand Up @@ -399,6 +406,29 @@ private RoleName getPrimaryRole(Long userId) {
return roles.isEmpty() ? null : roles.get(0).getRoleName();
}

// status 만 RECRUITING 으로 바꿔도 마감일이 과거면 여전히 마감이다. 그 상태로 통과시키면
// 같은 요청에 실린 내용 변경까지 반영돼 마감 공고 수정 금지가 우회된다.
// 적용 후 실제로 모집중이 되는 요청만 재개로 인정한다.
private boolean isReopening(Recruitment recruitment, RecruitmentUpdateRequest request) {
if (request.getStatus() != RecruitmentStatus.RECRUITING) {
return false;
}

LocalDate appliedDeadline = request.getDeadline() != null
? request.getDeadline()
: recruitment.getDeadline();

return appliedDeadline == null || !appliedDeadline.isBefore(recruitmentConverter.currentDate());
}

private boolean isClosed(Recruitment recruitment) {
return recruitmentConverter.resolveStatus(
recruitment.getClosedManually(),
recruitment.getDeadline(),
recruitmentConverter.currentDate()
) == RecruitmentStatus.CLOSED;
}

private List<RegionName> getUserRegions(Long userId) {
return locationRepository.findAllByUserIdAndRecruitmentIsNullOrderByIdAsc(userId)
.stream()
Expand Down
Loading
Loading