diff --git a/src/main/java/com/slatto/domain/project/controller/ProjectController.java b/src/main/java/com/slatto/domain/project/controller/ProjectController.java index 2ed82dd..e08b641 100644 --- a/src/main/java/com/slatto/domain/project/controller/ProjectController.java +++ b/src/main/java/com/slatto/domain/project/controller/ProjectController.java @@ -99,6 +99,7 @@ public ApiResponse getProject( `status` 를 `COMPLETED` 로 바꾸면 참여 중인 멤버 전원의 포트폴리오에 이 프로젝트가 생성된다. 프로젝트명·유형·개인외주 구분·설명·기간이 그대로 옮겨가고, 각자 맡은 역할이 함께 채워진다. + 프로젝트에 개인외주 구분이 없으면 포트폴리오에도 비어 있는 채로 만들어진다. 생성된 뒤에는 본인이 프로필에서 수정·삭제할 수 있다. 나간 멤버와 탈퇴한 유저는 대상에서 빠진다. @@ -106,12 +107,11 @@ public ApiResponse getProject( `COMPLETED` 는 최종 상태다. 완료한 뒤에는 다른 단계로 되돌릴 수 없고 시도하면 `PROJECT_COMPLETED409` 가 나간다. 이력이 두 번 생기는 것을 막기 위해서다. - `title` 또는 `kind` 가 비어 있으면 포트폴리오를 만들 수 없어 완료로 바꿀 수 없다. - 이때는 `PROJECT_COMPLETION400` 이 나간다. - `title` 은 생성·수정 요청 모두 필수라 실제로는 `kind` 만 이 조건에 걸린다. + 제목이 비어 있으면 포트폴리오를 만들 수 없어 `PROJECT_TITLE400` 이 나간다. + 제목은 생성·수정 요청 모두 필수라 이 API 만 쓰면 발생하지 않는다. """ ) - @ApiErrorCodes({"PROJECT_COMPLETION400", "PROJECT403", "PROJECT_ADMIN403", "PROJECT404", "PROJECT_COMPLETED409"}) + @ApiErrorCodes({"PROJECT_TITLE400", "PROJECT403", "PROJECT_ADMIN403", "PROJECT404", "PROJECT_COMPLETED409"}) @PatchMapping("/{projectId}") public ApiResponse updateProject( @AuthenticationPrincipal Long currentUserId, diff --git a/src/main/java/com/slatto/domain/project/exception/ProjectErrorCode.java b/src/main/java/com/slatto/domain/project/exception/ProjectErrorCode.java index 88aa1fe..06a718e 100644 --- a/src/main/java/com/slatto/domain/project/exception/ProjectErrorCode.java +++ b/src/main/java/com/slatto/domain/project/exception/ProjectErrorCode.java @@ -25,7 +25,7 @@ public enum ProjectErrorCode implements BaseCode { PROJECT_MEMBER_ALREADY_EXISTS(HttpStatus.CONFLICT, "PROJECT_MEMBER409", "이미 프로젝트에 참여 중인 멤버입니다."), PROJECT_LIMIT_EXCEEDED(HttpStatus.CONFLICT, "PROJECT409", "무료 계정은 최대 5개의 프로젝트를 생성할 수 있습니다."), PROJECT_ALREADY_COMPLETED(HttpStatus.CONFLICT, "PROJECT_COMPLETED409", "완료된 프로젝트는 진행 단계를 변경할 수 없습니다."), - PROJECT_COMPLETION_INFO_REQUIRED(HttpStatus.BAD_REQUEST, "PROJECT_COMPLETION400", "프로젝트 제목과 개인/외주 구분을 입력해야 완료할 수 있습니다."); + PROJECT_TITLE_REQUIRED(HttpStatus.BAD_REQUEST, "PROJECT_TITLE400", "프로젝트 제목을 입력해야 완료할 수 있습니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/com/slatto/domain/project/service/ProjectService.java b/src/main/java/com/slatto/domain/project/service/ProjectService.java index e657014..b2b0a3c 100644 --- a/src/main/java/com/slatto/domain/project/service/ProjectService.java +++ b/src/main/java/com/slatto/domain/project/service/ProjectService.java @@ -197,7 +197,7 @@ public ProjectResponse updateProject( request.getKind() ); - // 같은 요청에서 바뀐 제목·종류로 검증해야 하므로 updateInfo 다음에 처리한다. + // 같은 요청에서 바뀐 값이 포트폴리오로 옮겨가야 하므로 updateInfo 다음에 처리한다. if (request.getStatus() == ProjectStatus.COMPLETED && previousStatus != ProjectStatus.COMPLETED) { completeProject(project); } else if (request.getStatus() != null) { @@ -221,8 +221,12 @@ public ProjectResponse updateProject( // 완료 전환과 포트폴리오 생성을 한 트랜잭션에서 처리한다. // 포트폴리오 생성이 실패하면 완료 전환도 함께 롤백되어야 한다. private void completeProject(Project project) { - if (!StringUtils.hasText(project.getTitle()) || project.getKind() == null) { - throw new BaseException(ProjectErrorCode.PROJECT_COMPLETION_INFO_REQUIRED); + // 포트폴리오의 title 은 NOT NULL 이라 제목이 비면 저장이 DB 제약으로 끊긴다. + // 생성·수정 요청 모두 @NotBlank 라 API 로는 비어질 수 없지만, 그 밖의 경로로 들어온 + // 값까지 500 으로 나가지 않도록 여기서 막는다. + // kind 는 선택 입력이므로 비어 있어도 완료할 수 있다. + if (!StringUtils.hasText(project.getTitle())) { + throw new BaseException(ProjectErrorCode.PROJECT_TITLE_REQUIRED); } if (projectRepository.markCompleted(project.getId()) == 0) { diff --git a/src/main/java/com/slatto/domain/user/controller/PortfolioController.java b/src/main/java/com/slatto/domain/user/controller/PortfolioController.java index 87c9513..edb0740 100644 --- a/src/main/java/com/slatto/domain/user/controller/PortfolioController.java +++ b/src/main/java/com/slatto/domain/user/controller/PortfolioController.java @@ -60,7 +60,15 @@ public ApiResponse getPortfolio( return ApiResponse.success(CommonSuccessCode.OK, response); } - @Operation(summary = "포트폴리오 생성", description = "프로젝트 이력을 새로 등록한다. 영상 링크에서 썸네일을 자동 추출해 저장한다.") + @Operation( + summary = "포트폴리오 생성", + description = """ + 프로젝트 이력을 새로 등록한다. 영상 링크에서 썸네일을 자동 추출해 저장한다. + + `kind`(개인/외주 구분)는 선택 입력이라 고르지 않아도 등록된다. + `PERSONAL` 을 고르면 의뢰자를 입력해도 저장하지 않는다. + """ + ) @PostMapping("/me/portfolios") @ResponseStatus(HttpStatus.CREATED) public ApiResponse createPortfolio( diff --git a/src/main/java/com/slatto/domain/user/dto/PortfolioCreateRequest.java b/src/main/java/com/slatto/domain/user/dto/PortfolioCreateRequest.java index 3bceaa7..574e5ad 100644 --- a/src/main/java/com/slatto/domain/user/dto/PortfolioCreateRequest.java +++ b/src/main/java/com/slatto/domain/user/dto/PortfolioCreateRequest.java @@ -28,7 +28,7 @@ public class PortfolioCreateRequest { @Size(max = 100, message = "기타 유형명은 100자 이하로 입력해야 합니다.") private String customTypeName; - @NotNull(message = "개인/외주 구분은 필수입니다.") + // 선택 입력이다. 고르지 않아도 이력을 남길 수 있어야 한다. private Kind kind; @Size(max = 255, message = "클라이언트명은 255자 이하로 입력해야 합니다.") diff --git a/src/main/java/com/slatto/domain/user/entity/UserPortfolio.java b/src/main/java/com/slatto/domain/user/entity/UserPortfolio.java index fb3fbf0..be68e82 100644 --- a/src/main/java/com/slatto/domain/user/entity/UserPortfolio.java +++ b/src/main/java/com/slatto/domain/user/entity/UserPortfolio.java @@ -39,7 +39,7 @@ public class UserPortfolio extends BaseEntity { private String customTypeName; @Enumerated(EnumType.STRING) - @Column(name = "kind", nullable = false) + @Column(name = "kind", nullable = true) private Kind kind; @Column(name = "client_name", nullable = true, length = 255) diff --git a/src/main/java/com/slatto/domain/user/service/PortfolioService.java b/src/main/java/com/slatto/domain/user/service/PortfolioService.java index 1e6726e..115f845 100644 --- a/src/main/java/com/slatto/domain/user/service/PortfolioService.java +++ b/src/main/java/com/slatto/domain/user/service/PortfolioService.java @@ -281,8 +281,10 @@ private String resolveCustomTypeName(CategoryName type, String customTypeName) { return type == CategoryName.ETC ? customTypeName : null; } + // 개인 작업으로 명시한 경우에만 의뢰자를 비운다. + // kind 가 선택 입력이라, 구분을 고르지 않고 의뢰자만 입력하는 것도 허용한다. private String resolveClientName(Kind kind, String clientName) { - return kind == Kind.EXTERNAL ? clientName : null; + return kind == Kind.PERSONAL ? null : clientName; } private UserPortfolio getOwnedPortfolioOrThrow(Long userId, Long portfolioId) { diff --git a/src/main/resources/db/migration/V022__portfolio_kind_nullable.sql b/src/main/resources/db/migration/V022__portfolio_kind_nullable.sql new file mode 100644 index 0000000..980d844 --- /dev/null +++ b/src/main/resources/db/migration/V022__portfolio_kind_nullable.sql @@ -0,0 +1,20 @@ +-- 포트폴리오의 개인/외주 구분을 선택 입력으로 완화 +-- +-- 배경: +-- 이력 등록 시 개인 작업인지 외주 작업인지 반드시 고르게 되어 있었으나, +-- 목록 조회나 필터에 쓰이지 않아 등록 단계에서 불필요한 선택을 강요하는 상태였다. +-- 엔티티에서 nullable 로 바꾸면서 컬럼 제약도 함께 푼다. +-- +-- ENUM -> VARCHAR: +-- 운영 DB 는 ddl-auto=update 로 생성된 구간이 있어 이 컬럼이 MySQL ENUM 일 수 있다. +-- ENUM 은 값을 추가할 때마다 ALTER TABLE 이 필요하고, Java enum 과 어긋나면 +-- strict 모드에서 Data truncated 로 끊긴다. V009(activity_log.type), +-- V020(notification.type) 과 같은 처리로 VARCHAR 로 맞춘다. +-- PERSONAL / EXTERNAL 두 값 모두 문자열로 그대로 보존된다. +-- +-- 안전성: +-- 제약 완화라 기존 코드가 동작 중인 DB 에 먼저 적용해도 된다. +-- 아직 이 코드를 받지 않은 애플리케이션은 항상 값을 채워 INSERT 하므로 영향이 없다. + +ALTER TABLE user_portfolio + MODIFY COLUMN kind VARCHAR(255) NULL; diff --git a/src/test/java/com/slatto/domain/project/service/ProjectCompletionPortfolioTest.java b/src/test/java/com/slatto/domain/project/service/ProjectCompletionPortfolioTest.java index 1624ff7..ea362b7 100644 --- a/src/test/java/com/slatto/domain/project/service/ProjectCompletionPortfolioTest.java +++ b/src/test/java/com/slatto/domain/project/service/ProjectCompletionPortfolioTest.java @@ -183,8 +183,8 @@ void markCompleted_succeedsOnlyOnce() { } @Test - @DisplayName("개인/외주 구분이 없으면 완료로 바꿀 수 없다") - void completeProject_withoutKind_throws() { + @DisplayName("개인/외주 구분이 없는 프로젝트도 완료되고 포트폴리오는 구분 없이 생성된다") + void completeProject_withoutKind_createsPortfolioWithNullKind() { Project noKindProject = projectRepository.save(Project.create( owner, "종류 없는 프로젝트", @@ -198,12 +198,42 @@ void completeProject_withoutKind_throws() { projectMemberRepository.save(ProjectMember.createAdmin(noKindProject, owner)); entityManager.flush(); + projectService.updateProject(noKindProject.getId(), owner.getId(), completeRequestWithoutKind()); + + assertThat(projectRepository.findById(noKindProject.getId()).orElseThrow().getStatus()) + .isEqualTo(ProjectStatus.COMPLETED); + assertThat(userPortfolioRepository.findAll()) + .singleElement() + .satisfies(portfolio -> { + assertThat(portfolio.getTitle()).isEqualTo("종류 없는 프로젝트"); + assertThat(portfolio.getKind()).isNull(); + // 구분을 고르지 않았을 때 의뢰자가 함께 버려지지 않아야 한다. + assertThat(portfolio.getClientName()).isEqualTo("스튜디오 Y"); + }); + } + + @Test + @DisplayName("제목이 없으면 완료로 바꿀 수 없다") + void completeProject_withoutTitle_throws() { + Project noTitleProject = projectRepository.save(Project.create( + owner, + null, + CategoryName.FILM_DRAMA, + LengthType.SHORT_FORM, + "설명", + LocalDate.now().plusDays(10), + null, + null + )); + projectMemberRepository.save(ProjectMember.createAdmin(noTitleProject, owner)); + entityManager.flush(); + assertThatThrownBy(() -> - projectService.updateProject(noKindProject.getId(), owner.getId(), completeRequestWithoutKind()) + projectService.updateProject(noTitleProject.getId(), owner.getId(), completeRequestWithoutTitle()) ).isInstanceOf(BaseException.class); assertThat(userPortfolioRepository.findAll()).isEmpty(); - assertThat(projectRepository.findById(noKindProject.getId()).orElseThrow().getStatus()) + assertThat(projectRepository.findById(noTitleProject.getId()).orElseThrow().getStatus()) .isNotEqualTo(ProjectStatus.COMPLETED); } @@ -231,6 +261,13 @@ private ProjectUpdateRequest completeRequest() { private ProjectUpdateRequest completeRequestWithoutKind() { return request(""" {"title":"종류 없는 프로젝트","type":"FILM_DRAMA","lengthType":"SHORT_FORM","description":"설명", + "endDate":"%s","clientName":"스튜디오 Y","status":"COMPLETED"} + """.formatted(LocalDate.now().plusDays(10))); + } + + private ProjectUpdateRequest completeRequestWithoutTitle() { + return request(""" + {"type":"FILM_DRAMA","lengthType":"SHORT_FORM","description":"설명", "endDate":"%s","status":"COMPLETED"} """.formatted(LocalDate.now().plusDays(10))); }