Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -99,19 +99,19 @@ public ApiResponse<ProjectDetailResponse> getProject(

`status` 를 `COMPLETED` 로 바꾸면 참여 중인 멤버 전원의 포트폴리오에 이 프로젝트가 생성된다.
프로젝트명·유형·개인외주 구분·설명·기간이 그대로 옮겨가고, 각자 맡은 역할이 함께 채워진다.
프로젝트에 개인외주 구분이 없으면 포트폴리오에도 비어 있는 채로 만들어진다.
생성된 뒤에는 본인이 프로필에서 수정·삭제할 수 있다.

나간 멤버와 탈퇴한 유저는 대상에서 빠진다.

`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<ProjectResponse> updateProject(
@AuthenticationPrincipal Long currentUserId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,15 @@ public ApiResponse<PortfolioDetailResponse> getPortfolio(
return ApiResponse.success(CommonSuccessCode.OK, response);
}

@Operation(summary = "포트폴리오 생성", description = "프로젝트 이력을 새로 등록한다. 영상 링크에서 썸네일을 자동 추출해 저장한다.")
@Operation(
summary = "포트폴리오 생성",
description = """
프로젝트 이력을 새로 등록한다. 영상 링크에서 썸네일을 자동 추출해 저장한다.

`kind`(개인/외주 구분)는 선택 입력이라 고르지 않아도 등록된다.
`PERSONAL` 을 고르면 의뢰자를 입력해도 저장하지 않는다.
"""
)
@PostMapping("/me/portfolios")
@ResponseStatus(HttpStatus.CREATED)
public ApiResponse<PortfolioCreateResponse> createPortfolio(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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자 이하로 입력해야 합니다.")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
20 changes: 20 additions & 0 deletions src/main/resources/db/migration/V022__portfolio_kind_nullable.sql
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
Expand Up @@ -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,
"종류 없는 프로젝트",
Expand All @@ -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);
}

Expand Down Expand Up @@ -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)));
}
Expand Down
Loading