From 1e4586ab8fbc9a7a1423237a7f088cb6fea7fdbc Mon Sep 17 00:00:00 2001 From: Seungwan Yoo Date: Wed, 8 Jul 2026 00:36:02 +0900 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20[alt-267]=20=EC=97=85=EC=A2=85=20?= =?UTF-8?q?=EB=A7=88=EC=8A=A4=ED=84=B0=20=EA=B4=80=EB=A6=AC=EC=9E=90=20CRU?= =?UTF-8?q?D=20=EB=B0=8F=20=EC=B4=88=EA=B8=B0=20=EC=8B=9C=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /admin/posting-keywords 목록·생성·수정·삭제 API (약관 관리 패턴 재사용) - 쓰기 포트 PostingKeywordRepository 신설, 이름 중복·사용중 삭제 가드 - 초기 업종 Flyway 시드(V5, ON CONFLICT DO NOTHING 멱등) --- .../AdminPostingKeywordController.java | 85 +++++++++++++++++++ .../AdminPostingKeywordControllerSpec.java | 53 ++++++++++++ .../dto/AdminPostingKeywordRequestDto.java | 24 ++++++ .../dto/AdminPostingKeywordResponseDto.java | 42 +++++++++ .../PostingKeywordQueryRepositoryImpl.java | 14 +++ .../usecase/AdminCreatePostingKeyword.java | 29 +++++++ .../usecase/AdminDeletePostingKeyword.java | 32 +++++++ .../usecase/AdminGetPostingKeywordList.java | 23 +++++ .../usecase/AdminUpdatePostingKeyword.java | 31 +++++++ .../AdminCreatePostingKeywordCommand.java | 6 ++ .../AdminUpdatePostingKeywordCommand.java | 6 ++ .../domain/posting/entity/PostingKeyword.java | 5 ++ .../AdminCreatePostingKeywordUseCase.java | 8 ++ .../AdminDeletePostingKeywordUseCase.java | 6 ++ .../AdminGetPostingKeywordListUseCase.java | 10 +++ .../AdminUpdatePostingKeywordUseCase.java | 8 ++ .../PostingKeywordQueryRepository.java | 1 + .../outbound/PostingKeywordRepository.java | 11 +++ .../migration/V5__seed_posting_keywords.sql | 17 ++++ 19 files changed, 411 insertions(+) create mode 100644 src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/controller/AdminPostingKeywordController.java create mode 100644 src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/controller/AdminPostingKeywordControllerSpec.java create mode 100644 src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/dto/AdminPostingKeywordRequestDto.java create mode 100644 src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/dto/AdminPostingKeywordResponseDto.java create mode 100644 src/main/java/com/dreamteam/alter/application/posting/usecase/AdminCreatePostingKeyword.java create mode 100644 src/main/java/com/dreamteam/alter/application/posting/usecase/AdminDeletePostingKeyword.java create mode 100644 src/main/java/com/dreamteam/alter/application/posting/usecase/AdminGetPostingKeywordList.java create mode 100644 src/main/java/com/dreamteam/alter/application/posting/usecase/AdminUpdatePostingKeyword.java create mode 100644 src/main/java/com/dreamteam/alter/domain/posting/command/AdminCreatePostingKeywordCommand.java create mode 100644 src/main/java/com/dreamteam/alter/domain/posting/command/AdminUpdatePostingKeywordCommand.java create mode 100644 src/main/java/com/dreamteam/alter/domain/posting/port/inbound/AdminCreatePostingKeywordUseCase.java create mode 100644 src/main/java/com/dreamteam/alter/domain/posting/port/inbound/AdminDeletePostingKeywordUseCase.java create mode 100644 src/main/java/com/dreamteam/alter/domain/posting/port/inbound/AdminGetPostingKeywordListUseCase.java create mode 100644 src/main/java/com/dreamteam/alter/domain/posting/port/inbound/AdminUpdatePostingKeywordUseCase.java create mode 100644 src/main/java/com/dreamteam/alter/domain/posting/port/outbound/PostingKeywordRepository.java create mode 100644 src/main/resources/db/migration/V5__seed_posting_keywords.sql diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/controller/AdminPostingKeywordController.java b/src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/controller/AdminPostingKeywordController.java new file mode 100644 index 00000000..688c9df8 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/controller/AdminPostingKeywordController.java @@ -0,0 +1,85 @@ +package com.dreamteam.alter.adapter.inbound.admin.posting.controller; + +import com.dreamteam.alter.adapter.inbound.admin.posting.dto.AdminPostingKeywordRequestDto; +import com.dreamteam.alter.adapter.inbound.admin.posting.dto.AdminPostingKeywordResponseDto; +import com.dreamteam.alter.adapter.inbound.common.dto.CommonApiResponse; +import com.dreamteam.alter.domain.posting.command.AdminCreatePostingKeywordCommand; +import com.dreamteam.alter.domain.posting.command.AdminUpdatePostingKeywordCommand; +import com.dreamteam.alter.domain.posting.port.inbound.AdminCreatePostingKeywordUseCase; +import com.dreamteam.alter.domain.posting.port.inbound.AdminDeletePostingKeywordUseCase; +import com.dreamteam.alter.domain.posting.port.inbound.AdminGetPostingKeywordListUseCase; +import com.dreamteam.alter.domain.posting.port.inbound.AdminUpdatePostingKeywordUseCase; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/admin/posting-keywords") +@PreAuthorize("hasAnyRole('ADMIN')") +@RequiredArgsConstructor +@Validated +public class AdminPostingKeywordController implements AdminPostingKeywordControllerSpec { + + @Resource(name = "adminGetPostingKeywordList") + private final AdminGetPostingKeywordListUseCase adminGetPostingKeywordList; + + @Resource(name = "adminCreatePostingKeyword") + private final AdminCreatePostingKeywordUseCase adminCreatePostingKeyword; + + @Resource(name = "adminUpdatePostingKeyword") + private final AdminUpdatePostingKeywordUseCase adminUpdatePostingKeyword; + + @Resource(name = "adminDeletePostingKeyword") + private final AdminDeletePostingKeywordUseCase adminDeletePostingKeyword; + + @Override + @GetMapping + public ResponseEntity>> getPostingKeywordList() { + List result = adminGetPostingKeywordList.execute().stream() + .map(AdminPostingKeywordResponseDto::from) + .toList(); + return ResponseEntity.ok(CommonApiResponse.of(result)); + } + + @Override + @PostMapping + public ResponseEntity> createPostingKeyword( + @Valid @RequestBody AdminPostingKeywordRequestDto request + ) { + Long id = adminCreatePostingKeyword.execute( + new AdminCreatePostingKeywordCommand(request.getName(), request.getDescription()) + ); + return ResponseEntity.status(HttpStatus.CREATED) + .body(CommonApiResponse.of( + AdminPostingKeywordResponseDto.of(id, request.getName(), request.getDescription()) + )); + } + + @Override + @PutMapping("/{id}") + public ResponseEntity> updatePostingKeyword( + @PathVariable Long id, + @Valid @RequestBody AdminPostingKeywordRequestDto request + ) { + adminUpdatePostingKeyword.execute( + id, new AdminUpdatePostingKeywordCommand(request.getName(), request.getDescription()) + ); + return ResponseEntity.ok(CommonApiResponse.empty()); + } + + @Override + @DeleteMapping("/{id}") + public ResponseEntity> deletePostingKeyword( + @PathVariable Long id + ) { + adminDeletePostingKeyword.execute(id); + return ResponseEntity.ok(CommonApiResponse.empty()); + } +} diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/controller/AdminPostingKeywordControllerSpec.java b/src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/controller/AdminPostingKeywordControllerSpec.java new file mode 100644 index 00000000..2c3eecaa --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/controller/AdminPostingKeywordControllerSpec.java @@ -0,0 +1,53 @@ +package com.dreamteam.alter.adapter.inbound.admin.posting.controller; + +import com.dreamteam.alter.adapter.inbound.admin.posting.dto.AdminPostingKeywordRequestDto; +import com.dreamteam.alter.adapter.inbound.admin.posting.dto.AdminPostingKeywordResponseDto; +import com.dreamteam.alter.adapter.inbound.common.dto.CommonApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; + +import java.util.List; + +@Tag(name = "ADMIN - 업종(키워드) 관리 API") +public interface AdminPostingKeywordControllerSpec { + + @Operation(summary = "업종 목록 조회", description = "관리자가 등록된 업종(키워드) 전체 목록을 조회합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "업종 목록 조회 성공") + }) + ResponseEntity>> getPostingKeywordList(); + + @Operation(summary = "업종 생성", description = "관리자가 새 업종(키워드)을 등록합니다. 이름은 중복될 수 없습니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "201", description = "업종 생성 성공"), + @ApiResponse(responseCode = "400", description = "이름 중복 또는 유효성 검증 실패") + }) + ResponseEntity> createPostingKeyword( + @Valid @RequestBody AdminPostingKeywordRequestDto request + ); + + @Operation(summary = "업종 수정", description = "관리자가 업종(키워드)의 이름/설명을 수정합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "업종 수정 성공"), + @ApiResponse(responseCode = "400", description = "이름 중복 또는 존재하지 않는 업종") + }) + ResponseEntity> updatePostingKeyword( + @PathVariable Long id, + @Valid @RequestBody AdminPostingKeywordRequestDto request + ); + + @Operation(summary = "업종 삭제", description = "관리자가 업종(키워드)을 삭제합니다. 공고에서 사용 중인 업종은 삭제할 수 없습니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "업종 삭제 성공"), + @ApiResponse(responseCode = "409", description = "공고에서 사용 중인 업종") + }) + ResponseEntity> deletePostingKeyword( + @PathVariable Long id + ); +} diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/dto/AdminPostingKeywordRequestDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/dto/AdminPostingKeywordRequestDto.java new file mode 100644 index 00000000..747c9c02 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/dto/AdminPostingKeywordRequestDto.java @@ -0,0 +1,24 @@ +package com.dreamteam.alter.adapter.inbound.admin.posting.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "업종(키워드) 생성/수정 요청 DTO") +public class AdminPostingKeywordRequestDto { + + @NotBlank + @Size(max = 128) + @Schema(description = "업종명", example = "카페") + private String name; + + @Size(max = 255) + @Schema(description = "업종 설명", example = "카페/디저트 매장") + private String description; +} diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/dto/AdminPostingKeywordResponseDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/dto/AdminPostingKeywordResponseDto.java new file mode 100644 index 00000000..b83fedc1 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/dto/AdminPostingKeywordResponseDto.java @@ -0,0 +1,42 @@ +package com.dreamteam.alter.adapter.inbound.admin.posting.dto; + +import com.dreamteam.alter.domain.posting.entity.PostingKeyword; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder(access = AccessLevel.PRIVATE) +@Schema(description = "업종(키워드) 응답 DTO") +public class AdminPostingKeywordResponseDto { + + @Schema(description = "업종 ID", example = "1") + private Long id; + + @Schema(description = "업종명", example = "카페") + private String name; + + @Schema(description = "업종 설명", example = "카페/디저트 매장") + private String description; + + public static AdminPostingKeywordResponseDto from(PostingKeyword keyword) { + return AdminPostingKeywordResponseDto.builder() + .id(keyword.getId()) + .name(keyword.getName()) + .description(keyword.getDescription()) + .build(); + } + + public static AdminPostingKeywordResponseDto of(Long id, String name, String description) { + return AdminPostingKeywordResponseDto.builder() + .id(id) + .name(name) + .description(description) + .build(); + } +} diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/PostingKeywordQueryRepositoryImpl.java b/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/PostingKeywordQueryRepositoryImpl.java index 28a40bff..fc56cdfe 100644 --- a/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/PostingKeywordQueryRepositoryImpl.java +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/PostingKeywordQueryRepositoryImpl.java @@ -2,6 +2,7 @@ import com.dreamteam.alter.domain.posting.entity.PostingKeyword; import com.dreamteam.alter.domain.posting.entity.QPostingKeyword; +import com.dreamteam.alter.domain.posting.entity.QPostingKeywordMap; import com.dreamteam.alter.domain.posting.port.outbound.PostingKeywordQueryRepository; import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; @@ -35,4 +36,17 @@ public List findAll() { .fetch(); } + @Override + public boolean existsPostingUsingKeyword(Long keywordId) { + QPostingKeywordMap qPostingKeywordMap = QPostingKeywordMap.postingKeywordMap; + + Integer exists = queryFactory + .selectOne() + .from(qPostingKeywordMap) + .where(qPostingKeywordMap.postingKeyword.id.eq(keywordId)) + .fetchFirst(); + + return exists != null; + } + } diff --git a/src/main/java/com/dreamteam/alter/application/posting/usecase/AdminCreatePostingKeyword.java b/src/main/java/com/dreamteam/alter/application/posting/usecase/AdminCreatePostingKeyword.java new file mode 100644 index 00000000..98ac2c42 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/posting/usecase/AdminCreatePostingKeyword.java @@ -0,0 +1,29 @@ +package com.dreamteam.alter.application.posting.usecase; + +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.posting.command.AdminCreatePostingKeywordCommand; +import com.dreamteam.alter.domain.posting.entity.PostingKeyword; +import com.dreamteam.alter.domain.posting.port.inbound.AdminCreatePostingKeywordUseCase; +import com.dreamteam.alter.domain.posting.port.outbound.PostingKeywordRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service("adminCreatePostingKeyword") +@RequiredArgsConstructor +@Transactional +public class AdminCreatePostingKeyword implements AdminCreatePostingKeywordUseCase { + + private final PostingKeywordRepository postingKeywordRepository; + + @Override + public Long execute(AdminCreatePostingKeywordCommand command) { + if (postingKeywordRepository.existsByName(command.name())) { + throw new CustomException(ErrorCode.INVALID_KEYWORD, "이미 존재하는 업종입니다."); + } + + PostingKeyword keyword = PostingKeyword.create(command.name(), command.description()); + return postingKeywordRepository.save(keyword).getId(); + } +} diff --git a/src/main/java/com/dreamteam/alter/application/posting/usecase/AdminDeletePostingKeyword.java b/src/main/java/com/dreamteam/alter/application/posting/usecase/AdminDeletePostingKeyword.java new file mode 100644 index 00000000..efb59878 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/posting/usecase/AdminDeletePostingKeyword.java @@ -0,0 +1,32 @@ +package com.dreamteam.alter.application.posting.usecase; + +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.posting.entity.PostingKeyword; +import com.dreamteam.alter.domain.posting.port.inbound.AdminDeletePostingKeywordUseCase; +import com.dreamteam.alter.domain.posting.port.outbound.PostingKeywordQueryRepository; +import com.dreamteam.alter.domain.posting.port.outbound.PostingKeywordRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service("adminDeletePostingKeyword") +@RequiredArgsConstructor +@Transactional +public class AdminDeletePostingKeyword implements AdminDeletePostingKeywordUseCase { + + private final PostingKeywordRepository postingKeywordRepository; + private final PostingKeywordQueryRepository postingKeywordQueryRepository; + + @Override + public void execute(Long id) { + PostingKeyword keyword = postingKeywordRepository.findById(id) + .orElseThrow(() -> new CustomException(ErrorCode.POSTING_KEYWORDS_NOT_FOUND)); + + if (postingKeywordQueryRepository.existsPostingUsingKeyword(id)) { + throw new CustomException(ErrorCode.CONFLICT, "공고에서 사용 중인 업종은 삭제할 수 없습니다."); + } + + postingKeywordRepository.delete(keyword); + } +} diff --git a/src/main/java/com/dreamteam/alter/application/posting/usecase/AdminGetPostingKeywordList.java b/src/main/java/com/dreamteam/alter/application/posting/usecase/AdminGetPostingKeywordList.java new file mode 100644 index 00000000..464cf19f --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/posting/usecase/AdminGetPostingKeywordList.java @@ -0,0 +1,23 @@ +package com.dreamteam.alter.application.posting.usecase; + +import com.dreamteam.alter.domain.posting.entity.PostingKeyword; +import com.dreamteam.alter.domain.posting.port.inbound.AdminGetPostingKeywordListUseCase; +import com.dreamteam.alter.domain.posting.port.outbound.PostingKeywordQueryRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service("adminGetPostingKeywordList") +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminGetPostingKeywordList implements AdminGetPostingKeywordListUseCase { + + private final PostingKeywordQueryRepository postingKeywordQueryRepository; + + @Override + public List execute() { + return postingKeywordQueryRepository.findAll(); + } +} diff --git a/src/main/java/com/dreamteam/alter/application/posting/usecase/AdminUpdatePostingKeyword.java b/src/main/java/com/dreamteam/alter/application/posting/usecase/AdminUpdatePostingKeyword.java new file mode 100644 index 00000000..5ad913ef --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/posting/usecase/AdminUpdatePostingKeyword.java @@ -0,0 +1,31 @@ +package com.dreamteam.alter.application.posting.usecase; + +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.posting.command.AdminUpdatePostingKeywordCommand; +import com.dreamteam.alter.domain.posting.entity.PostingKeyword; +import com.dreamteam.alter.domain.posting.port.inbound.AdminUpdatePostingKeywordUseCase; +import com.dreamteam.alter.domain.posting.port.outbound.PostingKeywordRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service("adminUpdatePostingKeyword") +@RequiredArgsConstructor +@Transactional +public class AdminUpdatePostingKeyword implements AdminUpdatePostingKeywordUseCase { + + private final PostingKeywordRepository postingKeywordRepository; + + @Override + public void execute(Long id, AdminUpdatePostingKeywordCommand command) { + PostingKeyword keyword = postingKeywordRepository.findById(id) + .orElseThrow(() -> new CustomException(ErrorCode.POSTING_KEYWORDS_NOT_FOUND)); + + if (postingKeywordRepository.existsByNameAndIdNot(command.name(), id)) { + throw new CustomException(ErrorCode.INVALID_KEYWORD, "이미 존재하는 업종입니다."); + } + + keyword.update(command.name(), command.description()); + } +} diff --git a/src/main/java/com/dreamteam/alter/domain/posting/command/AdminCreatePostingKeywordCommand.java b/src/main/java/com/dreamteam/alter/domain/posting/command/AdminCreatePostingKeywordCommand.java new file mode 100644 index 00000000..4cb8dd7e --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/posting/command/AdminCreatePostingKeywordCommand.java @@ -0,0 +1,6 @@ +package com.dreamteam.alter.domain.posting.command; + +public record AdminCreatePostingKeywordCommand( + String name, + String description +) {} diff --git a/src/main/java/com/dreamteam/alter/domain/posting/command/AdminUpdatePostingKeywordCommand.java b/src/main/java/com/dreamteam/alter/domain/posting/command/AdminUpdatePostingKeywordCommand.java new file mode 100644 index 00000000..6257a424 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/posting/command/AdminUpdatePostingKeywordCommand.java @@ -0,0 +1,6 @@ +package com.dreamteam.alter.domain.posting.command; + +public record AdminUpdatePostingKeywordCommand( + String name, + String description +) {} diff --git a/src/main/java/com/dreamteam/alter/domain/posting/entity/PostingKeyword.java b/src/main/java/com/dreamteam/alter/domain/posting/entity/PostingKeyword.java index 7ebea4b2..b99e8565 100644 --- a/src/main/java/com/dreamteam/alter/domain/posting/entity/PostingKeyword.java +++ b/src/main/java/com/dreamteam/alter/domain/posting/entity/PostingKeyword.java @@ -43,4 +43,9 @@ public static PostingKeyword create(String name, String description) { .build(); } + public void update(String name, String description) { + this.name = name; + this.description = description; + } + } diff --git a/src/main/java/com/dreamteam/alter/domain/posting/port/inbound/AdminCreatePostingKeywordUseCase.java b/src/main/java/com/dreamteam/alter/domain/posting/port/inbound/AdminCreatePostingKeywordUseCase.java new file mode 100644 index 00000000..a979882a --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/posting/port/inbound/AdminCreatePostingKeywordUseCase.java @@ -0,0 +1,8 @@ +package com.dreamteam.alter.domain.posting.port.inbound; + +import com.dreamteam.alter.domain.posting.command.AdminCreatePostingKeywordCommand; + +public interface AdminCreatePostingKeywordUseCase { + + Long execute(AdminCreatePostingKeywordCommand command); +} diff --git a/src/main/java/com/dreamteam/alter/domain/posting/port/inbound/AdminDeletePostingKeywordUseCase.java b/src/main/java/com/dreamteam/alter/domain/posting/port/inbound/AdminDeletePostingKeywordUseCase.java new file mode 100644 index 00000000..25675e4d --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/posting/port/inbound/AdminDeletePostingKeywordUseCase.java @@ -0,0 +1,6 @@ +package com.dreamteam.alter.domain.posting.port.inbound; + +public interface AdminDeletePostingKeywordUseCase { + + void execute(Long id); +} diff --git a/src/main/java/com/dreamteam/alter/domain/posting/port/inbound/AdminGetPostingKeywordListUseCase.java b/src/main/java/com/dreamteam/alter/domain/posting/port/inbound/AdminGetPostingKeywordListUseCase.java new file mode 100644 index 00000000..bfa42c4d --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/posting/port/inbound/AdminGetPostingKeywordListUseCase.java @@ -0,0 +1,10 @@ +package com.dreamteam.alter.domain.posting.port.inbound; + +import com.dreamteam.alter.domain.posting.entity.PostingKeyword; + +import java.util.List; + +public interface AdminGetPostingKeywordListUseCase { + + List execute(); +} diff --git a/src/main/java/com/dreamteam/alter/domain/posting/port/inbound/AdminUpdatePostingKeywordUseCase.java b/src/main/java/com/dreamteam/alter/domain/posting/port/inbound/AdminUpdatePostingKeywordUseCase.java new file mode 100644 index 00000000..6d635a18 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/posting/port/inbound/AdminUpdatePostingKeywordUseCase.java @@ -0,0 +1,8 @@ +package com.dreamteam.alter.domain.posting.port.inbound; + +import com.dreamteam.alter.domain.posting.command.AdminUpdatePostingKeywordCommand; + +public interface AdminUpdatePostingKeywordUseCase { + + void execute(Long id, AdminUpdatePostingKeywordCommand command); +} diff --git a/src/main/java/com/dreamteam/alter/domain/posting/port/outbound/PostingKeywordQueryRepository.java b/src/main/java/com/dreamteam/alter/domain/posting/port/outbound/PostingKeywordQueryRepository.java index 8cec6ec5..ab3b612a 100644 --- a/src/main/java/com/dreamteam/alter/domain/posting/port/outbound/PostingKeywordQueryRepository.java +++ b/src/main/java/com/dreamteam/alter/domain/posting/port/outbound/PostingKeywordQueryRepository.java @@ -7,4 +7,5 @@ public interface PostingKeywordQueryRepository { List findByIds(List keywordIds); List findAll(); + boolean existsPostingUsingKeyword(Long keywordId); } diff --git a/src/main/java/com/dreamteam/alter/domain/posting/port/outbound/PostingKeywordRepository.java b/src/main/java/com/dreamteam/alter/domain/posting/port/outbound/PostingKeywordRepository.java new file mode 100644 index 00000000..d8d79c56 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/posting/port/outbound/PostingKeywordRepository.java @@ -0,0 +1,11 @@ +package com.dreamteam.alter.domain.posting.port.outbound; + +import com.dreamteam.alter.domain.posting.entity.PostingKeyword; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PostingKeywordRepository extends JpaRepository { + + boolean existsByName(String name); + + boolean existsByNameAndIdNot(String name, Long id); +} diff --git a/src/main/resources/db/migration/V5__seed_posting_keywords.sql b/src/main/resources/db/migration/V5__seed_posting_keywords.sql new file mode 100644 index 00000000..9967395f --- /dev/null +++ b/src/main/resources/db/migration/V5__seed_posting_keywords.sql @@ -0,0 +1,17 @@ +-- 업종/키워드 마스터 초기 시드. +-- 기존 수동 삽입분과의 충돌을 막기 위해 이름 기준 멱등 처리(ON CONFLICT DO NOTHING). +-- created_at/updated_at은 NOT NULL이며 기본값이 없어(Flyway는 JPA Auditing 우회) now()를 명시한다. +INSERT INTO posting_keywords (name, description, created_at, updated_at) +VALUES + ('카페', NULL, now(), now()), + ('음식점', NULL, now(), now()), + ('주점', NULL, now(), now()), + ('편의점', NULL, now(), now()), + ('베이커리', NULL, now(), now()), + ('패스트푸드', NULL, now(), now()), + ('배달', NULL, now(), now()), + ('판매/매장', NULL, now(), now()), + ('사무보조', NULL, now(), now()), + ('물류/포장', NULL, now(), now()), + ('기타', NULL, now(), now()) +ON CONFLICT (name) DO NOTHING; From a92b00153f8133090893cd33bcf0932f163f7fd5 Mon Sep 17 00:00:00 2001 From: Seungwan Yoo Date: Wed, 8 Jul 2026 00:36:02 +0900 Subject: [PATCH 2/9] =?UTF-8?q?feat:=20[alt-267]=20=EA=B3=B5=EA=B3=A0=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=C2=B7=EC=88=98=EC=A0=95=20=EC=8B=9C=20?= =?UTF-8?q?=EC=A7=81=EC=A0=91=EC=9E=85=EB=A0=A5=20=EC=97=85=EC=A2=85=20?= =?UTF-8?q?=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - postings.custom_keywords jsonb 컬럼 추가(V4) - 마스터 선택(keywords) + 직접입력(customKeywords) 병행, 업종 최소 1개 검증(AssertTrue) --- .../posting/dto/CreatePostingRequestDto.java | 17 ++++++++++++++--- .../posting/dto/UpdatePostingRequestDto.java | 17 ++++++++++++++--- .../posting/usecase/CreatePosting.java | 8 ++++++-- .../posting/usecase/ManagerUpdatePosting.java | 9 +++++++-- .../alter/domain/posting/entity/Posting.java | 13 +++++++++++++ .../V4__add_postings_custom_keywords_column.sql | 4 ++++ 6 files changed, 58 insertions(+), 10 deletions(-) create mode 100644 src/main/resources/db/migration/V4__add_postings_custom_keywords_column.sql diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/CreatePostingRequestDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/CreatePostingRequestDto.java index e546c2a0..9f4bafe2 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/CreatePostingRequestDto.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/CreatePostingRequestDto.java @@ -3,10 +3,12 @@ import com.dreamteam.alter.domain.posting.type.PaymentType; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; +import jakarta.validation.constraints.AssertTrue; import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; import lombok.*; +import org.apache.commons.lang3.ObjectUtils; import java.util.List; @@ -39,10 +41,13 @@ public class CreatePostingRequestDto { @NotNull private PaymentType paymentType; - @Schema(description = "키워드", example = "[2, 3, 1]") - @NotEmpty + @Schema(description = "마스터 업종 ID 목록 (선택)", example = "[2, 3, 1]") private List keywords; + @Schema(description = "직접입력 업종 (마스터 미등록, 공고 라벨로 저장)", example = "[\"브런치카페\"]") + @Size(max = 10) + private List<@NotBlank @Size(max = 128) String> customKeywords; + @Schema(description = "공고 스케줄", example = "[" + "{" + "\"workingDays\": [\"MONDAY\", \"WEDNESDAY\"], \"startTime\": \"09:00\", \"endTime\": \"18:00\", \"positionsNeeded\": 3, \"position\": \"설거지\"" + @@ -54,4 +59,10 @@ public class CreatePostingRequestDto { @Valid private List schedules; + @Schema(hidden = true) + @AssertTrue(message = "업종은 최소 1개 이상 선택하거나 직접 입력해야 합니다.") + public boolean isKeywordProvided() { + return ObjectUtils.isNotEmpty(keywords) || ObjectUtils.isNotEmpty(customKeywords); + } + } diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/manager/posting/dto/UpdatePostingRequestDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/manager/posting/dto/UpdatePostingRequestDto.java index 63b30d7c..a461bf76 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/manager/posting/dto/UpdatePostingRequestDto.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/manager/posting/dto/UpdatePostingRequestDto.java @@ -7,15 +7,17 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; +import jakarta.validation.constraints.AssertTrue; import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import org.apache.commons.lang3.ObjectUtils; @Getter @NoArgsConstructor(access = AccessLevel.PRIVATE) @@ -39,10 +41,13 @@ public class UpdatePostingRequestDto { @Schema(description = "급여 타입", example = "HOURLY") private PaymentType paymentType; - @NotEmpty - @Schema(description = "키워드", example = "[2, 3, 1]") + @Schema(description = "마스터 업종 ID 목록 (선택)", example = "[2, 3, 1]") private List keywords; + @Size(max = 10) + @Schema(description = "직접입력 업종 (마스터 미등록, 공고 라벨로 저장)", example = "[\"브런치카페\"]") + private List<@NotBlank @Size(max = 128) String> customKeywords; + @Valid @Schema(description = "새로 추가할 스케줄", example = "[{\"workingDays\": [\"FRIDAY\"], \"startTime\": \"13:00\", \"endTime\": \"21:00\", \"positionsNeeded\": 1, \"position\": \"설거지\"}]") private List createSchedules; @@ -53,4 +58,10 @@ public class UpdatePostingRequestDto { @Schema(description = "삭제할 스케줄 ID", example = "[2, 3]") private List deleteScheduleIds; + + @Schema(hidden = true) + @AssertTrue(message = "업종은 최소 1개 이상 선택하거나 직접 입력해야 합니다.") + public boolean isKeywordProvided() { + return ObjectUtils.isNotEmpty(keywords) || ObjectUtils.isNotEmpty(customKeywords); + } } diff --git a/src/main/java/com/dreamteam/alter/application/posting/usecase/CreatePosting.java b/src/main/java/com/dreamteam/alter/application/posting/usecase/CreatePosting.java index d299b006..e5227c07 100644 --- a/src/main/java/com/dreamteam/alter/application/posting/usecase/CreatePosting.java +++ b/src/main/java/com/dreamteam/alter/application/posting/usecase/CreatePosting.java @@ -2,6 +2,7 @@ import java.util.List; +import org.apache.commons.lang3.ObjectUtils; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -29,8 +30,11 @@ public class CreatePosting implements CreatePostingUseCase { @Override public void execute(CreatePostingRequestDto request) { - List postingKeywords = postingKeywordQueryRepository.findByIds(request.getKeywords()); - if (postingKeywords.size() != request.getKeywords().size()) { + List postingKeywords = ObjectUtils.isEmpty(request.getKeywords()) + ? List.of() + : postingKeywordQueryRepository.findByIds(request.getKeywords()); + if (ObjectUtils.isNotEmpty(request.getKeywords()) + && postingKeywords.size() != request.getKeywords().size()) { throw new CustomException(ErrorCode.INVALID_KEYWORD); } diff --git a/src/main/java/com/dreamteam/alter/application/posting/usecase/ManagerUpdatePosting.java b/src/main/java/com/dreamteam/alter/application/posting/usecase/ManagerUpdatePosting.java index 2cbf2d6f..9c4d3350 100644 --- a/src/main/java/com/dreamteam/alter/application/posting/usecase/ManagerUpdatePosting.java +++ b/src/main/java/com/dreamteam/alter/application/posting/usecase/ManagerUpdatePosting.java @@ -2,6 +2,7 @@ import java.util.List; +import org.apache.commons.lang3.ObjectUtils; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -33,8 +34,11 @@ public void execute(Long postingId, UpdatePostingRequestDto request, ManagerActo Posting posting = postingQueryRepository.findByManagerAndId(postingId, managerUser) .orElseThrow(() -> new CustomException(ErrorCode.POSTING_NOT_FOUND)); - List postingKeywords = postingKeywordQueryRepository.findByIds(request.getKeywords()); - if (postingKeywords.size() != request.getKeywords().size()) { + List postingKeywords = ObjectUtils.isEmpty(request.getKeywords()) + ? List.of() + : postingKeywordQueryRepository.findByIds(request.getKeywords()); + if (ObjectUtils.isNotEmpty(request.getKeywords()) + && postingKeywords.size() != request.getKeywords().size()) { throw new CustomException(ErrorCode.INVALID_KEYWORD); } @@ -44,6 +48,7 @@ public void execute(Long postingId, UpdatePostingRequestDto request, ManagerActo request.getPayAmount(), request.getPaymentType(), postingKeywords, + request.getCustomKeywords(), request.getCreateSchedules(), request.getUpdateSchedules(), request.getDeleteScheduleIds() diff --git a/src/main/java/com/dreamteam/alter/domain/posting/entity/Posting.java b/src/main/java/com/dreamteam/alter/domain/posting/entity/Posting.java index 0f2bc054..35f66f5a 100644 --- a/src/main/java/com/dreamteam/alter/domain/posting/entity/Posting.java +++ b/src/main/java/com/dreamteam/alter/domain/posting/entity/Posting.java @@ -8,17 +8,20 @@ import com.dreamteam.alter.domain.posting.type.PaymentType; import com.dreamteam.alter.domain.posting.type.PostingStatus; import com.dreamteam.alter.domain.workspace.entity.Workspace; +import com.vladmihalcea.hibernate.type.json.JsonBinaryType; import java.time.DayOfWeek; import java.time.LocalTime; import jakarta.persistence.*; import lombok.*; import org.apache.commons.lang3.ObjectUtils; +import org.hibernate.annotations.Type; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.List; @Entity @@ -70,6 +73,10 @@ public class Posting { @OneToMany(mappedBy = "posting", cascade = CascadeType.ALL, orphanRemoval = true) private List keywords; + @Type(JsonBinaryType.class) + @Column(name = "custom_keywords", columnDefinition = "jsonb") + private List customKeywords; + public static Posting create(CreatePostingRequestDto request, Workspace workspace, List postingKeywords) { Posting posting = Posting.builder() .workspace(workspace) @@ -85,6 +92,10 @@ public static Posting create(CreatePostingRequestDto request, Workspace workspac .map(keyword -> PostingKeywordMap.create(keyword, posting)) .toList(); + posting.customKeywords = ObjectUtils.isNotEmpty(request.getCustomKeywords()) + ? request.getCustomKeywords() + : new ArrayList<>(); + if (ObjectUtils.isNotEmpty(request.getSchedules())) { posting.schedules = request.getSchedules() .stream() @@ -112,6 +123,7 @@ public void updateContent( int payAmount, PaymentType paymentType, List postingKeywords, + List customKeywords, List createSchedules, List updateSchedules, List deleteScheduleIds @@ -120,6 +132,7 @@ public void updateContent( this.description = description; this.payAmount = payAmount; this.paymentType = paymentType; + this.customKeywords = ObjectUtils.isNotEmpty(customKeywords) ? customKeywords : new ArrayList<>(); // 키워드 업데이트 updateKeyword(postingKeywords); diff --git a/src/main/resources/db/migration/V4__add_postings_custom_keywords_column.sql b/src/main/resources/db/migration/V4__add_postings_custom_keywords_column.sql new file mode 100644 index 00000000..53eac0b0 --- /dev/null +++ b/src/main/resources/db/migration/V4__add_postings_custom_keywords_column.sql @@ -0,0 +1,4 @@ +-- 공고 직접입력 업종(라벨) 저장용 jsonb 컬럼 추가. +-- 마스터(posting_keywords)에 등록하지 않는 자유 입력 업종명 리스트를 공고에 직접 보관한다. +ALTER TABLE postings + ADD COLUMN custom_keywords jsonb; From 75387b345f2c95ad461302c8d5070f6f52fd2c3f Mon Sep 17 00:00:00 2001 From: Seungwan Yoo Date: Wed, 8 Jul 2026 00:36:02 +0900 Subject: [PATCH 3/9] =?UTF-8?q?feat:=20[alt-267]=20=EC=95=8C=EB=B0=94?= =?UTF-8?q?=EC=83=9D=20=EA=B3=B5=EA=B3=A0=20=EB=AA=A9=EB=A1=9D=20=EC=97=85?= =?UTF-8?q?=EC=A2=85=20=ED=95=84=ED=84=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /app/postings?keyword= : 마스터 업종명 OR 직접입력 라벨(jsonb) 부분일치 - filter-options에 업종 추천 facet 노출 --- .../dto/PostingFilterOptionsResponseDto.java | 11 +++++++- .../posting/dto/PostingListFilterDto.java | 3 ++ .../PostingQueryRepositoryImpl.java | 28 +++++++++++++++++-- .../usecase/GetPostingFilterOptions.java | 5 +++- 4 files changed, 43 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/PostingFilterOptionsResponseDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/PostingFilterOptionsResponseDto.java index 05f61292..51040da9 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/PostingFilterOptionsResponseDto.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/PostingFilterOptionsResponseDto.java @@ -1,6 +1,7 @@ package com.dreamteam.alter.adapter.inbound.general.posting.dto; import com.dreamteam.alter.adapter.inbound.common.dto.DescribedEnumDto; +import com.dreamteam.alter.domain.posting.entity.PostingKeyword; import com.dreamteam.alter.domain.posting.type.PostingSortType; import io.swagger.v3.oas.annotations.media.Schema; import lombok.*; @@ -26,11 +27,15 @@ public class PostingFilterOptionsResponseDto { @Schema(description = "정렬 옵션 목록") private List> sortOptions; + @Schema(description = "업종(마스터) 추천 목록 — 필터 검색어 자동완성/칩 용도") + private List keywords; + public static PostingFilterOptionsResponseDto of( List provinces, List districts, List towns, - List sortOptions + List sortOptions, + List keywords ) { return PostingFilterOptionsResponseDto.builder() .provinces(provinces) @@ -40,6 +45,10 @@ public static PostingFilterOptionsResponseDto of( .map(sortType -> DescribedEnumDto.of(sortType, PostingSortType.describe())) .toList() ) + .keywords(keywords.stream() + .map(PostingKeywordListResponseDto::from) + .toList() + ) .build(); } diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/PostingListFilterDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/PostingListFilterDto.java index 3f832039..7901320d 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/PostingListFilterDto.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/PostingListFilterDto.java @@ -37,4 +37,7 @@ public class PostingListFilterDto { @Parameter(description = "급여순 정렬 여부") private Boolean payAmountSort; + + @Parameter(description = "업종 검색어 (마스터 업종명 또는 직접입력 업종 부분일치)") + private String keyword; } diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/PostingQueryRepositoryImpl.java b/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/PostingQueryRepositoryImpl.java index 309fde81..d2a9bf64 100644 --- a/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/PostingQueryRepositoryImpl.java +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/PostingQueryRepositoryImpl.java @@ -19,6 +19,7 @@ import com.querydsl.core.types.OrderSpecifier; import com.querydsl.core.types.Projections; import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.Expressions; import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.BooleanUtils; @@ -41,6 +42,8 @@ public class PostingQueryRepositoryImpl implements PostingQueryRepository { public long getCountOfPostings(PostingListFilterDto filter) { QPosting qPosting = QPosting.posting; QPostingSchedule qPostingSchedule = QPostingSchedule.postingSchedule; + QPostingKeywordMap qPostingKeywordMap = QPostingKeywordMap.postingKeywordMap; + QPostingKeyword qPostingKeyword = QPostingKeyword.postingKeyword; QWorkspace qWorkspace = QWorkspace.workspace; Long count = queryFactory @@ -48,6 +51,8 @@ public long getCountOfPostings(PostingListFilterDto filter) { .from(qPosting) .leftJoin(qPosting.schedules, qPostingSchedule) .leftJoin(qPosting.workspace, qWorkspace) + .leftJoin(qPosting.keywords, qPostingKeywordMap) + .leftJoin(qPostingKeywordMap.postingKeyword, qPostingKeyword) .where( qPosting.status.eq(PostingStatus.OPEN), eqProvince(qWorkspace, filter.getProvince()), @@ -56,7 +61,8 @@ public long getCountOfPostings(PostingListFilterDto filter) { gtePayAmount(qPosting, filter.getMinPayAmount()), ltePayAmount(qPosting, filter.getMaxPayAmount()), gteStartTime(qPostingSchedule, filter.getStartTime()), - lteEndTime(qPostingSchedule, filter.getEndTime()) + lteEndTime(qPostingSchedule, filter.getEndTime()), + keywordMatches(qPostingKeyword, qPosting, filter.getKeyword()) ) .fetchOne(); @@ -96,6 +102,8 @@ public List getPostingsWithCursor(CursorPageRequest getPostingsWithCursor(CursorPageRequest[] getOrderSpecifiersForMapList(QPosting qPosting, PostingSortType sortType) { List> orderSpecifiers = new ArrayList<>(); diff --git a/src/main/java/com/dreamteam/alter/application/posting/usecase/GetPostingFilterOptions.java b/src/main/java/com/dreamteam/alter/application/posting/usecase/GetPostingFilterOptions.java index 6ccfbc10..8a0403d1 100644 --- a/src/main/java/com/dreamteam/alter/application/posting/usecase/GetPostingFilterOptions.java +++ b/src/main/java/com/dreamteam/alter/application/posting/usecase/GetPostingFilterOptions.java @@ -3,6 +3,7 @@ import com.dreamteam.alter.adapter.inbound.general.posting.dto.PostingFilterOptionsResponseDto; import com.dreamteam.alter.adapter.outbound.posting.persistence.readonly.PostingFilterOptionsResponse; import com.dreamteam.alter.domain.posting.port.inbound.GetPostingFilterOptionsUseCase; +import com.dreamteam.alter.domain.posting.port.outbound.PostingKeywordQueryRepository; import com.dreamteam.alter.domain.posting.port.outbound.PostingQueryRepository; import com.dreamteam.alter.domain.posting.type.PostingSortType; import lombok.RequiredArgsConstructor; @@ -17,6 +18,7 @@ public class GetPostingFilterOptions implements GetPostingFilterOptionsUseCase { private final PostingQueryRepository postingQueryRepository; + private final PostingKeywordQueryRepository postingKeywordQueryRepository; @Override public PostingFilterOptionsResponseDto execute() { @@ -26,7 +28,8 @@ public PostingFilterOptionsResponseDto execute() { filterOptions.getProvinces(), filterOptions.getDistricts(), filterOptions.getTowns(), - Arrays.asList(PostingSortType.values()) + Arrays.asList(PostingSortType.values()), + postingKeywordQueryRepository.findAll() ); } } From edd90aeca33a7a9545a75764254daf1f3ca5418a Mon Sep 17 00:00:00 2001 From: Seungwan Yoo Date: Wed, 8 Jul 2026 00:36:02 +0900 Subject: [PATCH 4/9] =?UTF-8?q?feat:=20[alt-267]=20=EA=B3=B5=EA=B3=A0=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=EC=97=90=20=EC=A7=81=EC=A0=91=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=20=EC=97=85=EC=A2=85=20=EB=85=B8=EC=B6=9C=20=EB=B0=8F?= =?UTF-8?q?=20=ED=82=A4=EC=9B=8C=EB=93=9C=20null=20=EA=B0=80=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 목록·상세 응답(알바생/매니저)에 customKeywords 필드 추가 - 마스터 키워드 0개(직접입력 전용) 공고 조회 시 NPE 방지(getOrDefault) --- .../general/posting/dto/PostingDetailResponseDto.java | 7 ++++++- .../general/posting/dto/PostingListResponseDto.java | 7 ++++++- .../posting/dto/ManagerPostingDetailResponseDto.java | 7 ++++++- .../manager/posting/dto/ManagerPostingListResponseDto.java | 7 ++++++- .../persistence/readonly/ManagerPostingDetailResponse.java | 4 ++++ .../persistence/readonly/ManagerPostingListResponse.java | 6 +++++- .../persistence/readonly/PostingDetailResponse.java | 4 ++++ .../posting/persistence/readonly/PostingListResponse.java | 6 +++++- 8 files changed, 42 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/PostingDetailResponseDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/PostingDetailResponseDto.java index b4f5863e..278186fc 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/PostingDetailResponseDto.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/PostingDetailResponseDto.java @@ -48,9 +48,13 @@ public class PostingDetailResponseDto { private LocalDateTime createdAt; @NotNull - @Schema(description = "키워드", example = "[{\"id\":1,\"name\":\"카페\"},{\"id\":4,\"name\":\"분식\"}]") + @Schema(description = "키워드(마스터 업종)", example = "[{\"id\":1,\"name\":\"카페\"},{\"id\":4,\"name\":\"분식\"}]") private List keywords; + @NotNull + @Schema(description = "직접입력 업종", example = "[\"브런치카페\"]") + private List customKeywords; + @NotNull @Schema(description = "공고 스케줄", example = "[{\"id\":1,\"workingDays\":[\"MONDAY\",\"WEDNESDAY\"],\"startTime\":\"09:00\",\"endTime\":\"18:00\",\"positionsNeeded\":3,\"positionsAvailable\":2,\"position\":\"홀서빙\"}]") private List schedules; @@ -77,6 +81,7 @@ public static PostingDetailResponseDto from( .keywords(entity.getPostingKeywords().stream() .map(PostingKeywordListResponseDto::from) .toList()) + .customKeywords(entity.getCustomKeywords()) .scrapped(entity.isScrapped()) .build(); } diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/PostingListResponseDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/PostingListResponseDto.java index 0d977a4a..6a98083e 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/PostingListResponseDto.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/general/posting/dto/PostingListResponseDto.java @@ -38,9 +38,13 @@ public class PostingListResponseDto { private LocalDateTime createdAt; @NotNull - @Schema(description = "키워드", example = "[{\"id\":1,\"name\":\"카페\"},{\"id\":4,\"name\":\"분식\"}]") + @Schema(description = "키워드(마스터 업종)", example = "[{\"id\":1,\"name\":\"카페\"},{\"id\":4,\"name\":\"분식\"}]") private List keywords; + @NotNull + @Schema(description = "직접입력 업종", example = "[\"브런치카페\"]") + private List customKeywords; + @NotNull @Schema(description = "공고 스케줄", example = "[" + "{" + @@ -73,6 +77,7 @@ public static PostingListResponseDto from(PostingListResponse entity) { .keywords(entity.getPostingKeywords().stream() .map(PostingKeywordListResponseDto::from) .toList()) + .customKeywords(entity.getCustomKeywords()) .workspace(PostingListWorkspaceResponseDto.from(entity.getWorkspace())) .scrapped(entity.isScrapped()) .build(); diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/manager/posting/dto/ManagerPostingDetailResponseDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/manager/posting/dto/ManagerPostingDetailResponseDto.java index 026ec861..4949a28e 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/manager/posting/dto/ManagerPostingDetailResponseDto.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/manager/posting/dto/ManagerPostingDetailResponseDto.java @@ -59,9 +59,13 @@ public class ManagerPostingDetailResponseDto { private LocalDateTime updatedAt; @NotNull - @Schema(description = "키워드", example = "[{\"id\":1,\"name\":\"카페\"},{\"id\":4,\"name\":\"분식\"}]") + @Schema(description = "키워드(마스터 업종)", example = "[{\"id\":1,\"name\":\"카페\"},{\"id\":4,\"name\":\"분식\"}]") private List keywords; + @NotNull + @Schema(description = "직접입력 업종", example = "[\"브런치카페\"]") + private List customKeywords; + @NotNull @Schema(description = "공고 스케줄", example = "[{\"id\":1,\"workingDays\":[\"MONDAY\",\"WEDNESDAY\"],\"startTime\":\"09:00\",\"endTime\":\"18:00\",\"positionsNeeded\":3,\"positionsAvailable\":2,\"position\":\"홀서빙\"}]") private List schedules; @@ -83,6 +87,7 @@ public static ManagerPostingDetailResponseDto from(ManagerPostingDetailResponse .keywords(entity.getPostingKeywords().stream() .map(PostingKeywordListResponseDto::from) .toList()) + .customKeywords(entity.getCustomKeywords()) .build(); } } diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/manager/posting/dto/ManagerPostingListResponseDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/manager/posting/dto/ManagerPostingListResponseDto.java index 7a146026..2272de80 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/manager/posting/dto/ManagerPostingListResponseDto.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/manager/posting/dto/ManagerPostingListResponseDto.java @@ -41,9 +41,13 @@ public class ManagerPostingListResponseDto { private LocalDateTime createdAt; @NotNull - @Schema(description = "키워드", example = "[{\"id\":1,\"name\":\"카페\"},{\"id\":4,\"name\":\"분식\"}]") + @Schema(description = "키워드(마스터 업종)", example = "[{\"id\":1,\"name\":\"카페\"},{\"id\":4,\"name\":\"분식\"}]") private List keywords; + @NotNull + @Schema(description = "직접입력 업종", example = "[\"브런치카페\"]") + private List customKeywords; + @NotNull @Schema(description = "공고 스케줄", example = "[" + "{" + @@ -69,6 +73,7 @@ public static ManagerPostingListResponseDto from(ManagerPostingListResponse resp .keywords(response.getPostingKeywords().stream() .map(PostingKeywordListResponseDto::from) .toList()) + .customKeywords(response.getCustomKeywords()) .schedules(response.getSchedules().stream() .map(PostingScheduleResponseDto::from) .toList()) diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/readonly/ManagerPostingDetailResponse.java b/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/readonly/ManagerPostingDetailResponse.java index 4ac27a5f..83ed7995 100644 --- a/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/readonly/ManagerPostingDetailResponse.java +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/readonly/ManagerPostingDetailResponse.java @@ -10,6 +10,7 @@ import java.time.LocalDateTime; import java.util.List; +import java.util.Objects; @Getter @NoArgsConstructor(access = AccessLevel.PRIVATE) @@ -36,6 +37,8 @@ public class ManagerPostingDetailResponse { private List postingKeywords; + private List customKeywords; + private List schedules; public static ManagerPostingDetailResponse of( @@ -53,6 +56,7 @@ public static ManagerPostingDetailResponse of( posting.getCreatedAt(), posting.getUpdatedAt(), postingKeywords, + Objects.requireNonNullElse(posting.getCustomKeywords(), List.of()), posting.getSchedules() ); } diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/readonly/ManagerPostingListResponse.java b/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/readonly/ManagerPostingListResponse.java index 6e24a67e..1d018bad 100644 --- a/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/readonly/ManagerPostingListResponse.java +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/readonly/ManagerPostingListResponse.java @@ -10,6 +10,7 @@ import java.time.LocalDateTime; import java.util.List; import java.util.Map; +import java.util.Objects; @Getter @NoArgsConstructor(access = AccessLevel.PRIVATE) @@ -29,6 +30,8 @@ public class ManagerPostingListResponse { private List postingKeywords; + private List customKeywords; + private List schedules; private Workspace workspace; @@ -41,7 +44,8 @@ public static ManagerPostingListResponse of(Posting posting, Map postingKeywords; + private List customKeywords; + private List schedules; private boolean scrapped; @@ -49,6 +52,7 @@ public static PostingDetailResponse of( posting.getPaymentType(), posting.getCreatedAt(), postingKeywords, + Objects.requireNonNullElse(posting.getCustomKeywords(), List.of()), posting.getSchedules(), scrapped ); diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/readonly/PostingListResponse.java b/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/readonly/PostingListResponse.java index c2424103..1e0296c0 100644 --- a/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/readonly/PostingListResponse.java +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/readonly/PostingListResponse.java @@ -10,6 +10,7 @@ import java.time.LocalDateTime; import java.util.List; import java.util.Map; +import java.util.Objects; @Getter @NoArgsConstructor(access = AccessLevel.PRIVATE) @@ -29,6 +30,8 @@ public class PostingListResponse { private List postingKeywords; + private List customKeywords; + private List schedules; private Workspace workspace; @@ -43,7 +46,8 @@ public static PostingListResponse of(Posting posting, Map Date: Wed, 8 Jul 2026 00:36:02 +0900 Subject: [PATCH 5/9] =?UTF-8?q?fix:=20[alt-267]=20=EA=B3=B5=EA=B3=A0=20?= =?UTF-8?q?=EC=A7=80=EC=9B=90=20=EC=A1=B0=EA=B1=B4=20=EB=B0=98=EC=A0=84=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 모집중(OPEN) 공고 지원이 오히려 차단되던 조건 반전 버그 수정. if (OPEN) throw -> if (!OPEN) throw. --- .../application/posting/usecase/CreatePostingApplication.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/dreamteam/alter/application/posting/usecase/CreatePostingApplication.java b/src/main/java/com/dreamteam/alter/application/posting/usecase/CreatePostingApplication.java index b625da98..14246ad1 100644 --- a/src/main/java/com/dreamteam/alter/application/posting/usecase/CreatePostingApplication.java +++ b/src/main/java/com/dreamteam/alter/application/posting/usecase/CreatePostingApplication.java @@ -40,7 +40,7 @@ public void execute(AppActor actor, Long postingId, CreatePostingApplicationRequ .orElseThrow(() -> new CustomException(ErrorCode.POSTING_SCHEDULE_NOT_FOUND)); Posting posting = postingSchedule.getPosting(); - if (PostingStatus.OPEN.equals(posting.getStatus())) { + if (!PostingStatus.OPEN.equals(posting.getStatus())) { throw new CustomException(ErrorCode.ILLEGAL_ARGUMENT, "모집이 종료된 공고입니다."); } From 32b2ef7bb119d99fc1e54e8d2829160d5b40c1ea Mon Sep 17 00:00:00 2001 From: Seungwan Yoo Date: Sat, 11 Jul 2026 21:43:21 +0900 Subject: [PATCH 6/9] =?UTF-8?q?refactor:=20[alt-267]=20=EC=97=85=EC=A2=85?= =?UTF-8?q?=20=ED=82=A4=EC=9B=8C=EB=93=9C=20=EC=83=9D=EC=84=B1=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=EC=9D=84=20=EC=98=81=EC=86=8D=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=EA=B8=B0=EB=B0=98=EC=9C=BC=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../posting/controller/AdminPostingKeywordController.java | 7 +++---- .../admin/posting/dto/AdminPostingKeywordResponseDto.java | 8 -------- .../posting/usecase/AdminCreatePostingKeyword.java | 4 ++-- .../port/inbound/AdminCreatePostingKeywordUseCase.java | 3 ++- 4 files changed, 7 insertions(+), 15 deletions(-) diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/controller/AdminPostingKeywordController.java b/src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/controller/AdminPostingKeywordController.java index 688c9df8..1a9f57cd 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/controller/AdminPostingKeywordController.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/controller/AdminPostingKeywordController.java @@ -5,6 +5,7 @@ import com.dreamteam.alter.adapter.inbound.common.dto.CommonApiResponse; import com.dreamteam.alter.domain.posting.command.AdminCreatePostingKeywordCommand; import com.dreamteam.alter.domain.posting.command.AdminUpdatePostingKeywordCommand; +import com.dreamteam.alter.domain.posting.entity.PostingKeyword; import com.dreamteam.alter.domain.posting.port.inbound.AdminCreatePostingKeywordUseCase; import com.dreamteam.alter.domain.posting.port.inbound.AdminDeletePostingKeywordUseCase; import com.dreamteam.alter.domain.posting.port.inbound.AdminGetPostingKeywordListUseCase; @@ -53,13 +54,11 @@ public ResponseEntity>> g public ResponseEntity> createPostingKeyword( @Valid @RequestBody AdminPostingKeywordRequestDto request ) { - Long id = adminCreatePostingKeyword.execute( + PostingKeyword created = adminCreatePostingKeyword.execute( new AdminCreatePostingKeywordCommand(request.getName(), request.getDescription()) ); return ResponseEntity.status(HttpStatus.CREATED) - .body(CommonApiResponse.of( - AdminPostingKeywordResponseDto.of(id, request.getName(), request.getDescription()) - )); + .body(CommonApiResponse.of(AdminPostingKeywordResponseDto.from(created))); } @Override diff --git a/src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/dto/AdminPostingKeywordResponseDto.java b/src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/dto/AdminPostingKeywordResponseDto.java index b83fedc1..344faaad 100644 --- a/src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/dto/AdminPostingKeywordResponseDto.java +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/dto/AdminPostingKeywordResponseDto.java @@ -31,12 +31,4 @@ public static AdminPostingKeywordResponseDto from(PostingKeyword keyword) { .description(keyword.getDescription()) .build(); } - - public static AdminPostingKeywordResponseDto of(Long id, String name, String description) { - return AdminPostingKeywordResponseDto.builder() - .id(id) - .name(name) - .description(description) - .build(); - } } diff --git a/src/main/java/com/dreamteam/alter/application/posting/usecase/AdminCreatePostingKeyword.java b/src/main/java/com/dreamteam/alter/application/posting/usecase/AdminCreatePostingKeyword.java index 98ac2c42..6e597e8c 100644 --- a/src/main/java/com/dreamteam/alter/application/posting/usecase/AdminCreatePostingKeyword.java +++ b/src/main/java/com/dreamteam/alter/application/posting/usecase/AdminCreatePostingKeyword.java @@ -18,12 +18,12 @@ public class AdminCreatePostingKeyword implements AdminCreatePostingKeywordUseCa private final PostingKeywordRepository postingKeywordRepository; @Override - public Long execute(AdminCreatePostingKeywordCommand command) { + public PostingKeyword execute(AdminCreatePostingKeywordCommand command) { if (postingKeywordRepository.existsByName(command.name())) { throw new CustomException(ErrorCode.INVALID_KEYWORD, "이미 존재하는 업종입니다."); } PostingKeyword keyword = PostingKeyword.create(command.name(), command.description()); - return postingKeywordRepository.save(keyword).getId(); + return postingKeywordRepository.save(keyword); } } diff --git a/src/main/java/com/dreamteam/alter/domain/posting/port/inbound/AdminCreatePostingKeywordUseCase.java b/src/main/java/com/dreamteam/alter/domain/posting/port/inbound/AdminCreatePostingKeywordUseCase.java index a979882a..209c3a2d 100644 --- a/src/main/java/com/dreamteam/alter/domain/posting/port/inbound/AdminCreatePostingKeywordUseCase.java +++ b/src/main/java/com/dreamteam/alter/domain/posting/port/inbound/AdminCreatePostingKeywordUseCase.java @@ -1,8 +1,9 @@ package com.dreamteam.alter.domain.posting.port.inbound; import com.dreamteam.alter.domain.posting.command.AdminCreatePostingKeywordCommand; +import com.dreamteam.alter.domain.posting.entity.PostingKeyword; public interface AdminCreatePostingKeywordUseCase { - Long execute(AdminCreatePostingKeywordCommand command); + PostingKeyword execute(AdminCreatePostingKeywordCommand command); } From cdca4df98e6da7f8c74b3bbdff13fe30c297df1c Mon Sep 17 00:00:00 2001 From: Seungwan Yoo Date: Sat, 11 Jul 2026 21:43:21 +0900 Subject: [PATCH 7/9] =?UTF-8?q?fix:=20[alt-267]=20=EA=B3=B5=EA=B3=A0=20?= =?UTF-8?q?=EC=97=85=EC=A2=85=20=ED=95=84=ED=84=B0=20=EC=A7=81=EC=A0=91?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20=EB=9D=BC=EB=B2=A8=20LIKE=20=EC=99=80?= =?UTF-8?q?=EC=9D=BC=EB=93=9C=EC=B9=B4=EB=93=9C=20=EC=9D=B4=EC=8A=A4?= =?UTF-8?q?=EC=BC=80=EC=9D=B4=ED=94=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../persistence/PostingQueryRepositoryImpl.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/PostingQueryRepositoryImpl.java b/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/PostingQueryRepositoryImpl.java index d2a9bf64..a4db9db6 100644 --- a/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/PostingQueryRepositoryImpl.java +++ b/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/PostingQueryRepositoryImpl.java @@ -596,15 +596,21 @@ private BooleanExpression containsSearchKeyword(QPosting qPosting, QWorkspace qW /** * 업종 필터: 입력 텍스트가 (마스터 연결 업종명 OR 직접입력 업종 라벨)에 부분일치. - * 직접입력 라벨은 jsonb(custom_keywords)라 text 캐스팅 후 ilike로 매칭한다. + * 직접입력 라벨은 jsonb(custom_keywords)라 text 캐스팅(HQL string → PostgreSQL text) 후 ilike로 매칭한다. + * LIKE 와일드카드(%, _)는 '!'로 이스케이프해 마스터 매칭(containsIgnoreCase)과 동일하게 부분일치만 허용한다. */ private BooleanExpression keywordMatches(QPostingKeyword qPostingKeyword, QPosting qPosting, String keyword) { if (ObjectUtils.isEmpty(keyword) || keyword.isBlank()) { return null; } BooleanExpression masterMatch = qPostingKeyword.name.containsIgnoreCase(keyword); + + String escaped = keyword + .replace("!", "!!") + .replace("%", "!%") + .replace("_", "!_"); BooleanExpression customMatch = Expressions.booleanTemplate( - "cast({0} as string) ilike {1}", qPosting.customKeywords, "%" + keyword + "%" + "cast({0} as string) ilike {1} escape '!'", qPosting.customKeywords, "%" + escaped + "%" ); return masterMatch.or(customMatch); } From 172b0586d001279dbe26c20a2966ee3e3638c087 Mon Sep 17 00:00:00 2001 From: Seungwan Yoo Date: Sat, 11 Jul 2026 21:43:21 +0900 Subject: [PATCH 8/9] =?UTF-8?q?refactor:=20[alt-267]=20=EA=B3=B5=EA=B3=A0?= =?UTF-8?q?=20=ED=82=A4=EC=9B=8C=EB=93=9C=20=EC=A1=B0=ED=9A=8C=C2=B7?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20=EA=B3=B5=EC=9A=A9=20?= =?UTF-8?q?=EB=A6=AC=EC=A1=B8=EB=B2=84=EB=A1=9C=20=EC=B6=94=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../posting/usecase/CreatePosting.java | 12 ++----- .../posting/usecase/ManagerUpdatePosting.java | 12 ++----- .../usecase/PostingKeywordResolver.java | 36 +++++++++++++++++++ 3 files changed, 40 insertions(+), 20 deletions(-) create mode 100644 src/main/java/com/dreamteam/alter/application/posting/usecase/PostingKeywordResolver.java diff --git a/src/main/java/com/dreamteam/alter/application/posting/usecase/CreatePosting.java b/src/main/java/com/dreamteam/alter/application/posting/usecase/CreatePosting.java index e5227c07..82336a0c 100644 --- a/src/main/java/com/dreamteam/alter/application/posting/usecase/CreatePosting.java +++ b/src/main/java/com/dreamteam/alter/application/posting/usecase/CreatePosting.java @@ -2,7 +2,6 @@ import java.util.List; -import org.apache.commons.lang3.ObjectUtils; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -12,7 +11,6 @@ import com.dreamteam.alter.domain.posting.entity.Posting; import com.dreamteam.alter.domain.posting.entity.PostingKeyword; import com.dreamteam.alter.domain.posting.port.inbound.CreatePostingUseCase; -import com.dreamteam.alter.domain.posting.port.outbound.PostingKeywordQueryRepository; import com.dreamteam.alter.domain.posting.port.outbound.PostingRepository; import com.dreamteam.alter.domain.workspace.entity.Workspace; import com.dreamteam.alter.domain.workspace.port.outbound.WorkspaceQueryRepository; @@ -25,18 +23,12 @@ public class CreatePosting implements CreatePostingUseCase { private final PostingRepository postingRepository; - private final PostingKeywordQueryRepository postingKeywordQueryRepository; + private final PostingKeywordResolver postingKeywordResolver; private final WorkspaceQueryRepository workspaceQueryRepository; @Override public void execute(CreatePostingRequestDto request) { - List postingKeywords = ObjectUtils.isEmpty(request.getKeywords()) - ? List.of() - : postingKeywordQueryRepository.findByIds(request.getKeywords()); - if (ObjectUtils.isNotEmpty(request.getKeywords()) - && postingKeywords.size() != request.getKeywords().size()) { - throw new CustomException(ErrorCode.INVALID_KEYWORD); - } + List postingKeywords = postingKeywordResolver.resolveAndValidate(request.getKeywords()); Workspace workspace = workspaceQueryRepository.findById(request.getWorkspaceId()) .orElseThrow(() -> new CustomException(ErrorCode.WORKSPACE_NOT_FOUND)); diff --git a/src/main/java/com/dreamteam/alter/application/posting/usecase/ManagerUpdatePosting.java b/src/main/java/com/dreamteam/alter/application/posting/usecase/ManagerUpdatePosting.java index 9c4d3350..60bf3ef2 100644 --- a/src/main/java/com/dreamteam/alter/application/posting/usecase/ManagerUpdatePosting.java +++ b/src/main/java/com/dreamteam/alter/application/posting/usecase/ManagerUpdatePosting.java @@ -2,7 +2,6 @@ import java.util.List; -import org.apache.commons.lang3.ObjectUtils; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -12,7 +11,6 @@ import com.dreamteam.alter.domain.posting.entity.Posting; import com.dreamteam.alter.domain.posting.entity.PostingKeyword; import com.dreamteam.alter.domain.posting.port.inbound.ManagerUpdatePostingUseCase; -import com.dreamteam.alter.domain.posting.port.outbound.PostingKeywordQueryRepository; import com.dreamteam.alter.domain.posting.port.outbound.PostingQueryRepository; import com.dreamteam.alter.domain.user.context.ManagerActor; import com.dreamteam.alter.domain.user.entity.ManagerUser; @@ -25,7 +23,7 @@ public class ManagerUpdatePosting implements ManagerUpdatePostingUseCase { private final PostingQueryRepository postingQueryRepository; - private final PostingKeywordQueryRepository postingKeywordQueryRepository; + private final PostingKeywordResolver postingKeywordResolver; @Override public void execute(Long postingId, UpdatePostingRequestDto request, ManagerActor actor) { @@ -34,13 +32,7 @@ public void execute(Long postingId, UpdatePostingRequestDto request, ManagerActo Posting posting = postingQueryRepository.findByManagerAndId(postingId, managerUser) .orElseThrow(() -> new CustomException(ErrorCode.POSTING_NOT_FOUND)); - List postingKeywords = ObjectUtils.isEmpty(request.getKeywords()) - ? List.of() - : postingKeywordQueryRepository.findByIds(request.getKeywords()); - if (ObjectUtils.isNotEmpty(request.getKeywords()) - && postingKeywords.size() != request.getKeywords().size()) { - throw new CustomException(ErrorCode.INVALID_KEYWORD); - } + List postingKeywords = postingKeywordResolver.resolveAndValidate(request.getKeywords()); posting.updateContent( request.getTitle(), diff --git a/src/main/java/com/dreamteam/alter/application/posting/usecase/PostingKeywordResolver.java b/src/main/java/com/dreamteam/alter/application/posting/usecase/PostingKeywordResolver.java new file mode 100644 index 00000000..effc9579 --- /dev/null +++ b/src/main/java/com/dreamteam/alter/application/posting/usecase/PostingKeywordResolver.java @@ -0,0 +1,36 @@ +package com.dreamteam.alter.application.posting.usecase; + +import com.dreamteam.alter.common.exception.CustomException; +import com.dreamteam.alter.common.exception.ErrorCode; +import com.dreamteam.alter.domain.posting.entity.PostingKeyword; +import com.dreamteam.alter.domain.posting.port.outbound.PostingKeywordQueryRepository; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.ObjectUtils; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@RequiredArgsConstructor +public class PostingKeywordResolver { + + private final PostingKeywordQueryRepository postingKeywordQueryRepository; + + /** + * 마스터 업종 ID 목록을 조회·검증한다. + * 비어 있으면 빈 리스트를 반환하고, 조회 결과 수가 요청 수와 다르면 존재하지 않는 ID가 있는 것으로 보아 예외. + * @param keywordIds 마스터 업종 ID 목록 (nullable) + * @return 검증된 PostingKeyword 목록 + */ + public List resolveAndValidate(List keywordIds) { + if (ObjectUtils.isEmpty(keywordIds)) { + return List.of(); + } + + List keywords = postingKeywordQueryRepository.findByIds(keywordIds); + if (keywords.size() != keywordIds.size()) { + throw new CustomException(ErrorCode.INVALID_KEYWORD); + } + return keywords; + } +} From a5715a4e7b5109a974ab1d166205b55c4ad98c99 Mon Sep 17 00:00:00 2001 From: Seungwan Yoo Date: Sat, 11 Jul 2026 21:43:21 +0900 Subject: [PATCH 9/9] =?UTF-8?q?refactor:=20[alt-267]=20=EA=B3=B5=EA=B3=A0?= =?UTF-8?q?=20=EC=A7=81=EC=A0=91=EC=9E=85=EB=A0=A5=20=EC=97=85=EC=A2=85=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EC=8B=9C=20=EB=B0=A9=EC=96=B4=EC=A0=81=20?= =?UTF-8?q?=EB=B3=B5=EC=82=AC=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/dreamteam/alter/domain/posting/entity/Posting.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/dreamteam/alter/domain/posting/entity/Posting.java b/src/main/java/com/dreamteam/alter/domain/posting/entity/Posting.java index 35f66f5a..fb2ed53c 100644 --- a/src/main/java/com/dreamteam/alter/domain/posting/entity/Posting.java +++ b/src/main/java/com/dreamteam/alter/domain/posting/entity/Posting.java @@ -93,7 +93,7 @@ public static Posting create(CreatePostingRequestDto request, Workspace workspac .toList(); posting.customKeywords = ObjectUtils.isNotEmpty(request.getCustomKeywords()) - ? request.getCustomKeywords() + ? new ArrayList<>(request.getCustomKeywords()) : new ArrayList<>(); if (ObjectUtils.isNotEmpty(request.getSchedules())) { @@ -132,7 +132,7 @@ public void updateContent( this.description = description; this.payAmount = payAmount; this.paymentType = paymentType; - this.customKeywords = ObjectUtils.isNotEmpty(customKeywords) ? customKeywords : new ArrayList<>(); + this.customKeywords = ObjectUtils.isNotEmpty(customKeywords) ? new ArrayList<>(customKeywords) : new ArrayList<>(); // 키워드 업데이트 updateKeyword(postingKeywords);