diff --git a/src/main/java/starlight/adapter/backoffice/expert/webapi/BackofficeExpertController.java b/src/main/java/starlight/adapter/backoffice/expert/webapi/BackofficeExpertController.java new file mode 100644 index 00000000..da233e15 --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/expert/webapi/BackofficeExpertController.java @@ -0,0 +1,104 @@ +package starlight.adapter.backoffice.expert.webapi; + +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import starlight.adapter.backoffice.expert.webapi.dto.request.BackofficeExpertActiveStatusUpdateRequest; +import starlight.adapter.backoffice.expert.webapi.dto.request.BackofficeExpertCreateRequest; +import starlight.adapter.backoffice.expert.webapi.dto.request.BackofficeExpertProfileImageUpdateRequest; +import starlight.adapter.backoffice.expert.webapi.dto.request.BackofficeExpertUpdateRequest; +import starlight.adapter.backoffice.expert.webapi.dto.response.BackofficeExpertCreateResponse; +import starlight.adapter.backoffice.expert.webapi.dto.response.BackofficeExpertDetailResponse; +import starlight.adapter.backoffice.expert.webapi.dto.response.BackofficeExpertListResponse; +import starlight.adapter.backoffice.expert.webapi.swagger.BackofficeExpertApiDoc; +import starlight.application.backoffice.expert.provided.BackofficeExpertCommandUseCase; +import starlight.application.backoffice.expert.provided.BackofficeExpertQueryUseCase; +import starlight.application.backoffice.expert.provided.dto.input.BackofficeExpertActiveStatusUpdateInput; +import starlight.application.backoffice.expert.provided.dto.input.BackofficeExpertProfileImageUpdateInput; +import starlight.shared.apiPayload.response.ApiResponse; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@SecurityRequirement(name = "backofficeSession") +@RequestMapping("/v1/backoffice/experts") +public class BackofficeExpertController implements BackofficeExpertApiDoc { + + private final BackofficeExpertQueryUseCase backofficeExpertQuery; + private final BackofficeExpertCommandUseCase backofficeExpertCommand; + + @GetMapping + public ApiResponse> searchAll() { + return ApiResponse.success(BackofficeExpertListResponse.fromAll( + backofficeExpertQuery.searchAll() + )); + } + + @PostMapping + public ApiResponse create( + @Valid @RequestBody BackofficeExpertCreateRequest request + ) { + return ApiResponse.success(BackofficeExpertCreateResponse.from( + backofficeExpertCommand.createExpert(request.toInput()) + )); + } + + @GetMapping("/{expertId}") + public ApiResponse detail( + @PathVariable Long expertId + ) { + return ApiResponse.success(BackofficeExpertDetailResponse.from( + backofficeExpertQuery.findById(expertId) + )); + } + + @PatchMapping("/{expertId}/active-status") + public ApiResponse updateActiveStatus( + @PathVariable Long expertId, + @Valid @RequestBody BackofficeExpertActiveStatusUpdateRequest request + ) { + backofficeExpertCommand.updateActiveStatus( + BackofficeExpertActiveStatusUpdateInput.of(expertId, request.activeStatus()) + ); + + return ApiResponse.success(); + } + + @PatchMapping("/{expertId}") + public ApiResponse update( + @PathVariable Long expertId, + @Valid @RequestBody BackofficeExpertUpdateRequest request + ) { + backofficeExpertCommand.updateExpert(request.toInput(expertId)); + return ApiResponse.success(); + } + + @DeleteMapping("/{expertId}") + public ApiResponse delete( + @PathVariable Long expertId + ) { + backofficeExpertCommand.deleteExpert(expertId); + return ApiResponse.success(); + } + + @PatchMapping("/{expertId}/profile-image") + public ApiResponse updateProfileImage( + @PathVariable Long expertId, + @Valid @RequestBody BackofficeExpertProfileImageUpdateRequest request + ) { + backofficeExpertCommand.updateProfileImage( + BackofficeExpertProfileImageUpdateInput.of(expertId, request.profileImageUrl()) + ); + return ApiResponse.success(); + } + +} diff --git a/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertActiveStatusUpdateRequest.java b/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertActiveStatusUpdateRequest.java new file mode 100644 index 00000000..87518508 --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertActiveStatusUpdateRequest.java @@ -0,0 +1,8 @@ +package starlight.adapter.backoffice.expert.webapi.dto.request; + +import jakarta.validation.constraints.NotNull; +import starlight.domain.expert.enumerate.ExpertActiveStatus; + +public record BackofficeExpertActiveStatusUpdateRequest( + @NotNull ExpertActiveStatus activeStatus +) { } diff --git a/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertCareerUpdateRequest.java b/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertCareerUpdateRequest.java new file mode 100644 index 00000000..15d1c113 --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertCareerUpdateRequest.java @@ -0,0 +1,25 @@ +package starlight.adapter.backoffice.expert.webapi.dto.request; + +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import java.time.LocalDateTime; + +public record BackofficeExpertCareerUpdateRequest( + Long id, + @NotNull @Min(0) Integer orderIndex, + @NotBlank String careerTitle, + String careerExplanation, + @NotNull LocalDateTime careerStartedAt, + @NotNull LocalDateTime careerEndedAt +) { + @AssertTrue(message = "경력 시작일은 종료일보다 늦을 수 없습니다.") + public boolean isValidPeriod() { + if (careerStartedAt == null || careerEndedAt == null) { + return true; + } + return !careerStartedAt.isAfter(careerEndedAt); + } +} diff --git a/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertCreateRequest.java b/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertCreateRequest.java new file mode 100644 index 00000000..90619cfe --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertCreateRequest.java @@ -0,0 +1,26 @@ +package starlight.adapter.backoffice.expert.webapi.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import starlight.application.backoffice.expert.provided.dto.input.BackofficeExpertCreateInput; +import starlight.domain.expert.enumerate.TagCategory; + +import java.util.List; + +public record BackofficeExpertCreateRequest( + @NotBlank String name, + @Email @NotBlank String email, + String oneLineIntroduction, + List tags, + List categories +) { + public BackofficeExpertCreateInput toInput() { + return BackofficeExpertCreateInput.of( + name, + email, + oneLineIntroduction, + tags, + categories + ); + } +} diff --git a/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertProfileImageUpdateRequest.java b/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertProfileImageUpdateRequest.java new file mode 100644 index 00000000..5cfdce32 --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertProfileImageUpdateRequest.java @@ -0,0 +1,7 @@ +package starlight.adapter.backoffice.expert.webapi.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record BackofficeExpertProfileImageUpdateRequest( + @NotBlank String profileImageUrl +) { } diff --git a/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertUpdateRequest.java b/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertUpdateRequest.java new file mode 100644 index 00000000..f8df7524 --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertUpdateRequest.java @@ -0,0 +1,50 @@ +package starlight.adapter.backoffice.expert.webapi.dto.request; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import starlight.application.backoffice.expert.provided.dto.input.BackofficeExpertCareerUpdateInput; +import starlight.application.backoffice.expert.provided.dto.input.BackofficeExpertUpdateInput; +import starlight.domain.expert.enumerate.TagCategory; + +import java.util.List; + +public record BackofficeExpertUpdateRequest( + @NotBlank String name, + @Email @NotBlank String email, + String oneLineIntroduction, + String detailedIntroduction, + Long workedPeriod, + Integer mentoringPriceWon, + List tags, + List categories, + @Valid List careers +) { + public BackofficeExpertUpdateInput toInput(Long expertId) { + List careerInputs = careers == null + ? null + : careers.stream() + .map(career -> new BackofficeExpertCareerUpdateInput( + career.id(), + career.orderIndex(), + career.careerTitle(), + career.careerExplanation(), + career.careerStartedAt(), + career.careerEndedAt() + )) + .toList(); + + return BackofficeExpertUpdateInput.of( + expertId, + name, + email, + oneLineIntroduction, + detailedIntroduction, + workedPeriod, + mentoringPriceWon, + tags, + categories, + careerInputs + ); + } +} diff --git a/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/response/BackofficeExpertCareerResponse.java b/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/response/BackofficeExpertCareerResponse.java new file mode 100644 index 00000000..3086dafe --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/response/BackofficeExpertCareerResponse.java @@ -0,0 +1,25 @@ +package starlight.adapter.backoffice.expert.webapi.dto.response; + +import starlight.application.expert.provided.dto.ExpertCareerResult; + +import java.time.LocalDateTime; + +public record BackofficeExpertCareerResponse( + Long id, + Integer orderIndex, + String careerTitle, + String careerExplanation, + LocalDateTime careerStartedAt, + LocalDateTime careerEndedAt +) { + public static BackofficeExpertCareerResponse from(ExpertCareerResult result) { + return new BackofficeExpertCareerResponse( + result.id(), + result.orderIndex(), + result.careerTitle(), + result.careerExplanation(), + result.careerStartedAt(), + result.careerEndedAt() + ); + } +} diff --git a/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/response/BackofficeExpertCreateResponse.java b/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/response/BackofficeExpertCreateResponse.java new file mode 100644 index 00000000..b507bb51 --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/response/BackofficeExpertCreateResponse.java @@ -0,0 +1,11 @@ +package starlight.adapter.backoffice.expert.webapi.dto.response; + +import starlight.application.backoffice.expert.provided.dto.result.BackofficeExpertCreateResult; + +public record BackofficeExpertCreateResponse( + Long id +) { + public static BackofficeExpertCreateResponse from(BackofficeExpertCreateResult result) { + return new BackofficeExpertCreateResponse(result.id()); + } +} diff --git a/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/response/BackofficeExpertDetailResponse.java b/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/response/BackofficeExpertDetailResponse.java new file mode 100644 index 00000000..620ca93a --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/response/BackofficeExpertDetailResponse.java @@ -0,0 +1,43 @@ +package starlight.adapter.backoffice.expert.webapi.dto.response; + +import starlight.application.backoffice.expert.provided.dto.result.BackofficeExpertDetailResult; + +import java.util.List; + +public record BackofficeExpertDetailResponse( + Long id, + Long applicationCount, + String name, + String oneLineIntroduction, + String detailedIntroduction, + String profileImageUrl, + Long workedPeriod, + String email, + Integer mentoringPriceWon, + String activeStatus, + List careers, + List tags, + List categories +) { + public static BackofficeExpertDetailResponse from(BackofficeExpertDetailResult result) { + List careers = result.careers().stream() + .map(BackofficeExpertCareerResponse::from) + .toList(); + + return new BackofficeExpertDetailResponse( + result.id(), + result.applicationCount(), + result.name(), + result.oneLineIntroduction(), + result.detailedIntroduction(), + result.profileImageUrl(), + result.workedPeriod(), + result.email(), + result.mentoringPriceWon(), + result.activeStatus().name(), + careers, + result.tags(), + result.categories() + ); + } +} diff --git a/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/response/BackofficeExpertListResponse.java b/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/response/BackofficeExpertListResponse.java new file mode 100644 index 00000000..b9e3a18e --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/response/BackofficeExpertListResponse.java @@ -0,0 +1,59 @@ +package starlight.adapter.backoffice.expert.webapi.dto.response; + +import starlight.application.backoffice.expert.provided.dto.result.BackofficeExpertDetailResult; +import starlight.application.expert.provided.dto.ExpertCareerResult; + +import java.util.List; + +public record BackofficeExpertListResponse( + Long id, + String name, + String oneLineIntroduction, + String profileImageUrl, + Long workedPeriod, + String email, + String activeStatus, + List careers, + List tags, + List categories +) { + private static final int MAX_CAREERS = 3; + + public static BackofficeExpertListResponse from(BackofficeExpertDetailResult result) { + List careers = result.careers().stream() + .limit(MAX_CAREERS) + .map(BackofficeExpertCareerSummaryResponse::from) + .toList(); + + return new BackofficeExpertListResponse( + result.id(), + result.name(), + result.oneLineIntroduction(), + result.profileImageUrl(), + result.workedPeriod(), + result.email(), + result.activeStatus().name(), + careers, + result.tags(), + result.categories() + ); + } + + public static List fromAll(List results) { + return results.stream() + .map(BackofficeExpertListResponse::from) + .toList(); + } + + public record BackofficeExpertCareerSummaryResponse( + Integer orderIndex, + String careerTitle + ) { + public static BackofficeExpertCareerSummaryResponse from(ExpertCareerResult result) { + return new BackofficeExpertCareerSummaryResponse( + result.orderIndex(), + result.careerTitle() + ); + } + } +} diff --git a/src/main/java/starlight/adapter/backoffice/expert/webapi/swagger/BackofficeExpertApiDoc.java b/src/main/java/starlight/adapter/backoffice/expert/webapi/swagger/BackofficeExpertApiDoc.java new file mode 100644 index 00000000..dd0310e9 --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/expert/webapi/swagger/BackofficeExpertApiDoc.java @@ -0,0 +1,384 @@ +package starlight.adapter.backoffice.expert.webapi.swagger; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import starlight.adapter.backoffice.expert.webapi.dto.request.BackofficeExpertActiveStatusUpdateRequest; +import starlight.adapter.backoffice.expert.webapi.dto.request.BackofficeExpertCreateRequest; +import starlight.adapter.backoffice.expert.webapi.dto.request.BackofficeExpertProfileImageUpdateRequest; +import starlight.adapter.backoffice.expert.webapi.dto.request.BackofficeExpertUpdateRequest; +import starlight.adapter.backoffice.expert.webapi.dto.response.BackofficeExpertCreateResponse; +import starlight.adapter.backoffice.expert.webapi.dto.response.BackofficeExpertDetailResponse; +import starlight.adapter.backoffice.expert.webapi.dto.response.BackofficeExpertListResponse; +import starlight.shared.apiPayload.response.ApiResponse; + +import java.util.List; + +@Tag(name = "[Office] 전문가", description = "백오피스 전문가 관리 API") +public interface BackofficeExpertApiDoc { + + @Operation( + summary = "전문가 목록 조회(백오피스)", + security = @SecurityRequirement(name = "backofficeSession") + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공", + content = @Content( + array = @ArraySchema(schema = @Schema(implementation = BackofficeExpertListResponse.class)) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "500", + description = "조회 오류", + content = @Content(examples = { + @ExampleObject( + name = "전문가 조회 오류", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "EXPERT_QUERY_ERROR", + "message": "전문가 정보를 조회하는 중에 오류가 발생했습니다." + } + } + """ + ), + @ExampleObject( + name = "신청 건수 조회 오류", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "EXPERT_APPLICATION_QUERY_ERROR", + "message": "전문가 신청 정보를 조회하는 중에 오류가 발생했습니다." + } + } + """ + ) + }) + ) + }) + @GetMapping + ApiResponse> searchAll(); + + @Operation( + summary = "전문가 생성(백오피스)", + security = @SecurityRequirement(name = "backofficeSession") + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공", + content = @Content(schema = @Schema(implementation = BackofficeExpertCreateResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "요청 값 오류", + content = @Content(examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "INVALID_REQUEST_ARGUMENT", + "message": "잘못된 요청 인자입니다." + } + } + """ + )) + ) + }) + @PostMapping + ApiResponse create( + @RequestBody BackofficeExpertCreateRequest request + ); + + @Operation( + summary = "전문가 상세 조회(백오피스)", + security = @SecurityRequirement(name = "backofficeSession") + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공", + content = @Content(schema = @Schema(implementation = BackofficeExpertDetailResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "전문가 조회 실패", + content = @Content(examples = @ExampleObject( + name = "전문가 없음", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "EXPERT_NOT_FOUND", + "message": "해당 전문가를 찾을 수 없습니다." + } + } + """ + )) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "500", + description = "조회 오류", + content = @Content(examples = { + @ExampleObject( + name = "전문가 조회 오류", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "EXPERT_QUERY_ERROR", + "message": "전문가 정보를 조회하는 중에 오류가 발생했습니다." + } + } + """ + ), + @ExampleObject( + name = "신청 건수 조회 오류", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "EXPERT_APPLICATION_QUERY_ERROR", + "message": "전문가 신청 정보를 조회하는 중에 오류가 발생했습니다." + } + } + """ + ) + }) + ) + }) + @GetMapping("/{expertId}") + ApiResponse detail( + @PathVariable Long expertId + ); + + @Operation( + summary = "전문가 상세 수정(백오피스)", + security = @SecurityRequirement(name = "backofficeSession") + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "요청 값 오류", + content = @Content(examples = { + @ExampleObject( + name = "요청 값 오류", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "INVALID_REQUEST_ARGUMENT", + "message": "잘못된 요청 인자입니다." + } + } + """ + ), + @ExampleObject( + name = "경력 정보 오류", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "EXPERT_CAREER_INVALID", + "message": "경력 정보가 올바르지 않습니다." + } + } + """ + ) + }) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "전문가 조회 실패", + content = @Content(examples = @ExampleObject( + name = "전문가 없음", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "EXPERT_NOT_FOUND", + "message": "해당 전문가를 찾을 수 없습니다." + } + } + """ + )) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "500", + description = "조회 오류", + content = @Content(examples = @ExampleObject( + name = "전문가 조회 오류", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "EXPERT_QUERY_ERROR", + "message": "전문가 정보를 조회하는 중에 오류가 발생했습니다." + } + } + """ + )) + ) + }) + @PatchMapping("/{expertId}") + ApiResponse update( + @PathVariable Long expertId, + @RequestBody BackofficeExpertUpdateRequest request + ); + + @Operation( + summary = "전문가 활성 상태 변경(백오피스)", + security = @SecurityRequirement(name = "backofficeSession") + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "요청 값 오류", + content = @Content(examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "INVALID_REQUEST_ARGUMENT", + "message": "잘못된 요청 인자입니다." + } + } + """ + )) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "전문가 조회 실패", + content = @Content(examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "EXPERT_NOT_FOUND", + "message": "해당 전문가를 찾을 수 없습니다." + } + } + """ + )) + ) + }) + @PatchMapping("/{expertId}/active-status") + ApiResponse updateActiveStatus( + @PathVariable Long expertId, + @RequestBody BackofficeExpertActiveStatusUpdateRequest request + ); + + @Operation( + summary = "전문가 프로필 이미지 변경(백오피스)", + security = @SecurityRequirement(name = "backofficeSession") + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "요청 값 오류", + content = @Content(examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "INVALID_REQUEST_ARGUMENT", + "message": "잘못된 요청 인자입니다." + } + } + """ + )) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "전문가 조회 실패", + content = @Content(examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "EXPERT_NOT_FOUND", + "message": "해당 전문가를 찾을 수 없습니다." + } + } + """ + )) + ) + }) + @PatchMapping("/{expertId}/profile-image") + ApiResponse updateProfileImage( + @PathVariable Long expertId, + @RequestBody BackofficeExpertProfileImageUpdateRequest request + ); + + @Operation( + summary = "전문가 삭제(백오피스)", + security = @SecurityRequirement(name = "backofficeSession") + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "전문가 조회 실패", + content = @Content(examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "EXPERT_NOT_FOUND", + "message": "해당 전문가를 찾을 수 없습니다." + } + } + """ + )) + ) + }) + @DeleteMapping("/{expertId}") + ApiResponse delete( + @PathVariable Long expertId + ); +} diff --git a/src/main/java/starlight/adapter/backoffice/image/webapi/BackofficeImageController.java b/src/main/java/starlight/adapter/backoffice/image/webapi/BackofficeImageController.java new file mode 100644 index 00000000..56ff7b47 --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/image/webapi/BackofficeImageController.java @@ -0,0 +1,45 @@ +package starlight.adapter.backoffice.image.webapi; + +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import starlight.adapter.backoffice.image.webapi.dto.request.BackofficeImagePublicRequest; +import starlight.adapter.backoffice.image.webapi.swagger.BackofficeImageApiDoc; +import starlight.adapter.backoffice.image.webapi.validation.ValidImageFileName; +import starlight.application.aireport.required.PresignedUrlProviderPort; +import starlight.shared.apiPayload.response.ApiResponse; +import starlight.shared.dto.infrastructure.PreSignedUrlResponse; + +@Validated +@RestController +@RequiredArgsConstructor +@SecurityRequirement(name = "backofficeSession") +@RequestMapping("/v1/backoffice/images") +public class BackofficeImageController implements BackofficeImageApiDoc { + + private static final long BACKOFFICE_USER_ID = 0L; + + private final PresignedUrlProviderPort presignedUrlProvider; + + @GetMapping(value = "/upload-url", produces = MediaType.APPLICATION_JSON_VALUE) + public ApiResponse getPresignedUrl( + @RequestParam @ValidImageFileName String fileName + ) { + return ApiResponse.success(presignedUrlProvider.getPreSignedUrl(BACKOFFICE_USER_ID, fileName)); + } + + @PostMapping(value = "/upload-url/public", consumes = MediaType.APPLICATION_JSON_VALUE) + public ApiResponse finalizePublic( + @Valid @RequestBody BackofficeImagePublicRequest request + ) { + return ApiResponse.success(presignedUrlProvider.makePublic(request.objectUrl())); + } +} diff --git a/src/main/java/starlight/adapter/backoffice/image/webapi/dto/request/BackofficeImagePublicRequest.java b/src/main/java/starlight/adapter/backoffice/image/webapi/dto/request/BackofficeImagePublicRequest.java new file mode 100644 index 00000000..fb96bf5e --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/image/webapi/dto/request/BackofficeImagePublicRequest.java @@ -0,0 +1,7 @@ +package starlight.adapter.backoffice.image.webapi.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record BackofficeImagePublicRequest( + @NotBlank String objectUrl +) { } diff --git a/src/main/java/starlight/adapter/backoffice/image/webapi/swagger/BackofficeImageApiDoc.java b/src/main/java/starlight/adapter/backoffice/image/webapi/swagger/BackofficeImageApiDoc.java new file mode 100644 index 00000000..32c220d8 --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/image/webapi/swagger/BackofficeImageApiDoc.java @@ -0,0 +1,166 @@ +package starlight.adapter.backoffice.image.webapi.swagger; + +import jakarta.validation.Valid; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import starlight.adapter.backoffice.image.webapi.dto.request.BackofficeImagePublicRequest; +import starlight.adapter.backoffice.image.webapi.validation.ValidImageFileName; +import starlight.shared.apiPayload.response.ApiResponse; +import starlight.shared.dto.infrastructure.PreSignedUrlResponse; + +@Tag(name = "[Office] 이미지", description = "백오피스 이미지 업로드 API") +public interface BackofficeImageApiDoc { + + @Operation( + summary = "Presigned URL 발급(백오피스)", + security = @SecurityRequirement(name = "backofficeSession") + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = ApiResponse.class) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "요청 값 오류", + content = @Content(examples = { + @ExampleObject( + name = "fileName 검증 실패", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "INVALID_REQUEST_ARGUMENT", + "message": "fileName이 올바르지 않습니다." + } + } + """ + ), + @ExampleObject( + name = "요청 값 오류", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "INVALID_REQUEST_ARGUMENT", + "message": "잘못된 요청 인자입니다." + } + } + """ + ) + }) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "500", + description = "Presigned URL 생성 실패", + content = @Content(examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "INTERNAL_ERROR", + "message": "알 수 없는 내부 오류입니다." + } + } + """ + )) + ) + }) + @GetMapping(value = "/v1/backoffice/images/upload-url", produces = MediaType.APPLICATION_JSON_VALUE) + ApiResponse getPresignedUrl( + @RequestParam @ValidImageFileName String fileName + ); + + @Operation( + summary = "이미지 공개 전환(백오피스)", + security = @SecurityRequirement(name = "backofficeSession") + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = ApiResponse.class), + examples = @ExampleObject( + value = """ + { + "result": "SUCCESS", + "data": "https://bucket.example.com/path/to/object.jpg", + "error": null + } + """ + ) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "요청 값 오류", + content = @Content(examples = { + @ExampleObject( + name = "요청 값 오류", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "INVALID_REQUEST_ARGUMENT", + "message": "잘못된 요청 인자입니다." + } + } + """ + ), + @ExampleObject( + name = "JSON 형식 오류", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "INVALID_REQUEST_ARGUMENT", + "message": "잘못된 JSON 형식입니다." + } + } + """ + ) + }) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "500", + description = "이미지 공개 처리 실패", + content = @Content(examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "INTERNAL_ERROR", + "message": "알 수 없는 내부 오류입니다." + } + } + """ + )) + ) + }) + @PostMapping(value = "/v1/backoffice/images/upload-url/public", consumes = MediaType.APPLICATION_JSON_VALUE) + ApiResponse finalizePublic( + @Valid @RequestBody BackofficeImagePublicRequest request + ); +} diff --git a/src/main/java/starlight/adapter/backoffice/image/webapi/validation/ValidImageFileName.java b/src/main/java/starlight/adapter/backoffice/image/webapi/validation/ValidImageFileName.java new file mode 100644 index 00000000..9727ada9 --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/image/webapi/validation/ValidImageFileName.java @@ -0,0 +1,23 @@ +package starlight.adapter.backoffice.image.webapi.validation; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Documented +@Constraint(validatedBy = ValidImageFileNameValidator.class) +@Target({ElementType.PARAMETER, ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ValidImageFileName { + + String message() default "fileName이 올바르지 않습니다."; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/src/main/java/starlight/adapter/backoffice/image/webapi/validation/ValidImageFileNameValidator.java b/src/main/java/starlight/adapter/backoffice/image/webapi/validation/ValidImageFileNameValidator.java new file mode 100644 index 00000000..a43ef885 --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/image/webapi/validation/ValidImageFileNameValidator.java @@ -0,0 +1,27 @@ +package starlight.adapter.backoffice.image.webapi.validation; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import org.springframework.util.StringUtils; + +import java.util.regex.Pattern; + +public class ValidImageFileNameValidator implements ConstraintValidator { + + private static final Pattern FILE_NAME_PATTERN = + Pattern.compile("^[A-Za-z0-9._-]+\\.(png|jpg|jpeg|webp)$", Pattern.CASE_INSENSITIVE); + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + + if (!StringUtils.hasText(value)) { + return false; + } + + if (value.contains("/") || value.contains("\\")) { + return false; + } + + return FILE_NAME_PATTERN.matcher(value).matches(); + } +} diff --git a/src/main/java/starlight/adapter/backoffice/mail/email/SmtpMailSender.java b/src/main/java/starlight/adapter/backoffice/mail/email/SmtpMailSender.java index c5c3d6f1..0eda1a13 100644 --- a/src/main/java/starlight/adapter/backoffice/mail/email/SmtpMailSender.java +++ b/src/main/java/starlight/adapter/backoffice/mail/email/SmtpMailSender.java @@ -39,9 +39,9 @@ public void send(BackofficeMailSendInput input, BackofficeMailContentType conten helper.setText(body, isHtml); javaMailSender.send(message); - log.info("[MAIL] sent to={} subject={}", input.to(), input.subject()); + log.info("[MAIL] sent recipients={} subject={}", input.to().size(), input.subject()); } catch (MessagingException e) { - log.error("[MAIL] send failed to={}", input.to(), e); + log.error("[MAIL] send failed recipients={}", input.to().size(), e); throw new BackofficeException(BackofficeErrorType.MAIL_SEND_FAILED, e); } } diff --git a/src/main/java/starlight/adapter/backoffice/mail/webapi/BackofficeMailController.java b/src/main/java/starlight/adapter/backoffice/mail/webapi/BackofficeMailController.java index f6492534..e017521b 100644 --- a/src/main/java/starlight/adapter/backoffice/mail/webapi/BackofficeMailController.java +++ b/src/main/java/starlight/adapter/backoffice/mail/webapi/BackofficeMailController.java @@ -1,11 +1,13 @@ package starlight.adapter.backoffice.mail.webapi; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; import starlight.adapter.backoffice.mail.webapi.dto.request.BackofficeMailSendRequest; import starlight.adapter.backoffice.mail.webapi.dto.request.BackofficeMailTemplateCreateRequest; import starlight.adapter.backoffice.mail.webapi.dto.response.BackofficeMailTemplateResponse; +import starlight.adapter.backoffice.mail.webapi.swagger.BackofficeMailApiDoc; import starlight.application.backoffice.mail.provided.BackofficeMailSendUseCase; import starlight.application.backoffice.mail.provided.BackofficeMailTemplateUseCase; import starlight.shared.apiPayload.response.ApiResponse; @@ -14,7 +16,8 @@ @RestController @RequiredArgsConstructor -public class BackofficeMailController { +@SecurityRequirement(name = "backofficeSession") +public class BackofficeMailController implements BackofficeMailApiDoc { private final BackofficeMailSendUseCase backofficeMailSendUseCase; private final BackofficeMailTemplateUseCase templateUseCase; diff --git a/src/main/java/starlight/adapter/backoffice/mail/webapi/swagger/BackofficeMailApiDoc.java b/src/main/java/starlight/adapter/backoffice/mail/webapi/swagger/BackofficeMailApiDoc.java new file mode 100644 index 00000000..766fe3aa --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/mail/webapi/swagger/BackofficeMailApiDoc.java @@ -0,0 +1,244 @@ +package starlight.adapter.backoffice.mail.webapi.swagger; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import starlight.adapter.backoffice.mail.webapi.dto.request.BackofficeMailSendRequest; +import starlight.adapter.backoffice.mail.webapi.dto.request.BackofficeMailTemplateCreateRequest; +import starlight.adapter.backoffice.mail.webapi.dto.response.BackofficeMailTemplateResponse; +import starlight.shared.apiPayload.response.ApiResponse; + +import java.util.List; + +@Tag(name = "[Office] 메일", description = "백오피스 메일 관리 API") +public interface BackofficeMailApiDoc { + + @Operation( + summary = "백오피스 메일 발송", + security = @SecurityRequirement(name = "backofficeSession") + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공", + content = @Content(examples = @ExampleObject( + value = """ + { + "result": "SUCCESS", + "data": "이메일 전송에 성공하였습니다.", + "error": null + } + """ + )) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "요청 값 오류", + content = @Content(examples = { + @ExampleObject( + name = "요청 값 오류", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "INVALID_REQUEST_ARGUMENT", + "message": "잘못된 요청 인자입니다." + } + } + """ + ), + @ExampleObject( + name = "contentType 오류", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "INVALID_MAIL_CONTENT_TYPE", + "message": "유효하지 않은 contentType입니다." + } + } + """ + ), + @ExampleObject( + name = "메일 요청 오류", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "INVALID_MAIL_REQUEST", + "message": "메일 발송 요청이 유효하지 않습니다." + } + } + """ + ) + }) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "500", + description = "메일 전송 실패", + content = @Content(examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "MAIL_SEND_FAILED", + "message": "메일 전송에 실패했습니다." + } + } + """ + )) + ) + }) + @PostMapping("/v1/backoffice/mail/send") + ApiResponse send( + @RequestBody BackofficeMailSendRequest request + ); + + @Operation( + summary = "메일 템플릿 생성", + security = @SecurityRequirement(name = "backofficeSession") + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공", + content = @Content(schema = @Schema(implementation = BackofficeMailTemplateResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "요청 값 오류", + content = @Content(examples = { + @ExampleObject( + name = "요청 값 오류", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "INVALID_REQUEST_ARGUMENT", + "message": "잘못된 요청 인자입니다." + } + } + """ + ), + @ExampleObject( + name = "contentType 오류", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "INVALID_MAIL_CONTENT_TYPE", + "message": "유효하지 않은 contentType입니다." + } + } + """ + ) + }) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "500", + description = "템플릿 저장 실패", + content = @Content(examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "MAIL_TEMPLATE_SAVE_FAILED", + "message": "메일 템플릿 저장에 실패했습니다." + } + } + """ + )) + ) + }) + @PostMapping("/v1/backoffice/mail/templates") + ApiResponse createTemplate( + @RequestBody BackofficeMailTemplateCreateRequest request + ); + + @Operation( + summary = "메일 템플릿 목록 조회", + security = @SecurityRequirement(name = "backofficeSession") + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = BackofficeMailTemplateResponse.class))) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "500", + description = "템플릿 조회 실패", + content = @Content(examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "MAIL_TEMPLATE_QUERY_FAILED", + "message": "메일 템플릿 조회에 실패했습니다." + } + } + """ + )) + ) + }) + @GetMapping("/v1/backoffice/mail/templates") + ApiResponse> findTemplates(); + + @Operation( + summary = "메일 템플릿 삭제", + security = @SecurityRequirement(name = "backofficeSession") + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공", + content = @Content(examples = @ExampleObject( + value = """ + { + "result": "SUCCESS", + "data": "템플릿이 삭제되었습니다.", + "error": null + } + """ + )) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "500", + description = "템플릿 삭제 실패", + content = @Content(examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "MAIL_TEMPLATE_DELETE_FAILED", + "message": "메일 템플릿 삭제에 실패했습니다." + } + } + """ + )) + ) + }) + @DeleteMapping("/v1/backoffice/mail/templates/{templateId}") + ApiResponse deleteTemplate( + @PathVariable Long templateId + ); +} diff --git a/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanQueryJpa.java b/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanQueryJpa.java index f5dd0d6b..09a5eeda 100644 --- a/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanQueryJpa.java +++ b/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanQueryJpa.java @@ -16,8 +16,8 @@ @RequiredArgsConstructor public class BusinessPlanQueryJpa implements BusinessPlanCommandPort, BusinessPlanQueryPort, starlight.application.expert.required.BusinessPlanQueryLookupPort, - starlight.application.aireport.required.BusinessPlanCommandLookUpPort, - starlight.application.aireport.required.BusinessPlanQueryLookUpPort { + starlight.application.aireport.required.BusinessPlanCommandLookupPort, + starlight.application.aireport.required.BusinessPlanQueryLookupPort { private final BusinessPlanRepository businessPlanRepository; diff --git a/src/main/java/starlight/adapter/expert/persistence/ExpertJpa.java b/src/main/java/starlight/adapter/expert/persistence/ExpertJpa.java index 92501813..519bb666 100644 --- a/src/main/java/starlight/adapter/expert/persistence/ExpertJpa.java +++ b/src/main/java/starlight/adapter/expert/persistence/ExpertJpa.java @@ -18,6 +18,8 @@ @Component @RequiredArgsConstructor public class ExpertJpa implements ExpertQueryPort, + starlight.application.backoffice.expert.required.BackofficeExpertQueryPort, + starlight.application.backoffice.expert.required.BackofficeExpertCommandPort, starlight.application.expertReport.required.ExpertLookupPort, starlight.application.expertApplication.required.ExpertLookupPort { @@ -30,6 +32,32 @@ public Expert findByIdOrThrow(Long id) { ); } + @Override + public Expert findByIdWithCareersTagsCategories(Long id) { + try { + List experts = fetchWithCollections(List.of(id)); + if (experts.isEmpty()) { + throw new ExpertException(ExpertErrorType.EXPERT_NOT_FOUND); + } + return experts.get(0); + } catch (ExpertException e) { + throw e; + } catch (Exception e) { + log.error("전문가 상세 조회 중 오류가 발생했습니다.", e); + throw new ExpertException(ExpertErrorType.EXPERT_QUERY_ERROR); + } + } + + @Override + public Expert save(Expert expert) { + return repository.save(expert); + } + + @Override + public void delete(Expert expert) { + repository.delete(expert); + } + @Override public Expert findByIdWithCareersAndTags(Long id) { try { diff --git a/src/main/java/starlight/adapter/expert/webapi/ExpertController.java b/src/main/java/starlight/adapter/expert/webapi/ExpertController.java index ce98ccd8..c92942a8 100644 --- a/src/main/java/starlight/adapter/expert/webapi/ExpertController.java +++ b/src/main/java/starlight/adapter/expert/webapi/ExpertController.java @@ -27,7 +27,7 @@ public class ExpertController implements ExpertApiDoc { @GetMapping public ApiResponse> search() { - return ApiResponse.success(ExpertListResponse.fromAll(expertDetailQuery.searchAll())); + return ApiResponse.success(ExpertListResponse.fromAll(expertDetailQuery.searchAllActive())); } @GetMapping("/{expertId}") diff --git a/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationJpa.java b/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationJpa.java index b8a4ef3c..71896ab7 100644 --- a/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationJpa.java +++ b/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationJpa.java @@ -17,6 +17,7 @@ @Component @RequiredArgsConstructor public class ExpertApplicationJpa implements ExpertApplicationQueryPort, + starlight.application.backoffice.expert.required.BackofficeExpertApplicationCountLookupPort, starlight.application.expert.required.ExpertApplicationCountLookupPort, starlight.application.expertReport.required.ExpertApplicationCountLookupPort { diff --git a/src/main/java/starlight/adapter/member/persistence/MemberJpa.java b/src/main/java/starlight/adapter/member/persistence/MemberJpa.java index 76ec3636..4ffc200b 100644 --- a/src/main/java/starlight/adapter/member/persistence/MemberJpa.java +++ b/src/main/java/starlight/adapter/member/persistence/MemberJpa.java @@ -2,7 +2,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; -import starlight.application.businessplan.required.MemberLookUpPort; +import starlight.application.businessplan.required.MemberLookupPort; import starlight.application.member.required.MemberCommandPort; import starlight.application.member.required.MemberQueryPort; import starlight.domain.member.entity.Member; @@ -13,7 +13,7 @@ @Repository @RequiredArgsConstructor -public class MemberJpa implements MemberQueryPort, MemberCommandPort, MemberLookUpPort { +public class MemberJpa implements MemberQueryPort, MemberCommandPort, MemberLookupPort { private final MemberRepository memberRepository; diff --git a/src/main/java/starlight/adapter/aireport/infrastructure/storage/NcpPresignedUrlProvider.java b/src/main/java/starlight/adapter/shared/infrastructure/storage/NcpPresignedUrlProvider.java similarity index 94% rename from src/main/java/starlight/adapter/aireport/infrastructure/storage/NcpPresignedUrlProvider.java rename to src/main/java/starlight/adapter/shared/infrastructure/storage/NcpPresignedUrlProvider.java index 59ffb381..06de3103 100644 --- a/src/main/java/starlight/adapter/aireport/infrastructure/storage/NcpPresignedUrlProvider.java +++ b/src/main/java/starlight/adapter/shared/infrastructure/storage/NcpPresignedUrlProvider.java @@ -1,4 +1,4 @@ -package starlight.adapter.aireport.infrastructure.storage; +package starlight.adapter.shared.infrastructure.storage; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -13,6 +13,8 @@ import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; import starlight.application.aireport.required.PresignedUrlProviderPort; +import starlight.domain.aireport.exception.AiReportErrorType; +import starlight.domain.aireport.exception.AiReportException; import starlight.shared.dto.infrastructure.PreSignedUrlResponse; import java.net.URLEncoder; @@ -79,12 +81,14 @@ public String makePublic(String objectUrl) { .key(key) .acl(ObjectCannedACL.PUBLIC_READ) .build(); + ncpS3Client.putObjectAcl(aclRequest); log.info("객체 공개 처리 완료(PUBLIC_READ): key={}", objectUrl); } catch (S3Exception e) { log.error("객체 공개 처리 실패 - Message: {}", e.getMessage()); - throw new RuntimeException("객체 공개 처리 실패: " + e.getMessage(), e); + throw new AiReportException(AiReportErrorType.OBJECT_ACL_UPDATE_FAILED, e); } + return objectUrl; } diff --git a/src/main/java/starlight/application/aireport/AiReportService.java b/src/main/java/starlight/application/aireport/AiReportService.java index f7b73f58..5de7c3b1 100644 --- a/src/main/java/starlight/application/aireport/AiReportService.java +++ b/src/main/java/starlight/application/aireport/AiReportService.java @@ -28,8 +28,8 @@ @Transactional public class AiReportService implements AiReportUseCase { - private final BusinessPlanCommandLookUpPort businessPlanCommandLookUpPort; - private final BusinessPlanQueryLookUpPort businessPlanQueryLookUpPort; + private final BusinessPlanCommandLookupPort businessPlanCommandLookupPort; + private final BusinessPlanQueryLookupPort businessPlanQueryLookupPort; private final AiReportQueryPort aiReportQueryPort; private final AiReportCommandPort aiReportCommandPort; private final ReportGraderPort reportGrader; @@ -42,7 +42,7 @@ public class AiReportService implements AiReportUseCase { public AiReportResult gradeBusinessPlan(Long planId, Long memberId) { log.info("사업계획서 AI 채점 시작. planId: {}, memberId: {}", planId, memberId); - BusinessPlan plan = businessPlanQueryLookUpPort.findByIdOrThrow(planId); + BusinessPlan plan = businessPlanQueryLookupPort.findByIdOrThrow(planId); checkBusinessPlanOwned(plan, memberId); checkBusinessPlanWritingCompleted(plan); @@ -78,8 +78,8 @@ public AiReportResult gradeBusinessPlan(Long planId, Long memberId) { public AiReportResult createAndGradePdfBusinessPlan(String title, String pdfUrl, Long memberId) { log.info("PDF 사업계획서 생성 및 AI 채점 시작. title: {}, pdfUrl: {}, memberId: {}", title, pdfUrl, memberId); - Long businessPlanId = businessPlanCommandLookUpPort.createBusinessPlanWithPdf(title, pdfUrl, memberId); - BusinessPlan plan = businessPlanQueryLookUpPort.findByIdOrThrow(businessPlanId); + Long businessPlanId = businessPlanCommandLookupPort.createBusinessPlanWithPdf(title, pdfUrl, memberId); + BusinessPlan plan = businessPlanQueryLookupPort.findByIdOrThrow(businessPlanId); log.debug("OCR 시작. pdfUrl: {}", pdfUrl); String pdfText = ocrProvider.ocrPdfTextByUrl(pdfUrl); @@ -111,7 +111,7 @@ public AiReportResult createAndGradePdfBusinessPlan(String title, String pdfUrl, @Override @Transactional(readOnly = true) public AiReportResult getAiReport(Long planId, Long memberId) { - BusinessPlan plan = businessPlanQueryLookUpPort.findByIdOrThrow(planId); + BusinessPlan plan = businessPlanQueryLookupPort.findByIdOrThrow(planId); checkBusinessPlanOwned(plan, memberId); AiReport aiReport = aiReportQueryPort.findByBusinessPlanId(planId) @@ -143,7 +143,7 @@ private AiReport upsertAiReportWithRawJsonStr(String rawJsonString, BusinessPlan } plan.updateStatus(PlanStatus.AI_REVIEWED); - businessPlanCommandLookUpPort.save(plan); + businessPlanCommandLookupPort.save(plan); return aiReportCommandPort.save(aiReport); } diff --git a/src/main/java/starlight/application/aireport/required/BusinessPlanCommandLookUpPort.java b/src/main/java/starlight/application/aireport/required/BusinessPlanCommandLookupPort.java similarity index 82% rename from src/main/java/starlight/application/aireport/required/BusinessPlanCommandLookUpPort.java rename to src/main/java/starlight/application/aireport/required/BusinessPlanCommandLookupPort.java index 0c25cefa..66a9dfb4 100644 --- a/src/main/java/starlight/application/aireport/required/BusinessPlanCommandLookUpPort.java +++ b/src/main/java/starlight/application/aireport/required/BusinessPlanCommandLookupPort.java @@ -2,7 +2,7 @@ import starlight.domain.businessplan.entity.BusinessPlan; -public interface BusinessPlanCommandLookUpPort { +public interface BusinessPlanCommandLookupPort { BusinessPlan save(BusinessPlan plan); Long createBusinessPlanWithPdf(String title, String pdfUrl, Long memberId); diff --git a/src/main/java/starlight/application/aireport/required/BusinessPlanQueryLookUpPort.java b/src/main/java/starlight/application/aireport/required/BusinessPlanQueryLookupPort.java similarity index 75% rename from src/main/java/starlight/application/aireport/required/BusinessPlanQueryLookUpPort.java rename to src/main/java/starlight/application/aireport/required/BusinessPlanQueryLookupPort.java index 0024cb71..f0ea03ae 100644 --- a/src/main/java/starlight/application/aireport/required/BusinessPlanQueryLookUpPort.java +++ b/src/main/java/starlight/application/aireport/required/BusinessPlanQueryLookupPort.java @@ -2,6 +2,6 @@ import starlight.domain.businessplan.entity.BusinessPlan; -public interface BusinessPlanQueryLookUpPort { +public interface BusinessPlanQueryLookupPort { BusinessPlan findByIdOrThrow(Long id); -} \ No newline at end of file +} diff --git a/src/main/java/starlight/application/backoffice/expert/BackofficeExpertCommandService.java b/src/main/java/starlight/application/backoffice/expert/BackofficeExpertCommandService.java new file mode 100644 index 00000000..0c680d46 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/expert/BackofficeExpertCommandService.java @@ -0,0 +1,101 @@ +package starlight.application.backoffice.expert; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import starlight.application.backoffice.expert.provided.BackofficeExpertCommandUseCase; +import starlight.application.backoffice.expert.provided.dto.input.BackofficeExpertActiveStatusUpdateInput; +import starlight.application.backoffice.expert.provided.dto.input.BackofficeExpertCreateInput; +import starlight.application.backoffice.expert.provided.dto.input.BackofficeExpertProfileImageUpdateInput; +import starlight.application.backoffice.expert.provided.dto.input.BackofficeExpertCareerUpdateInput; +import starlight.application.backoffice.expert.provided.dto.input.BackofficeExpertUpdateInput; +import starlight.application.backoffice.expert.provided.dto.result.BackofficeExpertCreateResult; +import starlight.application.backoffice.expert.required.BackofficeExpertCommandPort; +import starlight.application.backoffice.expert.required.BackofficeExpertQueryPort; +import starlight.domain.expert.entity.Expert; +import starlight.domain.expert.dto.ExpertCareerUpdate; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional +public class BackofficeExpertCommandService implements BackofficeExpertCommandUseCase { + + private final BackofficeExpertCommandPort expertCommandPort; + private final BackofficeExpertQueryPort expertQueryPort; + + @Override + public BackofficeExpertCreateResult createExpert(BackofficeExpertCreateInput input) { + Expert expert = Expert.createBackoffice( + input.name(), + input.email(), + input.oneLineIntroduction(), + input.tags(), + input.categories() + ); + + Expert savedExpert = expertCommandPort.save(expert); + + return BackofficeExpertCreateResult.from(savedExpert.getId()); + } + + @Override + public void updateExpert(BackofficeExpertUpdateInput input) { + Expert expert = expertQueryPort.findByIdWithCareersTagsCategories(input.expertId()); + + expert.updateBasicInfo( + input.name(), + input.email(), + input.oneLineIntroduction(), + input.detailedIntroduction(), + input.workedPeriod(), + input.mentoringPriceWon() + ); + + expert.replaceTags(input.tags()); + expert.replaceCategories(input.categories()); + + if (input.careers() != null) { + expert.syncCareers(toCareerUpdates(input.careers())); + } + } + + @Override + public void deleteExpert(Long expertId) { + Expert expert = expertQueryPort.findByIdOrThrow(expertId); + + expertCommandPort.delete(expert); + } + + @Override + public void updateActiveStatus(BackofficeExpertActiveStatusUpdateInput input) { + Expert expert = expertQueryPort.findByIdOrThrow(input.expertId()); + + expert.updateActiveStatus(input.activeStatus()); + } + + @Override + public void updateProfileImage(BackofficeExpertProfileImageUpdateInput input) { + Expert expert = expertQueryPort.findByIdOrThrow(input.expertId()); + + expert.updateProfileImageUrl(input.profileImageUrl()); + } + + private List toCareerUpdates(List inputs) { + if (inputs == null || inputs.isEmpty()) { + return List.of(); + } + + return inputs.stream() + .map(input -> new ExpertCareerUpdate( + input.id(), + input.orderIndex(), + input.careerTitle(), + input.careerExplanation(), + input.careerStartedAt(), + input.careerEndedAt() + )) + .toList(); + } +} diff --git a/src/main/java/starlight/application/backoffice/expert/BackofficeExpertQueryService.java b/src/main/java/starlight/application/backoffice/expert/BackofficeExpertQueryService.java new file mode 100644 index 00000000..57ac2ae8 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/expert/BackofficeExpertQueryService.java @@ -0,0 +1,52 @@ +package starlight.application.backoffice.expert; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import starlight.application.backoffice.expert.provided.BackofficeExpertQueryUseCase; +import starlight.application.backoffice.expert.provided.dto.result.BackofficeExpertDetailResult; +import starlight.application.backoffice.expert.required.BackofficeExpertApplicationCountLookupPort; +import starlight.application.backoffice.expert.required.BackofficeExpertQueryPort; +import starlight.domain.expert.entity.Expert; + +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class BackofficeExpertQueryService implements BackofficeExpertQueryUseCase { + + private final BackofficeExpertQueryPort expertQueryPort; + private final BackofficeExpertApplicationCountLookupPort expertApplicationLookupPort; + + @Override + public List searchAll() { + List experts = expertQueryPort.findAllWithCareersTagsCategories(); + + List expertIds = experts.stream() + .map(Expert::getId) + .toList(); + + Map countMap = expertApplicationLookupPort.countByExpertIds(expertIds); + + return experts.stream() + .map(expert -> BackofficeExpertDetailResult.from( + expert, + countMap.getOrDefault(expert.getId(), 0L) + )) + .toList(); + } + + @Override + public BackofficeExpertDetailResult findById(Long expertId) { + Expert expert = expertQueryPort.findByIdOrThrow(expertId); + + Map countMap = expertApplicationLookupPort.countByExpertIds( + List.of(expertId) + ); + long count = countMap.getOrDefault(expertId, 0L); + + return BackofficeExpertDetailResult.from(expert, count); + } +} diff --git a/src/main/java/starlight/application/backoffice/expert/provided/BackofficeExpertCommandUseCase.java b/src/main/java/starlight/application/backoffice/expert/provided/BackofficeExpertCommandUseCase.java new file mode 100644 index 00000000..0d8ef20d --- /dev/null +++ b/src/main/java/starlight/application/backoffice/expert/provided/BackofficeExpertCommandUseCase.java @@ -0,0 +1,20 @@ +package starlight.application.backoffice.expert.provided; + +import starlight.application.backoffice.expert.provided.dto.input.BackofficeExpertActiveStatusUpdateInput; +import starlight.application.backoffice.expert.provided.dto.input.BackofficeExpertCreateInput; +import starlight.application.backoffice.expert.provided.dto.input.BackofficeExpertProfileImageUpdateInput; +import starlight.application.backoffice.expert.provided.dto.input.BackofficeExpertUpdateInput; +import starlight.application.backoffice.expert.provided.dto.result.BackofficeExpertCreateResult; + +public interface BackofficeExpertCommandUseCase { + + BackofficeExpertCreateResult createExpert(BackofficeExpertCreateInput input); + + void updateExpert(BackofficeExpertUpdateInput input); + + void deleteExpert(Long expertId); + + void updateActiveStatus(BackofficeExpertActiveStatusUpdateInput input); + + void updateProfileImage(BackofficeExpertProfileImageUpdateInput input); +} diff --git a/src/main/java/starlight/application/backoffice/expert/provided/BackofficeExpertQueryUseCase.java b/src/main/java/starlight/application/backoffice/expert/provided/BackofficeExpertQueryUseCase.java new file mode 100644 index 00000000..1abf2b48 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/expert/provided/BackofficeExpertQueryUseCase.java @@ -0,0 +1,12 @@ +package starlight.application.backoffice.expert.provided; + +import starlight.application.backoffice.expert.provided.dto.result.BackofficeExpertDetailResult; + +import java.util.List; + +public interface BackofficeExpertQueryUseCase { + + List searchAll(); + + BackofficeExpertDetailResult findById(Long expertId); +} diff --git a/src/main/java/starlight/application/backoffice/expert/provided/dto/input/BackofficeExpertActiveStatusUpdateInput.java b/src/main/java/starlight/application/backoffice/expert/provided/dto/input/BackofficeExpertActiveStatusUpdateInput.java new file mode 100644 index 00000000..95f5c52a --- /dev/null +++ b/src/main/java/starlight/application/backoffice/expert/provided/dto/input/BackofficeExpertActiveStatusUpdateInput.java @@ -0,0 +1,12 @@ +package starlight.application.backoffice.expert.provided.dto.input; + +import starlight.domain.expert.enumerate.ExpertActiveStatus; + +public record BackofficeExpertActiveStatusUpdateInput( + Long expertId, + ExpertActiveStatus activeStatus +) { + public static BackofficeExpertActiveStatusUpdateInput of(Long expertId, ExpertActiveStatus activeStatus) { + return new BackofficeExpertActiveStatusUpdateInput(expertId, activeStatus); + } +} diff --git a/src/main/java/starlight/application/backoffice/expert/provided/dto/input/BackofficeExpertCareerUpdateInput.java b/src/main/java/starlight/application/backoffice/expert/provided/dto/input/BackofficeExpertCareerUpdateInput.java new file mode 100644 index 00000000..8ad95704 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/expert/provided/dto/input/BackofficeExpertCareerUpdateInput.java @@ -0,0 +1,13 @@ +package starlight.application.backoffice.expert.provided.dto.input; + +import java.time.LocalDateTime; + +public record BackofficeExpertCareerUpdateInput( + Long id, + Integer orderIndex, + String careerTitle, + String careerExplanation, + LocalDateTime careerStartedAt, + LocalDateTime careerEndedAt +) { +} diff --git a/src/main/java/starlight/application/backoffice/expert/provided/dto/input/BackofficeExpertCreateInput.java b/src/main/java/starlight/application/backoffice/expert/provided/dto/input/BackofficeExpertCreateInput.java new file mode 100644 index 00000000..cca4a890 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/expert/provided/dto/input/BackofficeExpertCreateInput.java @@ -0,0 +1,23 @@ +package starlight.application.backoffice.expert.provided.dto.input; + +import starlight.domain.expert.enumerate.TagCategory; + +import java.util.List; + +public record BackofficeExpertCreateInput( + String name, + String email, + String oneLineIntroduction, + List tags, + List categories +) { + public static BackofficeExpertCreateInput of( + String name, + String email, + String oneLineIntroduction, + List tags, + List categories + ) { + return new BackofficeExpertCreateInput(name, email, oneLineIntroduction, tags, categories); + } +} diff --git a/src/main/java/starlight/application/backoffice/expert/provided/dto/input/BackofficeExpertProfileImageUpdateInput.java b/src/main/java/starlight/application/backoffice/expert/provided/dto/input/BackofficeExpertProfileImageUpdateInput.java new file mode 100644 index 00000000..24a92a29 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/expert/provided/dto/input/BackofficeExpertProfileImageUpdateInput.java @@ -0,0 +1,10 @@ +package starlight.application.backoffice.expert.provided.dto.input; + +public record BackofficeExpertProfileImageUpdateInput( + Long expertId, + String profileImageUrl +) { + public static BackofficeExpertProfileImageUpdateInput of(Long expertId, String profileImageUrl) { + return new BackofficeExpertProfileImageUpdateInput(expertId, profileImageUrl); + } +} diff --git a/src/main/java/starlight/application/backoffice/expert/provided/dto/input/BackofficeExpertUpdateInput.java b/src/main/java/starlight/application/backoffice/expert/provided/dto/input/BackofficeExpertUpdateInput.java new file mode 100644 index 00000000..2d7fe445 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/expert/provided/dto/input/BackofficeExpertUpdateInput.java @@ -0,0 +1,44 @@ +package starlight.application.backoffice.expert.provided.dto.input; + +import starlight.domain.expert.enumerate.TagCategory; + +import java.util.List; + +public record BackofficeExpertUpdateInput( + Long expertId, + String name, + String email, + String oneLineIntroduction, + String detailedIntroduction, + Long workedPeriod, + Integer mentoringPriceWon, + List tags, + List categories, + List careers +) { + public static BackofficeExpertUpdateInput of( + Long expertId, + String name, + String email, + String oneLineIntroduction, + String detailedIntroduction, + Long workedPeriod, + Integer mentoringPriceWon, + List tags, + List categories, + List careers + ) { + return new BackofficeExpertUpdateInput( + expertId, + name, + email, + oneLineIntroduction, + detailedIntroduction, + workedPeriod, + mentoringPriceWon, + tags, + categories, + careers + ); + } +} diff --git a/src/main/java/starlight/application/backoffice/expert/provided/dto/result/BackofficeExpertCreateResult.java b/src/main/java/starlight/application/backoffice/expert/provided/dto/result/BackofficeExpertCreateResult.java new file mode 100644 index 00000000..7514b81c --- /dev/null +++ b/src/main/java/starlight/application/backoffice/expert/provided/dto/result/BackofficeExpertCreateResult.java @@ -0,0 +1,9 @@ +package starlight.application.backoffice.expert.provided.dto.result; + +public record BackofficeExpertCreateResult( + Long id +) { + public static BackofficeExpertCreateResult from(Long id) { + return new BackofficeExpertCreateResult(id); + } +} diff --git a/src/main/java/starlight/application/backoffice/expert/provided/dto/result/BackofficeExpertDetailResult.java b/src/main/java/starlight/application/backoffice/expert/provided/dto/result/BackofficeExpertDetailResult.java new file mode 100644 index 00000000..ae1cb972 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/expert/provided/dto/result/BackofficeExpertDetailResult.java @@ -0,0 +1,55 @@ +package starlight.application.backoffice.expert.provided.dto.result; + +import starlight.application.expert.provided.dto.ExpertCareerResult; +import starlight.domain.expert.entity.Expert; +import starlight.domain.expert.enumerate.ExpertActiveStatus; +import starlight.domain.expert.enumerate.TagCategory; + +import java.util.List; + +public record BackofficeExpertDetailResult( + Long id, + Long applicationCount, + String name, + String oneLineIntroduction, + String detailedIntroduction, + String profileImageUrl, + Long workedPeriod, + String email, + Integer mentoringPriceWon, + ExpertActiveStatus activeStatus, + List careers, + List tags, + List categories +) { + public static BackofficeExpertDetailResult from(Expert expert, long applicationCount) { + List careers = expert.getCareers().stream() + .map(ExpertCareerResult::from) + .toList(); + + List categories = expert.getCategories().stream() + .map(TagCategory::name) + .distinct() + .toList(); + + List tags = expert.getTags().stream() + .distinct() + .toList(); + + return new BackofficeExpertDetailResult( + expert.getId(), + applicationCount, + expert.getName(), + expert.getOneLineIntroduction(), + expert.getDetailedIntroduction(), + expert.getProfileImageUrl(), + expert.getWorkedPeriod(), + expert.getEmail(), + expert.getMentoringPriceWon(), + expert.getActiveStatus(), + careers, + tags, + categories + ); + } +} diff --git a/src/main/java/starlight/application/backoffice/expert/required/BackofficeExpertApplicationCountLookupPort.java b/src/main/java/starlight/application/backoffice/expert/required/BackofficeExpertApplicationCountLookupPort.java new file mode 100644 index 00000000..3bfd7304 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/expert/required/BackofficeExpertApplicationCountLookupPort.java @@ -0,0 +1,9 @@ +package starlight.application.backoffice.expert.required; + +import java.util.List; +import java.util.Map; + +public interface BackofficeExpertApplicationCountLookupPort { + + Map countByExpertIds(List expertIds); +} diff --git a/src/main/java/starlight/application/backoffice/expert/required/BackofficeExpertCommandPort.java b/src/main/java/starlight/application/backoffice/expert/required/BackofficeExpertCommandPort.java new file mode 100644 index 00000000..3b329949 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/expert/required/BackofficeExpertCommandPort.java @@ -0,0 +1,10 @@ +package starlight.application.backoffice.expert.required; + +import starlight.domain.expert.entity.Expert; + +public interface BackofficeExpertCommandPort { + + Expert save(Expert expert); + + void delete(Expert expert); +} diff --git a/src/main/java/starlight/application/backoffice/expert/required/BackofficeExpertQueryPort.java b/src/main/java/starlight/application/backoffice/expert/required/BackofficeExpertQueryPort.java new file mode 100644 index 00000000..c6da3181 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/expert/required/BackofficeExpertQueryPort.java @@ -0,0 +1,14 @@ +package starlight.application.backoffice.expert.required; + +import starlight.domain.expert.entity.Expert; + +import java.util.List; + +public interface BackofficeExpertQueryPort { + + Expert findByIdOrThrow(Long id); + + Expert findByIdWithCareersTagsCategories(Long id); + + List findAllWithCareersTagsCategories(); +} diff --git a/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendLogEventHandler.java b/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendLogEventHandler.java index 41928262..40620c70 100644 --- a/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendLogEventHandler.java +++ b/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendLogEventHandler.java @@ -1,28 +1,41 @@ package starlight.application.backoffice.mail; import lombok.RequiredArgsConstructor; -import org.springframework.context.event.EventListener; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataAccessException; +import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; import starlight.application.backoffice.mail.event.BackofficeMailSendEvent; import starlight.application.backoffice.mail.required.BackofficeMailSendLogCommandPort; +import starlight.application.backoffice.mail.util.EmailMaskingUtils; import starlight.domain.backoffice.mail.BackofficeMailSendLog; +@Slf4j @Component @RequiredArgsConstructor public class BackofficeMailSendLogEventHandler { private final BackofficeMailSendLogCommandPort logCommandPort; - @EventListener + @Async("emailTaskExecutor") + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION) public void handle(BackofficeMailSendEvent event) { - String recipients = String.join(",", event.to()); - BackofficeMailSendLog log = BackofficeMailSendLog.create( + String recipients = EmailMaskingUtils.maskRecipients(event.to()); + + BackofficeMailSendLog mailSendLog = BackofficeMailSendLog.create( recipients, event.subject(), event.contentType(), event.success(), event.errorMessage() ); - logCommandPort.save(log); + + try { + logCommandPort.save(mailSendLog); + } catch (DataAccessException exception) { + log.warn("[MAIL] send log save failed. subject={}", event.subject(), exception); + } } } diff --git a/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendService.java b/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendService.java index 07243780..536b72f7 100644 --- a/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendService.java +++ b/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendService.java @@ -1,17 +1,17 @@ package starlight.application.backoffice.mail; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import starlight.application.backoffice.mail.provided.BackofficeMailSendUseCase; import starlight.application.backoffice.mail.provided.dto.input.BackofficeMailSendInput; import starlight.application.backoffice.mail.required.MailSenderPort; +import starlight.application.backoffice.mail.util.BackofficeMailContentTypeParser; import starlight.application.backoffice.mail.event.BackofficeMailSendEvent; -import org.springframework.context.ApplicationEventPublisher; import starlight.domain.backoffice.exception.BackofficeErrorType; import starlight.domain.backoffice.exception.BackofficeException; import starlight.domain.backoffice.mail.BackofficeMailContentType; -import starlight.domain.backoffice.mail.BackofficeMailSendLog; @Service @RequiredArgsConstructor @@ -23,11 +23,13 @@ public class BackofficeMailSendService implements BackofficeMailSendUseCase { @Override @Transactional public void send(BackofficeMailSendInput input) { - BackofficeMailContentType contentType = parseContentType(input.contentType()); + BackofficeMailContentType contentType = BackofficeMailContentTypeParser.parse(input.contentType()); try { validate(input, contentType); + mailSenderPort.send(input, contentType); + BackofficeMailSendEvent log = BackofficeMailSendEvent.of( input.to(), input.subject(), @@ -36,39 +38,30 @@ public void send(BackofficeMailSendInput input) { null ); eventPublisher.publishEvent(log); - - } catch (IllegalArgumentException exception) { + } catch (BackofficeException exception) { publishFailureEvent(input, contentType, exception.getMessage()); - throw new BackofficeException(BackofficeErrorType.INVALID_MAIL_REQUEST); + throw exception; } catch (Exception exception) { publishFailureEvent(input, contentType, exception.getMessage()); throw new BackofficeException(BackofficeErrorType.MAIL_SEND_FAILED); } } - private BackofficeMailContentType parseContentType(String contentType) { - try { - return BackofficeMailContentType.from(contentType); - } catch (IllegalArgumentException exception) { - throw new BackofficeException(BackofficeErrorType.INVALID_MAIL_CONTENT_TYPE); - } - } - private void validate(BackofficeMailSendInput input, BackofficeMailContentType contentType) { if (input.to() == null || input.to().isEmpty()) { - throw new IllegalArgumentException("recipient is required"); + throw new BackofficeException(BackofficeErrorType.INVALID_MAIL_REQUEST); } if (input.subject() == null || input.subject().isBlank()) { - throw new IllegalArgumentException("subject is required"); + throw new BackofficeException(BackofficeErrorType.INVALID_MAIL_REQUEST); } if (contentType == BackofficeMailContentType.HTML) { if (input.html() == null || input.html().isBlank()) { - throw new IllegalArgumentException("html body is required"); + throw new BackofficeException(BackofficeErrorType.INVALID_MAIL_REQUEST); } } if (contentType == BackofficeMailContentType.TEXT) { if (input.text() == null || input.text().isBlank()) { - throw new IllegalArgumentException("text body is required"); + throw new BackofficeException(BackofficeErrorType.INVALID_MAIL_REQUEST); } } } diff --git a/src/main/java/starlight/application/backoffice/mail/BackofficeMailTemplateService.java b/src/main/java/starlight/application/backoffice/mail/BackofficeMailTemplateService.java index bdd12e9f..2d588071 100644 --- a/src/main/java/starlight/application/backoffice/mail/BackofficeMailTemplateService.java +++ b/src/main/java/starlight/application/backoffice/mail/BackofficeMailTemplateService.java @@ -9,6 +9,7 @@ import starlight.application.backoffice.mail.provided.dto.result.BackofficeMailTemplateResult; import starlight.application.backoffice.mail.required.BackofficeMailTemplateCommandPort; import starlight.application.backoffice.mail.required.BackofficeMailTemplateQueryPort; +import starlight.application.backoffice.mail.util.BackofficeMailContentTypeParser; import starlight.domain.backoffice.exception.BackofficeErrorType; import starlight.domain.backoffice.exception.BackofficeException; import starlight.domain.backoffice.mail.BackofficeMailContentType; @@ -26,7 +27,7 @@ public class BackofficeMailTemplateService implements BackofficeMailTemplateUseC @Override @Transactional public BackofficeMailTemplateResult createTemplate(BackofficeMailTemplateCreateInput input) { - BackofficeMailContentType contentType = parseContentType(input.contentType()); + BackofficeMailContentType contentType = BackofficeMailContentTypeParser.parse(input.contentType()); BackofficeMailTemplate template = BackofficeMailTemplate.create( input.name(), input.title(), @@ -37,20 +38,13 @@ public BackofficeMailTemplateResult createTemplate(BackofficeMailTemplateCreateI try { BackofficeMailTemplate saved = templateCommandPort.save(template); + return toResult(saved); } catch (DataAccessException exception) { throw new BackofficeException(BackofficeErrorType.MAIL_TEMPLATE_SAVE_FAILED); } } - private BackofficeMailContentType parseContentType(String contentType) { - try { - return BackofficeMailContentType.from(contentType); - } catch (IllegalArgumentException exception) { - throw new BackofficeException(BackofficeErrorType.INVALID_MAIL_CONTENT_TYPE); - } - } - @Override @Transactional(readOnly = true) public List findTemplates() { diff --git a/src/main/java/starlight/application/backoffice/mail/util/BackofficeMailContentTypeParser.java b/src/main/java/starlight/application/backoffice/mail/util/BackofficeMailContentTypeParser.java new file mode 100644 index 00000000..6bf8717d --- /dev/null +++ b/src/main/java/starlight/application/backoffice/mail/util/BackofficeMailContentTypeParser.java @@ -0,0 +1,18 @@ +package starlight.application.backoffice.mail.util; + +import starlight.domain.backoffice.exception.BackofficeErrorType; +import starlight.domain.backoffice.exception.BackofficeException; +import starlight.domain.backoffice.mail.BackofficeMailContentType; + +public final class BackofficeMailContentTypeParser { + + private BackofficeMailContentTypeParser() {} + + public static BackofficeMailContentType parse(String contentType) { + try { + return BackofficeMailContentType.from(contentType); + } catch (IllegalArgumentException exception) { + throw new BackofficeException(BackofficeErrorType.INVALID_MAIL_CONTENT_TYPE); + } + } +} diff --git a/src/main/java/starlight/application/backoffice/mail/util/EmailMaskingUtils.java b/src/main/java/starlight/application/backoffice/mail/util/EmailMaskingUtils.java new file mode 100644 index 00000000..9c35246c --- /dev/null +++ b/src/main/java/starlight/application/backoffice/mail/util/EmailMaskingUtils.java @@ -0,0 +1,34 @@ +package starlight.application.backoffice.mail.util; + +import java.util.List; +import java.util.stream.Collectors; + +public final class EmailMaskingUtils { + + private EmailMaskingUtils() { + } + + public static String maskRecipients(List recipients) { + if (recipients == null || recipients.isEmpty()) { + return ""; + } + return recipients.stream() + .map(EmailMaskingUtils::maskEmail) + .collect(Collectors.joining(",")); + } + + private static String maskEmail(String email) { + if (email == null || email.isBlank()) { + return "***"; + } + int atIndex = email.indexOf("@"); + if (atIndex <= 0) { + return "***"; + } + String local = email.substring(0, atIndex); + String domain = email.substring(atIndex + 1); + String maskedLocal = local.length() <= 1 ? "*" : local.charAt(0) + "***"; + String maskedDomain = domain.isBlank() ? "***" : domain; + return maskedLocal + "@" + maskedDomain; + } +} diff --git a/src/main/java/starlight/application/businessplan/BusinessPlanService.java b/src/main/java/starlight/application/businessplan/BusinessPlanService.java index dc5a964a..8675083f 100644 --- a/src/main/java/starlight/application/businessplan/BusinessPlanService.java +++ b/src/main/java/starlight/application/businessplan/BusinessPlanService.java @@ -15,7 +15,7 @@ import starlight.application.businessplan.required.BusinessPlanCommandPort; import starlight.application.businessplan.required.BusinessPlanQueryPort; import starlight.application.businessplan.required.ChecklistGraderPort; -import starlight.application.businessplan.required.MemberLookUpPort; +import starlight.application.businessplan.required.MemberLookupPort; import starlight.application.businessplan.util.PlainTextExtractUtils; import starlight.application.businessplan.util.SubSectionSupportUtils; import starlight.domain.businessplan.entity.*; @@ -37,13 +37,13 @@ public class BusinessPlanService implements BusinessPlanUseCase { private final BusinessPlanCommandPort businessPlanCommandPort; private final BusinessPlanQueryPort businessPlanQueryPort; - private final MemberLookUpPort memberLookUpPort; + private final MemberLookupPort memberLookupPort; private final ChecklistGraderPort checklistGrader; private final ObjectMapper objectMapper; @Override public BusinessPlanResult.Result createBusinessPlan(Long memberId) { - Member member = memberLookUpPort.findByIdOrThrow(memberId); + Member member = memberLookupPort.findByIdOrThrow(memberId); String planTitle = member.getName() == null ? "제목 없는 사업계획서" : member.getName() + "의 사업계획서"; diff --git a/src/main/java/starlight/application/businessplan/required/MemberLookUpPort.java b/src/main/java/starlight/application/businessplan/required/MemberLookupPort.java similarity index 79% rename from src/main/java/starlight/application/businessplan/required/MemberLookUpPort.java rename to src/main/java/starlight/application/businessplan/required/MemberLookupPort.java index 54445249..978d72a5 100644 --- a/src/main/java/starlight/application/businessplan/required/MemberLookUpPort.java +++ b/src/main/java/starlight/application/businessplan/required/MemberLookupPort.java @@ -2,6 +2,6 @@ import starlight.domain.member.entity.Member; -public interface MemberLookUpPort { +public interface MemberLookupPort { Member findByIdOrThrow(Long id); } diff --git a/src/main/java/starlight/application/expert/ExpertDetailQueryService.java b/src/main/java/starlight/application/expert/ExpertDetailQueryService.java index 88edceaa..3d20a8be 100644 --- a/src/main/java/starlight/application/expert/ExpertDetailQueryService.java +++ b/src/main/java/starlight/application/expert/ExpertDetailQueryService.java @@ -7,7 +7,10 @@ import starlight.application.expert.provided.dto.ExpertDetailResult; import starlight.application.expert.required.ExpertApplicationCountLookupPort; import starlight.application.expert.required.ExpertQueryPort; +import starlight.domain.expert.enumerate.ExpertActiveStatus; import starlight.domain.expert.entity.Expert; +import starlight.domain.expert.exception.ExpertErrorType; +import starlight.domain.expert.exception.ExpertException; import java.util.List; import java.util.Map; @@ -21,16 +24,20 @@ public class ExpertDetailQueryService implements ExpertDetailQueryUseCase { private final ExpertApplicationCountLookupPort expertApplicationLookupPort; @Override - public List searchAll() { + public List searchAllActive() { List experts = expertQueryPort.findAllWithCareersTagsCategories(); - List expertIds = experts.stream() + List activeExperts = experts.stream() + .filter(expert -> expert.getActiveStatus() == ExpertActiveStatus.ACTIVE) + .toList(); + + List expertIds = activeExperts.stream() .map(Expert::getId) .toList(); Map countMap = expertApplicationLookupPort.countByExpertIds(expertIds); - return experts.stream() + return activeExperts.stream() .map(expert -> ExpertDetailResult.from(expert, countMap.getOrDefault(expert.getId(), 0L))) .toList(); } @@ -38,6 +45,9 @@ public List searchAll() { @Override public ExpertDetailResult findById(Long expertId) { Expert expert = expertQueryPort.findByIdWithCareersAndTags(expertId); + if (expert.getActiveStatus() != ExpertActiveStatus.ACTIVE) { + throw new ExpertException(ExpertErrorType.EXPERT_NOT_ACTIVE); + } Map countMap = expertApplicationLookupPort.countByExpertIds(List.of(expertId)); long count = countMap.getOrDefault(expertId, 0L); return ExpertDetailResult.from(expert, count); diff --git a/src/main/java/starlight/application/expert/provided/ExpertDetailQueryUseCase.java b/src/main/java/starlight/application/expert/provided/ExpertDetailQueryUseCase.java index 705b16ee..53c13d4c 100644 --- a/src/main/java/starlight/application/expert/provided/ExpertDetailQueryUseCase.java +++ b/src/main/java/starlight/application/expert/provided/ExpertDetailQueryUseCase.java @@ -6,7 +6,7 @@ public interface ExpertDetailQueryUseCase { - List searchAll(); + List searchAllActive(); ExpertDetailResult findById(Long expertId); } diff --git a/src/main/java/starlight/bootstrap/SecurityConfig.java b/src/main/java/starlight/bootstrap/SecurityConfig.java index 07f42329..4eeecc1a 100644 --- a/src/main/java/starlight/bootstrap/SecurityConfig.java +++ b/src/main/java/starlight/bootstrap/SecurityConfig.java @@ -47,12 +47,13 @@ @RequiredArgsConstructor public class SecurityConfig { - @Value("${cors.origin.server}") String ServerBaseUrl; + @Value("${cors.origin.server}") String serverBaseUrl; @Value("${cors.origin.client}") String clientBaseUrl; @Value("${cors.origin.office}") String officeBaseUrl; @Value("${cors.origin.develop}") String devBaseUrl; @Value("${backoffice.auth.username}") String backofficeUsername; @Value("${backoffice.auth.password-hash}") String backofficePasswordHash; + @Value("${backoffice.csrf.cookie-domain}") String backofficeCsrfCookieDomain; private final Environment environment; private final JwtFilter jwtFilter; @@ -70,13 +71,13 @@ public SecurityFilterChain backofficeFilterChain(HttpSecurity http) throws Excep boolean isDevProfile = List.of(environment.getActiveProfiles()).contains("dev"); if (!isDevProfile) { csrfTokenRepository.setCookieCustomizer(cookie -> cookie - .domain("starlight-official.co.kr") + .domain(backofficeCsrfCookieDomain) .sameSite("None") .secure(true) ); } - http.securityMatcher("/v1/backoffice/mail/**", "/login", "/logout") + http.securityMatcher("/v1/backoffice/**", "/login", "/logout") .cors(Customizer.withDefaults()) .csrf((csrf) -> csrf .csrfTokenRepository(csrfTokenRepository) @@ -148,7 +149,7 @@ public CorsConfigurationSource corsConfigurationSource() { configuration.setAllowedOrigins(List.of( clientBaseUrl, - ServerBaseUrl, + serverBaseUrl, devBaseUrl, officeBaseUrl )); diff --git a/src/main/java/starlight/bootstrap/SwaggerConfig.java b/src/main/java/starlight/bootstrap/SwaggerConfig.java index df6ebb17..879c6f2e 100644 --- a/src/main/java/starlight/bootstrap/SwaggerConfig.java +++ b/src/main/java/starlight/bootstrap/SwaggerConfig.java @@ -2,7 +2,6 @@ import io.swagger.v3.oas.annotations.OpenAPIDefinition; import io.swagger.v3.oas.annotations.info.Info; -import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.servers.Server; import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; @@ -10,8 +9,6 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import java.util.Collections; - @OpenAPIDefinition( info = @Info(title = "StarLight 명세서", description = "StarLight API 명세서", version = "v1" ), @@ -31,11 +28,15 @@ public OpenAPI openAPI() { .bearerFormat("JWT") .in(SecurityScheme.In.HEADER) .name("Authorization"); - io.swagger.v3.oas.models.security.SecurityRequirement securityRequirement = - new io.swagger.v3.oas.models.security.SecurityRequirement().addList("bearerAuth"); + SecurityScheme backofficeSessionScheme = new SecurityScheme() + .type(SecurityScheme.Type.APIKEY) + .in(SecurityScheme.In.COOKIE) + .name("JSESSIONID"); return new OpenAPI() - .components(new Components().addSecuritySchemes("bearerAuth", securityScheme)) - .security(Collections.singletonList(securityRequirement)); + .components(new Components() + .addSecuritySchemes("bearerAuth", securityScheme) + .addSecuritySchemes("backofficeSession", backofficeSessionScheme) + ); } } diff --git a/src/main/java/starlight/domain/aireport/exception/AiReportErrorType.java b/src/main/java/starlight/domain/aireport/exception/AiReportErrorType.java index 56353ae7..912baf9c 100644 --- a/src/main/java/starlight/domain/aireport/exception/AiReportErrorType.java +++ b/src/main/java/starlight/domain/aireport/exception/AiReportErrorType.java @@ -14,6 +14,7 @@ public enum AiReportErrorType implements ErrorType { UNAUTHORIZED_ACCESS(HttpStatus.FORBIDDEN, "권한이 없습니다."), AI_RESPONSE_PARSING_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "AI 응답 파싱에 실패했습니다."), AI_GRADING_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "AI 채점에 실패했습니다."), + OBJECT_ACL_UPDATE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "객체 공개 처리에 실패했습니다."), AI_AGENT_DUPLICATED(HttpStatus.INTERNAL_SERVER_ERROR, "AI 리포트 에이전트가 중복입니다."); ; diff --git a/src/main/java/starlight/domain/expert/dto/ExpertCareerUpdate.java b/src/main/java/starlight/domain/expert/dto/ExpertCareerUpdate.java new file mode 100644 index 00000000..a4e0fa5f --- /dev/null +++ b/src/main/java/starlight/domain/expert/dto/ExpertCareerUpdate.java @@ -0,0 +1,12 @@ +package starlight.domain.expert.dto; + +import java.time.LocalDateTime; + +public record ExpertCareerUpdate( + Long id, + Integer orderIndex, + String careerTitle, + String careerExplanation, + LocalDateTime careerStartedAt, + LocalDateTime careerEndedAt +) { } diff --git a/src/main/java/starlight/domain/expert/entity/Expert.java b/src/main/java/starlight/domain/expert/entity/Expert.java index f1ce0af0..10fc42aa 100644 --- a/src/main/java/starlight/domain/expert/entity/Expert.java +++ b/src/main/java/starlight/domain/expert/entity/Expert.java @@ -5,13 +5,23 @@ import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; +import org.springframework.util.Assert; +import starlight.domain.expert.enumerate.ExpertActiveStatus; import starlight.domain.expert.enumerate.TagCategory; +import starlight.domain.expert.dto.ExpertCareerUpdate; +import starlight.domain.expert.exception.ExpertErrorType; +import starlight.domain.expert.exception.ExpertException; import starlight.shared.AbstractEntity; import java.util.ArrayList; +import java.util.Collection; import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; +import java.util.Objects; import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; @Getter @Entity @@ -36,6 +46,10 @@ public class Expert extends AbstractEntity { @Column private String detailedIntroduction; + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private ExpertActiveStatus activeStatus = ExpertActiveStatus.ACTIVE; + @Min(0) @Column private Integer mentoringPriceWon; @@ -54,4 +68,151 @@ public class Expert extends AbstractEntity { @Enumerated(EnumType.STRING) @Column(name = "category", length = 40, nullable = false) private Set categories = new LinkedHashSet<>(); + + public static Expert createBackoffice( + String name, + String email, + String oneLineIntroduction, + Collection tags, + Collection categories + ) { + Assert.hasText(name, "name must not be blank"); + Assert.hasText(email, "email must not be blank"); + + Expert expert = new Expert(); + expert.name = name; + expert.email = email; + expert.oneLineIntroduction = oneLineIntroduction; + expert.activeStatus = ExpertActiveStatus.INACTIVE; + + if (tags != null && !tags.isEmpty()) { + expert.tags.clear(); + expert.tags.addAll(tags); + } + + if (categories != null && !categories.isEmpty()) { + expert.categories.clear(); + expert.categories.addAll(categories); + } + + return expert; + } + + public void updateActiveStatus(ExpertActiveStatus activeStatus) { + Assert.notNull(activeStatus, "activeStatus must not be null"); + this.activeStatus = activeStatus; + } + + public void updateProfileImageUrl(String profileImageUrl) { + Assert.hasText(profileImageUrl, "profileImageUrl must not be blank"); + this.profileImageUrl = profileImageUrl; + } + + public void updateBasicInfo( + String name, String email, String oneLineIntroduction, + String detailedIntroduction, Long workedPeriod, Integer mentoringPriceWon + ) { + Assert.hasText(name, "name must not be blank"); + Assert.hasText(email, "email must not be blank"); + this.name = name; + this.email = email; + this.oneLineIntroduction = oneLineIntroduction; + this.detailedIntroduction = detailedIntroduction; + this.workedPeriod = workedPeriod; + this.mentoringPriceWon = mentoringPriceWon; + } + + public void replaceTags(Collection tags) { + this.tags.clear(); + if (tags != null && !tags.isEmpty()) { + this.tags.addAll(tags); + } + } + + public void replaceCategories(Collection categories) { + this.categories.clear(); + if (categories != null && !categories.isEmpty()) { + this.categories.addAll(categories); + } + } + + public void syncCareers(List updates) { + List careerUpdates = updates != null ? updates : List.of(); + + validateCareerUpdates(careerUpdates); + + Map careerById = careers.stream() + .filter(career -> career.getId() != null) + .collect(Collectors.toMap( + ExpertCareer::getId, + Function.identity(), + (a, b) -> a + )); + + Set requestedIds = careerUpdates.stream() + .map(ExpertCareerUpdate::id) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + + careers.removeIf(career -> + career.getId() != null && !requestedIds.contains(career.getId()) + ); + + for (ExpertCareerUpdate update : careerUpdates) { + if (update.id() == null) { + careers.add(ExpertCareer.of( + this, + update.orderIndex(), + update.careerTitle(), + update.careerExplanation(), + update.careerStartedAt(), + update.careerEndedAt() + )); + continue; + } + + ExpertCareer career = careerById.get(update.id()); + if (career == null) { + throw new ExpertException(ExpertErrorType.EXPERT_CAREER_INVALID); + } + + career.update( + update.orderIndex(), + update.careerTitle(), + update.careerExplanation(), + update.careerStartedAt(), + update.careerEndedAt() + ); + } + } + + private void validateCareerUpdates(List careerUpdates) { + Set orderIndexes = careerUpdates.stream() + .map(ExpertCareerUpdate::orderIndex) + .collect(Collectors.toSet()); + + Set requestedIds = careerUpdates.stream() + .map(ExpertCareerUpdate::id) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + + long requestedIdCount = careerUpdates.stream() + .map(ExpertCareerUpdate::id) + .filter(Objects::nonNull) + .count(); + + boolean hasDuplicateOrderIndex = orderIndexes.size() != careerUpdates.size(); + boolean hasDuplicateIds = requestedIds.size() != requestedIdCount; + boolean hasInvalidPeriod = careerUpdates.stream().anyMatch(update -> + update.orderIndex() == null + || update.orderIndex() < 0 + || update.careerStartedAt() == null + || update.careerEndedAt() == null + || update.careerStartedAt().isAfter(update.careerEndedAt()) + ); + + if (hasDuplicateOrderIndex || hasDuplicateIds || hasInvalidPeriod) { + throw new ExpertException(ExpertErrorType.EXPERT_CAREER_INVALID); + } + } } diff --git a/src/main/java/starlight/domain/expert/entity/ExpertCareer.java b/src/main/java/starlight/domain/expert/entity/ExpertCareer.java index cd151cae..964699ea 100644 --- a/src/main/java/starlight/domain/expert/entity/ExpertCareer.java +++ b/src/main/java/starlight/domain/expert/entity/ExpertCareer.java @@ -44,7 +44,8 @@ public static ExpertCareer of(Expert expert, int orderIndex, String title, Strin return expertCareer; } - public void update(String title, String explanation, LocalDateTime startedAt, LocalDateTime endedAt) { + public void update(int orderIndex, String title, String explanation, LocalDateTime startedAt, LocalDateTime endedAt) { + this.orderIndex = orderIndex; this.careerTitle = title; this.careerExplanation = explanation; this.careerStartedAt = startedAt; diff --git a/src/main/java/starlight/domain/expert/enumerate/ExpertActiveStatus.java b/src/main/java/starlight/domain/expert/enumerate/ExpertActiveStatus.java new file mode 100644 index 00000000..198209ff --- /dev/null +++ b/src/main/java/starlight/domain/expert/enumerate/ExpertActiveStatus.java @@ -0,0 +1,14 @@ +package starlight.domain.expert.enumerate; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ExpertActiveStatus { + + ACTIVE("활동중"), + INACTIVE("비활동중"); + + private final String description; +} diff --git a/src/main/java/starlight/domain/expert/exception/ExpertErrorType.java b/src/main/java/starlight/domain/expert/exception/ExpertErrorType.java index e8179cd8..4453c73a 100644 --- a/src/main/java/starlight/domain/expert/exception/ExpertErrorType.java +++ b/src/main/java/starlight/domain/expert/exception/ExpertErrorType.java @@ -9,8 +9,10 @@ @RequiredArgsConstructor public enum ExpertErrorType implements ErrorType { - EXPERT_QUERY_ERROR(HttpStatus.NOT_FOUND, "전문가 정보를 조회하는 중에 오류가 발생했습니다."), - EXPERT_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 전문가를 찾을 수 없습니다."); + EXPERT_QUERY_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "전문가 정보를 조회하는 중에 오류가 발생했습니다."), + EXPERT_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 전문가를 찾을 수 없습니다."), + EXPERT_NOT_ACTIVE(HttpStatus.FORBIDDEN, "비활성 전문가입니다."), + EXPERT_CAREER_INVALID(HttpStatus.BAD_REQUEST, "경력 정보가 올바르지 않습니다."); ; private final HttpStatus status; diff --git a/src/test/java/starlight/adapter/aireport/infrastructure/storage/NcpPresignedUrlProviderUnitTest.java b/src/test/java/starlight/adapter/aireport/infrastructure/storage/NcpPresignedUrlProviderUnitTest.java index 22344d18..2b3d8c40 100644 --- a/src/test/java/starlight/adapter/aireport/infrastructure/storage/NcpPresignedUrlProviderUnitTest.java +++ b/src/test/java/starlight/adapter/aireport/infrastructure/storage/NcpPresignedUrlProviderUnitTest.java @@ -14,7 +14,8 @@ import software.amazon.awssdk.services.s3.presigner.S3Presigner; import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; -import starlight.adapter.aireport.infrastructure.storage.NcpPresignedUrlProvider; +import starlight.adapter.shared.infrastructure.storage.NcpPresignedUrlProvider; +import starlight.domain.aireport.exception.AiReportException; import starlight.shared.dto.infrastructure.PreSignedUrlResponse; import java.net.URL; @@ -149,8 +150,8 @@ void makePublic_Failure_S3Exception() { // when & then assertThatThrownBy(() -> presignedUrlProvider.makePublic(objectUrl)) - .isInstanceOf(RuntimeException.class) - .hasMessageContaining("객체 공개 처리 실패"); + .isInstanceOf(AiReportException.class) + .hasMessageContaining("객체 공개 처리에 실패했습니다."); verify(ncpS3Client).putObjectAcl(any(PutObjectAclRequest.class)); } @@ -178,4 +179,4 @@ void makePublic_InvalidUrl_NoPath() { .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("path가 없습니다"); } -} \ No newline at end of file +} diff --git a/src/test/java/starlight/adapter/expert/webapi/ExpertControllerTest.java b/src/test/java/starlight/adapter/expert/webapi/ExpertControllerTest.java index a4c7a490..42d69a89 100644 --- a/src/test/java/starlight/adapter/expert/webapi/ExpertControllerTest.java +++ b/src/test/java/starlight/adapter/expert/webapi/ExpertControllerTest.java @@ -64,7 +64,7 @@ class ExpertControllerTest { void listAll() throws Exception { ExpertDetailResult e1 = expertResult(1L, "홍길동", Set.of(TagCategory.GROWTH_STRATEGY, TagCategory.TEAM_CAPABILITY)); - when(expertDetailQuery.searchAll()).thenReturn(List.of(e1)); + when(expertDetailQuery.searchAllActive()).thenReturn(List.of(e1)); mockMvc.perform(get("/v1/experts")) .andExpect(status().isOk()) diff --git a/src/test/java/starlight/application/aireport/AiReportServiceIntegrationTest.java b/src/test/java/starlight/application/aireport/AiReportServiceIntegrationTest.java index d1da1f1b..5de51af5 100644 --- a/src/test/java/starlight/application/aireport/AiReportServiceIntegrationTest.java +++ b/src/test/java/starlight/application/aireport/AiReportServiceIntegrationTest.java @@ -22,8 +22,8 @@ import starlight.application.aireport.required.ReportGraderPort; import starlight.application.businessplan.required.BusinessPlanCommandPort; import starlight.application.businessplan.required.BusinessPlanQueryPort; -import starlight.application.aireport.required.BusinessPlanCommandLookUpPort; -import starlight.application.aireport.required.BusinessPlanQueryLookUpPort; +import starlight.application.aireport.required.BusinessPlanCommandLookupPort; +import starlight.application.aireport.required.BusinessPlanQueryLookupPort; import starlight.application.businessplan.util.BusinessPlanContentExtractor; import starlight.domain.aireport.entity.AiReport; import starlight.domain.businessplan.entity.BusinessPlan; @@ -193,8 +193,8 @@ BusinessPlanContentExtractor businessPlanContentExtractor() { } @Bean - BusinessPlanCommandLookUpPort businessPlanCommandLookUpPort(BusinessPlanRepository businessPlanRepository) { - return new BusinessPlanCommandLookUpPort() { + BusinessPlanCommandLookupPort businessPlanCommandLookupPort(BusinessPlanRepository businessPlanRepository) { + return new BusinessPlanCommandLookupPort() { @Override public BusinessPlan save(BusinessPlan plan) { return businessPlanRepository.save(plan); @@ -210,8 +210,8 @@ public Long createBusinessPlanWithPdf(String title, String pdfUrl, Long memberId } @Bean - BusinessPlanQueryLookUpPort businessPlanQueryLookUpPort(BusinessPlanRepository businessPlanRepository) { - return new BusinessPlanQueryLookUpPort() { + BusinessPlanQueryLookupPort businessPlanQueryLookupPort(BusinessPlanRepository businessPlanRepository) { + return new BusinessPlanQueryLookupPort() { @Override public BusinessPlan findByIdOrThrow(Long id) { return businessPlanRepository.findById(id) diff --git a/src/test/java/starlight/application/aireport/AiReportServiceUnitTest.java b/src/test/java/starlight/application/aireport/AiReportServiceUnitTest.java index 4529c9a9..1cc0c191 100644 --- a/src/test/java/starlight/application/aireport/AiReportServiceUnitTest.java +++ b/src/test/java/starlight/application/aireport/AiReportServiceUnitTest.java @@ -9,8 +9,8 @@ import starlight.application.aireport.required.AiReportQueryPort; import starlight.application.aireport.required.AiReportCommandPort; import starlight.application.aireport.required.OcrProviderPort; -import starlight.application.aireport.required.BusinessPlanCommandLookUpPort; -import starlight.application.aireport.required.BusinessPlanQueryLookUpPort; +import starlight.application.aireport.required.BusinessPlanCommandLookupPort; +import starlight.application.aireport.required.BusinessPlanQueryLookupPort; import starlight.application.businessplan.util.BusinessPlanContentExtractor; import starlight.domain.aireport.entity.AiReport; import starlight.domain.aireport.exception.AiReportErrorType; @@ -33,8 +33,8 @@ @DisplayName("AiReportService 유닛 테스트") class AiReportServiceUnitTest { - private final BusinessPlanCommandLookUpPort businessPlanCommandLookUpPort = mock(BusinessPlanCommandLookUpPort.class); - private final BusinessPlanQueryLookUpPort businessPlanQueryLookUpPort = mock(BusinessPlanQueryLookUpPort.class); + private final BusinessPlanCommandLookupPort businessPlanCommandLookupPort = mock(BusinessPlanCommandLookupPort.class); + private final BusinessPlanQueryLookupPort businessPlanQueryLookupPort = mock(BusinessPlanQueryLookupPort.class); private final AiReportQueryPort aiReportQuery = mock(AiReportQueryPort.class); private final AiReportCommandPort aiReportCommand = mock(AiReportCommandPort.class); private final ReportGraderPort aiReportGrader = mock(ReportGraderPort.class); @@ -55,7 +55,7 @@ void gradeBusinessPlan_createsNewReport() { when(plan.getId()).thenReturn(planId); when(plan.isOwnedBy(memberId)).thenReturn(true); when(plan.areWritingCompleted()).thenReturn(true); - when(businessPlanQueryLookUpPort.findByIdOrThrow(planId)).thenReturn(plan); + when(businessPlanQueryLookupPort.findByIdOrThrow(planId)).thenReturn(plan); when(aiReportQuery.findByBusinessPlanId(planId)).thenReturn(Optional.empty()); String extractedContent = "사업계획서 내용"; @@ -92,9 +92,9 @@ void gradeBusinessPlan_createsNewReport() { when(savedReport.getBusinessPlanId()).thenReturn(planId); when(savedReport.getRawJson()).thenReturn(RawJson.create(rawJson)); when(aiReportCommand.save(any(AiReport.class))).thenReturn(savedReport); - when(businessPlanCommandLookUpPort.save(any(BusinessPlan.class))).thenReturn(plan); + when(businessPlanCommandLookupPort.save(any(BusinessPlan.class))).thenReturn(plan); - sut = new AiReportService(businessPlanCommandLookUpPort, businessPlanQueryLookUpPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); + sut = new AiReportService(businessPlanCommandLookupPort, businessPlanQueryLookupPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); // when AiReportResult result = sut.gradeBusinessPlan(planId, memberId); @@ -103,7 +103,7 @@ void gradeBusinessPlan_createsNewReport() { assertThat(result).isNotNull(); verify(plan).updateStatus(PlanStatus.AI_REVIEWED); verify(aiReportCommand).save(any(AiReport.class)); - verify(businessPlanCommandLookUpPort).save(plan); + verify(businessPlanCommandLookupPort).save(plan); } @Test @@ -116,7 +116,7 @@ void gradeBusinessPlan_updatesExistingReport() { when(plan.getId()).thenReturn(planId); when(plan.isOwnedBy(memberId)).thenReturn(true); when(plan.areWritingCompleted()).thenReturn(true); - when(businessPlanQueryLookUpPort.findByIdOrThrow(planId)).thenReturn(plan); + when(businessPlanQueryLookupPort.findByIdOrThrow(planId)).thenReturn(plan); AiReport existingReport = mock(AiReport.class); when(aiReportQuery.findByBusinessPlanId(planId)).thenReturn(Optional.of(existingReport)); @@ -154,9 +154,9 @@ void gradeBusinessPlan_updatesExistingReport() { when(existingReport.getBusinessPlanId()).thenReturn(planId); when(existingReport.getRawJson()).thenReturn(RawJson.create(rawJson)); when(aiReportCommand.save(existingReport)).thenReturn(existingReport); - when(businessPlanCommandLookUpPort.save(any(BusinessPlan.class))).thenReturn(plan); + when(businessPlanCommandLookupPort.save(any(BusinessPlan.class))).thenReturn(plan); - sut = new AiReportService(businessPlanCommandLookUpPort, businessPlanQueryLookUpPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); + sut = new AiReportService(businessPlanCommandLookupPort, businessPlanQueryLookupPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); // when AiReportResult result = sut.gradeBusinessPlan(planId, memberId); @@ -176,9 +176,9 @@ void gradeBusinessPlan_throwsExceptionWhenNotOwner() { Long memberId = 1L; BusinessPlan plan = mock(BusinessPlan.class); when(plan.isOwnedBy(memberId)).thenReturn(false); - when(businessPlanQueryLookUpPort.findByIdOrThrow(planId)).thenReturn(plan); + when(businessPlanQueryLookupPort.findByIdOrThrow(planId)).thenReturn(plan); - sut = new AiReportService(businessPlanCommandLookUpPort, businessPlanQueryLookUpPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); + sut = new AiReportService(businessPlanCommandLookupPort, businessPlanQueryLookupPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); // when & then assertThatThrownBy(() -> sut.gradeBusinessPlan(planId, memberId)) @@ -196,9 +196,9 @@ void gradeBusinessPlan_throwsExceptionWhenNotCompleted() { BusinessPlan plan = mock(BusinessPlan.class); when(plan.isOwnedBy(memberId)).thenReturn(true); when(plan.areWritingCompleted()).thenReturn(false); - when(businessPlanQueryLookUpPort.findByIdOrThrow(planId)).thenReturn(plan); + when(businessPlanQueryLookupPort.findByIdOrThrow(planId)).thenReturn(plan); - sut = new AiReportService(businessPlanCommandLookUpPort, businessPlanQueryLookUpPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); + sut = new AiReportService(businessPlanCommandLookupPort, businessPlanQueryLookupPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); // when & then assertThatThrownBy(() -> sut.gradeBusinessPlan(planId, memberId)) @@ -217,7 +217,7 @@ void getAiReport_returnsResponse() { when(plan.getId()).thenReturn(planId); when(plan.isOwnedBy(memberId)).thenReturn(true); when(plan.areWritingCompleted()).thenReturn(true); - when(businessPlanQueryLookUpPort.findByIdOrThrow(planId)).thenReturn(plan); + when(businessPlanQueryLookupPort.findByIdOrThrow(planId)).thenReturn(plan); String rawJson = """ { @@ -236,7 +236,7 @@ void getAiReport_returnsResponse() { when(aiReport.getRawJson()).thenReturn(RawJson.create(rawJson)); when(aiReportQuery.findByBusinessPlanId(planId)).thenReturn(Optional.of(aiReport)); - sut = new AiReportService(businessPlanCommandLookUpPort, businessPlanQueryLookUpPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); + sut = new AiReportService(businessPlanCommandLookupPort, businessPlanQueryLookupPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); // when AiReportResult result = sut.getAiReport(planId, memberId); @@ -257,10 +257,10 @@ void getAiReport_throwsExceptionWhenNotFound() { when(plan.getId()).thenReturn(planId); when(plan.isOwnedBy(memberId)).thenReturn(true); when(plan.areWritingCompleted()).thenReturn(true); - when(businessPlanQueryLookUpPort.findByIdOrThrow(planId)).thenReturn(plan); + when(businessPlanQueryLookupPort.findByIdOrThrow(planId)).thenReturn(plan); when(aiReportQuery.findByBusinessPlanId(planId)).thenReturn(Optional.empty()); - sut = new AiReportService(businessPlanCommandLookUpPort, businessPlanQueryLookUpPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); + sut = new AiReportService(businessPlanCommandLookupPort, businessPlanQueryLookupPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); // when & then assertThatThrownBy(() -> sut.getAiReport(planId, memberId)) diff --git a/src/test/java/starlight/application/businessplan/BusinessPlanServiceIntegrationTest.java b/src/test/java/starlight/application/businessplan/BusinessPlanServiceIntegrationTest.java index 61656949..7d951fa5 100644 --- a/src/test/java/starlight/application/businessplan/BusinessPlanServiceIntegrationTest.java +++ b/src/test/java/starlight/application/businessplan/BusinessPlanServiceIntegrationTest.java @@ -12,7 +12,7 @@ import starlight.adapter.businessplan.persistence.BusinessPlanQueryJpa; import starlight.adapter.businessplan.persistence.BusinessPlanRepository; import starlight.application.businessplan.required.ChecklistGraderPort; -import starlight.application.businessplan.required.MemberLookUpPort; +import starlight.application.businessplan.required.MemberLookupPort; import starlight.domain.businessplan.entity.BusinessPlan; import starlight.domain.businessplan.entity.SubSection; import starlight.domain.businessplan.enumerate.SubSectionType; @@ -50,8 +50,8 @@ ObjectMapper objectMapper() { } @Bean - MemberLookUpPort memberLookUpPort() { - return new MemberLookUpPort() { + MemberLookupPort memberLookupPort() { + return new MemberLookupPort() { @Override public Member findByIdOrThrow(Long memberId) { Member m = mock(Member.class); diff --git a/src/test/java/starlight/application/businessplan/BusinessPlanServiceUnitTest.java b/src/test/java/starlight/application/businessplan/BusinessPlanServiceUnitTest.java index 83b25714..3fc6c64d 100644 --- a/src/test/java/starlight/application/businessplan/BusinessPlanServiceUnitTest.java +++ b/src/test/java/starlight/application/businessplan/BusinessPlanServiceUnitTest.java @@ -21,7 +21,7 @@ import starlight.domain.businessplan.enumerate.SubSectionType; import starlight.domain.businessplan.exception.BusinessPlanException; import starlight.shared.enumerate.SectionType; -import starlight.application.businessplan.required.MemberLookUpPort; +import starlight.application.businessplan.required.MemberLookupPort; import starlight.domain.member.entity.Member; import java.util.List; @@ -50,7 +50,7 @@ class BusinessPlanServiceUnitTest { private ObjectMapper objectMapper; @Mock - private MemberLookUpPort memberLookUpPort; + private MemberLookupPort memberLookupPort; @InjectMocks private BusinessPlanService sut; @@ -67,10 +67,10 @@ void setup() { when(objectMapper.writeValueAsString(any())).thenReturn("{}"); } catch (Exception ignored) { } - // memberLookUpPort 기본 스텁 + // memberLookupPort 기본 스텁 Member stubMember = mock(Member.class); when(stubMember.getName()).thenReturn("tester"); - when(memberLookUpPort.findByIdOrThrow(anyLong())).thenReturn(stubMember); + when(memberLookupPort.findByIdOrThrow(anyLong())).thenReturn(stubMember); } @Test diff --git "a/\352\260\234\353\260\234\352\260\200\354\235\264\353\223\234.md" "b/\352\260\234\353\260\234\352\260\200\354\235\264\353\223\234.md" index c45767a8..f45704e2 100644 --- "a/\352\260\234\353\260\234\352\260\200\354\235\264\353\223\234.md" +++ "b/\352\260\234\353\260\234\352\260\200\354\235\264\353\223\234.md" @@ -30,6 +30,7 @@ - Inbound `provided`는 해당 도메인의 유스케이스만 노출한다. - Outbound 포트는 소비자 도메인에서 정의한다(`application//required`). - Cross-domain 조회는 `OtherDomainLookupPort` 규칙을 따른다. +- 포트/타입 네이밍에서 `Lookup`을 한 단어로 사용한다(`LookupPort`). `LookUp` 표기는 사용하지 않는다. - 다른 도메인의 `provided` 서비스를 직접 호출하지 않는다. 소비자 도메인에 `required` 포트를 정의하고, 제공 도메인의 어댑터가 구현한다. - Response DTO는 애플리케이션 DTO로만 변환하고 엔티티를 직접 받지 않는다. - 도메인 의미가 있는 포트는 소비자 도메인 `required`에 둔다. @@ -39,6 +40,7 @@ - Provided (inbound): `*UseCase` - Required (outbound): `*Port` - Cross-domain lookup: `OtherDomainLookupPort` +- Lookup 철자: `Lookup`만 사용 (`LookUp` 금지) - 컬렉션을 함께 로딩하는 경우 이름에 컬렉션을 명시한다. - 예: `findAllWithCareersTagsCategories`, `findByIdWithCareersAndTags` - 예: `fetchExpertsWithCareersByIds` @@ -56,5 +58,21 @@ - Application 입력: `*Input` - Application 출력: `*Result` +## API 응답 규칙 +- 조회 API는 항상 데이터를 반환한다. +- 생성 API는 식별자 또는 핵심 결과만 반환한다. +- 수정/삭제 API는 기본적으로 `ApiResponse.success()`로 통일한다(응답 data 없음). +- 사용자 메시지가 필요한 액션(메일 전송 등)만 메시지 포함 응답을 사용한다. + +## 포맷팅 규칙 +- 컨트롤러 호출이 ~100자 이내면 한 줄로 유지한다. +- 인자가 래핑되면 한 줄에 한 인자로 멀티라인을 유지한다. +- 빌더나 `*Input.of(...)`는 인자가 2개 이상이면 멀티라인을 우선한다. +- 논리 단계별로 빈 줄을 넣어 구분한다(예: 조회 → 계산 → 반환). + +## 도메인 검증 규칙 +- `Assert`는 프로그래머 오류/불변식 위반에만 사용한다. +- 비즈니스 규칙 위반/사용자 입력 오류는 도메인 예외로 처리한다. + ## 로컬 실행 - `./gradlew bootRun --args='--spring.profiles.active=dev'`