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..1a9f57cd --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/controller/AdminPostingKeywordController.java @@ -0,0 +1,84 @@ +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.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; +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 + ) { + PostingKeyword created = adminCreatePostingKeyword.execute( + new AdminCreatePostingKeywordCommand(request.getName(), request.getDescription()) + ); + return ResponseEntity.status(HttpStatus.CREATED) + .body(CommonApiResponse.of(AdminPostingKeywordResponseDto.from(created))); + } + + @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..344faaad --- /dev/null +++ b/src/main/java/com/dreamteam/alter/adapter/inbound/admin/posting/dto/AdminPostingKeywordResponseDto.java @@ -0,0 +1,34 @@ +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(); + } +} 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/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/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/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/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/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/adapter/outbound/posting/persistence/PostingQueryRepositoryImpl.java b/src/main/java/com/dreamteam/alter/adapter/outbound/posting/persistence/PostingQueryRepositoryImpl.java index 309fde81..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 @@ -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/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 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/application/posting/usecase/CreatePosting.java b/src/main/java/com/dreamteam/alter/application/posting/usecase/CreatePosting.java index d299b006..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 @@ -11,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; @@ -24,15 +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 = postingKeywordQueryRepository.findByIds(request.getKeywords()); - if (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/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, "모집이 종료된 공고입니다."); } 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() ); } } 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..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 @@ -11,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; @@ -24,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) { @@ -33,10 +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 = postingKeywordQueryRepository.findByIds(request.getKeywords()); - if (postingKeywords.size() != request.getKeywords().size()) { - throw new CustomException(ErrorCode.INVALID_KEYWORD); - } + List postingKeywords = postingKeywordResolver.resolveAndValidate(request.getKeywords()); posting.updateContent( request.getTitle(), @@ -44,6 +40,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/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; + } +} 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/Posting.java b/src/main/java/com/dreamteam/alter/domain/posting/entity/Posting.java index 0f2bc054..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 @@ -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()) + ? new ArrayList<>(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) ? new ArrayList<>(customKeywords) : new ArrayList<>(); // 키워드 업데이트 updateKeyword(postingKeywords); 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..209c3a2d --- /dev/null +++ b/src/main/java/com/dreamteam/alter/domain/posting/port/inbound/AdminCreatePostingKeywordUseCase.java @@ -0,0 +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 { + + PostingKeyword 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/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; 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;