diff --git a/src/main/java/ditda/backend/domain/common/auth/controller/AuthController.java b/src/main/java/ditda/backend/domain/auth/controller/AuthController.java similarity index 85% rename from src/main/java/ditda/backend/domain/common/auth/controller/AuthController.java rename to src/main/java/ditda/backend/domain/auth/controller/AuthController.java index bf3bf81..d6a26f3 100644 --- a/src/main/java/ditda/backend/domain/common/auth/controller/AuthController.java +++ b/src/main/java/ditda/backend/domain/auth/controller/AuthController.java @@ -1,4 +1,4 @@ -package ditda.backend.domain.common.auth.controller; +package ditda.backend.domain.auth.controller; import org.springframework.http.HttpHeaders; import org.springframework.web.bind.annotation.CookieValue; @@ -7,16 +7,16 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import ditda.backend.domain.common.auth.dto.AuthResult; -import ditda.backend.domain.common.auth.dto.TokenResult; -import ditda.backend.domain.common.auth.dto.request.CheckUsernameRequest; -import ditda.backend.domain.common.auth.dto.request.EmailCodeVerificationRequest; -import ditda.backend.domain.common.auth.dto.request.EmailVerificationRequest; -import ditda.backend.domain.common.auth.dto.request.LoginRequest; -import ditda.backend.domain.common.auth.dto.response.LoginResponse; -import ditda.backend.domain.common.auth.dto.response.ReissueResponse; -import ditda.backend.domain.common.auth.facade.AuthFacade; -import ditda.backend.domain.common.auth.mapper.AuthResponseMapper; +import ditda.backend.domain.auth.dto.AuthResult; +import ditda.backend.domain.auth.dto.TokenResult; +import ditda.backend.domain.auth.dto.request.CheckUsernameRequest; +import ditda.backend.domain.auth.dto.request.EmailCodeVerificationRequest; +import ditda.backend.domain.auth.dto.request.EmailVerificationRequest; +import ditda.backend.domain.auth.dto.request.LoginRequest; +import ditda.backend.domain.auth.dto.response.LoginResponse; +import ditda.backend.domain.auth.dto.response.ReissueResponse; +import ditda.backend.domain.auth.facade.AuthFacade; +import ditda.backend.domain.auth.mapper.AuthResponseMapper; import ditda.backend.global.apipayload.response.ApiResponse; import ditda.backend.global.jwt.utils.CookieUtils; import io.swagger.v3.oas.annotations.Operation; diff --git a/src/main/java/ditda/backend/domain/auth/controller/DesignerAuthController.java b/src/main/java/ditda/backend/domain/auth/controller/DesignerAuthController.java new file mode 100644 index 0000000..3d3a8b4 --- /dev/null +++ b/src/main/java/ditda/backend/domain/auth/controller/DesignerAuthController.java @@ -0,0 +1,67 @@ +package ditda.backend.domain.auth.controller; + +import org.springframework.http.HttpHeaders; +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 ditda.backend.domain.auth.dto.AuthResult; +import ditda.backend.domain.auth.dto.request.DesignerSignupRequest; +import ditda.backend.domain.auth.dto.request.PortfolioPresignRequest; +import ditda.backend.domain.auth.dto.response.PortfolioPresignResponse; +import ditda.backend.domain.auth.dto.response.SignupResponse; +import ditda.backend.domain.auth.facade.DesignerAuthFacade; +import ditda.backend.domain.auth.mapper.AuthResponseMapper; +import ditda.backend.global.apipayload.response.ApiResponse; +import ditda.backend.global.jwt.utils.CookieUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/v1/designers/auth") +@RequiredArgsConstructor +@Tag(name = "Designer Auth", description = "디자이너 회원가입 API") +public class DesignerAuthController { + + private final DesignerAuthFacade designerAuthFacade; + private final CookieUtils cookieUtils; + private final AuthResponseMapper authResponseMapper; + + @Operation( + summary = "포트폴리오 업로드 URL 발급", + description = "**[회원가입]** 이메일 인증 완료 후, 포트폴리오 파일을 S3에 올릴 presigned PUT URL을 발급합니다. " + + "PUT URL로 S3에 직접 업로드한 뒤, 응답의 key를 회원가입 요청의 portfolioKeys에 담아 전송합니다." + ) + @PostMapping("/signup/portfolio/presigned-url") + public ApiResponse issuePortfolioPresignedUrls( + @Valid @RequestBody PortfolioPresignRequest request + ) { + PortfolioPresignResponse response = designerAuthFacade.issuePortfolioPresignedUrl(request); + + return ApiResponse.onSuccess("포트폴리오 업로드 URL 발급 성공", response); + } + + @Operation( + summary = "디자이너 회원가입", + description = "**[회원가입]** 회원가입 후 자동 로그인 처리됩니다. presigned PUT URL로 업로드한 포트폴리오 key를 portfolioKeys에 담아 전송합니다." + ) + @PostMapping(value = "/signup") + public ApiResponse signup( + @Valid @RequestBody DesignerSignupRequest request, + HttpServletResponse response + ) { + AuthResult result = designerAuthFacade.signup(request); + + addRefreshTokenCookie(response, result.refreshToken()); + + return ApiResponse.onSuccess("디자이너 회원가입 성공", authResponseMapper.toSignupResponse(result)); + } + + private void addRefreshTokenCookie(HttpServletResponse response, String refreshToken) { + response.addHeader(HttpHeaders.SET_COOKIE, cookieUtils.createRefreshTokenCookie(refreshToken).toString()); + } +} diff --git a/src/main/java/ditda/backend/domain/instructor/auth/controller/InstructorAuthController.java b/src/main/java/ditda/backend/domain/auth/controller/InstructorAuthController.java similarity index 70% rename from src/main/java/ditda/backend/domain/instructor/auth/controller/InstructorAuthController.java rename to src/main/java/ditda/backend/domain/auth/controller/InstructorAuthController.java index 6314c3d..bf80c08 100644 --- a/src/main/java/ditda/backend/domain/instructor/auth/controller/InstructorAuthController.java +++ b/src/main/java/ditda/backend/domain/auth/controller/InstructorAuthController.java @@ -1,4 +1,4 @@ -package ditda.backend.domain.instructor.auth.controller; +package ditda.backend.domain.auth.controller; import org.springframework.http.HttpHeaders; import org.springframework.web.bind.annotation.PostMapping; @@ -6,11 +6,11 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import ditda.backend.domain.instructor.auth.dto.InstructorAuthResult; -import ditda.backend.domain.instructor.auth.dto.request.InstructorSignupRequest; -import ditda.backend.domain.instructor.auth.dto.response.InstructorSignupResponse; -import ditda.backend.domain.instructor.auth.facade.InstructorAuthFacade; -import ditda.backend.domain.instructor.auth.mapper.InstructorAuthResponseMapper; +import ditda.backend.domain.auth.dto.AuthResult; +import ditda.backend.domain.auth.dto.request.InstructorSignupRequest; +import ditda.backend.domain.auth.dto.response.SignupResponse; +import ditda.backend.domain.auth.facade.InstructorAuthFacade; +import ditda.backend.domain.auth.mapper.AuthResponseMapper; import ditda.backend.global.apipayload.response.ApiResponse; import ditda.backend.global.jwt.utils.CookieUtils; import io.swagger.v3.oas.annotations.Operation; @@ -27,16 +27,16 @@ public class InstructorAuthController { private final InstructorAuthFacade instructorAuthFacade; private final CookieUtils cookieUtils; - private final InstructorAuthResponseMapper authResponseMapper; + private final AuthResponseMapper authResponseMapper; @Operation(summary = "강사 회원가입", description = "**[회원가입]** 회원가입 후 자동 로그인 처리됩니다.") @PostMapping("/signup") - public ApiResponse signup( + public ApiResponse signup( @Valid @RequestBody InstructorSignupRequest request, HttpServletResponse response ) { - InstructorAuthResult result = instructorAuthFacade.signup(request); + AuthResult result = instructorAuthFacade.signup(request); addRefreshTokenCookie(response, result.refreshToken()); diff --git a/src/main/java/ditda/backend/domain/common/auth/dto/AuthResult.java b/src/main/java/ditda/backend/domain/auth/dto/AuthResult.java similarity index 52% rename from src/main/java/ditda/backend/domain/common/auth/dto/AuthResult.java rename to src/main/java/ditda/backend/domain/auth/dto/AuthResult.java index 004e2aa..1dcc2a8 100644 --- a/src/main/java/ditda/backend/domain/common/auth/dto/AuthResult.java +++ b/src/main/java/ditda/backend/domain/auth/dto/AuthResult.java @@ -1,9 +1,13 @@ -package ditda.backend.domain.common.auth.dto; +package ditda.backend.domain.auth.dto; + +import ditda.backend.domain.user.entity.enums.UserRole; public record AuthResult( Long userId, + UserRole userRole, + String name, String profileImage, diff --git a/src/main/java/ditda/backend/domain/common/auth/dto/TokenResult.java b/src/main/java/ditda/backend/domain/auth/dto/TokenResult.java similarity index 62% rename from src/main/java/ditda/backend/domain/common/auth/dto/TokenResult.java rename to src/main/java/ditda/backend/domain/auth/dto/TokenResult.java index b64e7c7..645fd11 100644 --- a/src/main/java/ditda/backend/domain/common/auth/dto/TokenResult.java +++ b/src/main/java/ditda/backend/domain/auth/dto/TokenResult.java @@ -1,4 +1,4 @@ -package ditda.backend.domain.common.auth.dto; +package ditda.backend.domain.auth.dto; public record TokenResult( String accessToken, diff --git a/src/main/java/ditda/backend/domain/common/auth/dto/request/CheckUsernameRequest.java b/src/main/java/ditda/backend/domain/auth/dto/request/CheckUsernameRequest.java similarity index 90% rename from src/main/java/ditda/backend/domain/common/auth/dto/request/CheckUsernameRequest.java rename to src/main/java/ditda/backend/domain/auth/dto/request/CheckUsernameRequest.java index a66e83c..3dd5043 100644 --- a/src/main/java/ditda/backend/domain/common/auth/dto/request/CheckUsernameRequest.java +++ b/src/main/java/ditda/backend/domain/auth/dto/request/CheckUsernameRequest.java @@ -1,4 +1,4 @@ -package ditda.backend.domain.common.auth.dto.request; +package ditda.backend.domain.auth.dto.request; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; diff --git a/src/main/java/ditda/backend/domain/designer/auth/dto/request/DesignerSignupRequest.java b/src/main/java/ditda/backend/domain/auth/dto/request/DesignerSignupRequest.java similarity index 86% rename from src/main/java/ditda/backend/domain/designer/auth/dto/request/DesignerSignupRequest.java rename to src/main/java/ditda/backend/domain/auth/dto/request/DesignerSignupRequest.java index 11339de..66dd89f 100644 --- a/src/main/java/ditda/backend/domain/designer/auth/dto/request/DesignerSignupRequest.java +++ b/src/main/java/ditda/backend/domain/auth/dto/request/DesignerSignupRequest.java @@ -1,11 +1,11 @@ -package ditda.backend.domain.designer.auth.dto.request; +package ditda.backend.domain.auth.dto.request; import java.util.List; import com.fasterxml.jackson.annotation.JsonProperty; -import ditda.backend.domain.common.term.entity.enums.TermType; -import ditda.backend.domain.designer.auth.entity.enums.BankName; +import ditda.backend.domain.designer.entity.enums.BankName; +import ditda.backend.domain.term.entity.enums.TermType; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.Email; @@ -84,7 +84,12 @@ public record DesignerSignupRequest( @NotNull(message = "계좌 정보는 필수입니다") @Valid - BankAccountRequest bankAccount + BankAccountRequest bankAccount, + + @Schema(description = "presigned URL로 업로드한 포트폴리오 key 목록 (최대 3개)", + example = "[\"portfolio/tmp/3f2b....pdf\", \"portfolio/tmp/9a1c....png\"]") + @Size(max = 3, message = "포트폴리오 파일은 최대 3개까지 업로드 가능합니다.") + List<@NotBlank(message = "포트폴리오 key는 비어 있을 수 없습니다.") String> portfolioKeys ) { public record TermRequest( diff --git a/src/main/java/ditda/backend/domain/common/auth/dto/request/EmailCodeVerificationRequest.java b/src/main/java/ditda/backend/domain/auth/dto/request/EmailCodeVerificationRequest.java similarity index 93% rename from src/main/java/ditda/backend/domain/common/auth/dto/request/EmailCodeVerificationRequest.java rename to src/main/java/ditda/backend/domain/auth/dto/request/EmailCodeVerificationRequest.java index 4300346..ae22f1b 100644 --- a/src/main/java/ditda/backend/domain/common/auth/dto/request/EmailCodeVerificationRequest.java +++ b/src/main/java/ditda/backend/domain/auth/dto/request/EmailCodeVerificationRequest.java @@ -1,4 +1,4 @@ -package ditda.backend.domain.common.auth.dto.request; +package ditda.backend.domain.auth.dto.request; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Email; diff --git a/src/main/java/ditda/backend/domain/common/auth/dto/request/EmailVerificationRequest.java b/src/main/java/ditda/backend/domain/auth/dto/request/EmailVerificationRequest.java similarity index 91% rename from src/main/java/ditda/backend/domain/common/auth/dto/request/EmailVerificationRequest.java rename to src/main/java/ditda/backend/domain/auth/dto/request/EmailVerificationRequest.java index 7a28862..a2ed4c6 100644 --- a/src/main/java/ditda/backend/domain/common/auth/dto/request/EmailVerificationRequest.java +++ b/src/main/java/ditda/backend/domain/auth/dto/request/EmailVerificationRequest.java @@ -1,4 +1,4 @@ -package ditda.backend.domain.common.auth.dto.request; +package ditda.backend.domain.auth.dto.request; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Email; diff --git a/src/main/java/ditda/backend/domain/instructor/auth/dto/request/InstructorSignupRequest.java b/src/main/java/ditda/backend/domain/auth/dto/request/InstructorSignupRequest.java similarity index 96% rename from src/main/java/ditda/backend/domain/instructor/auth/dto/request/InstructorSignupRequest.java rename to src/main/java/ditda/backend/domain/auth/dto/request/InstructorSignupRequest.java index 89c288f..dac10ca 100644 --- a/src/main/java/ditda/backend/domain/instructor/auth/dto/request/InstructorSignupRequest.java +++ b/src/main/java/ditda/backend/domain/auth/dto/request/InstructorSignupRequest.java @@ -1,10 +1,10 @@ -package ditda.backend.domain.instructor.auth.dto.request; +package ditda.backend.domain.auth.dto.request; import java.util.List; import com.fasterxml.jackson.annotation.JsonProperty; -import ditda.backend.domain.common.term.entity.enums.TermType; +import ditda.backend.domain.term.entity.enums.TermType; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.Email; diff --git a/src/main/java/ditda/backend/domain/common/auth/dto/request/LoginRequest.java b/src/main/java/ditda/backend/domain/auth/dto/request/LoginRequest.java similarity index 89% rename from src/main/java/ditda/backend/domain/common/auth/dto/request/LoginRequest.java rename to src/main/java/ditda/backend/domain/auth/dto/request/LoginRequest.java index 2fef033..7e0a3eb 100644 --- a/src/main/java/ditda/backend/domain/common/auth/dto/request/LoginRequest.java +++ b/src/main/java/ditda/backend/domain/auth/dto/request/LoginRequest.java @@ -1,4 +1,4 @@ -package ditda.backend.domain.common.auth.dto.request; +package ditda.backend.domain.auth.dto.request; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; diff --git a/src/main/java/ditda/backend/domain/auth/dto/request/PortfolioPresignRequest.java b/src/main/java/ditda/backend/domain/auth/dto/request/PortfolioPresignRequest.java new file mode 100644 index 0000000..644bcf9 --- /dev/null +++ b/src/main/java/ditda/backend/domain/auth/dto/request/PortfolioPresignRequest.java @@ -0,0 +1,19 @@ +package ditda.backend.domain.auth.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +@Schema(description = "포트폴리오 presigned URL 발급 요청") +public record PortfolioPresignRequest( + + @Schema(description = "인증 완료된 이메일", example = "testid@gmail.com") + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "올바른 이메일 형식이 아닙니다.") + String email, + + @Schema(description = "파일 Content-Type", example = "application/pdf") + @NotBlank(message = "Content-Type은 필수입니다.") + String contentType +) { +} diff --git a/src/main/java/ditda/backend/domain/common/auth/dto/response/LoginResponse.java b/src/main/java/ditda/backend/domain/auth/dto/response/LoginResponse.java similarity index 71% rename from src/main/java/ditda/backend/domain/common/auth/dto/response/LoginResponse.java rename to src/main/java/ditda/backend/domain/auth/dto/response/LoginResponse.java index 806abff..1044852 100644 --- a/src/main/java/ditda/backend/domain/common/auth/dto/response/LoginResponse.java +++ b/src/main/java/ditda/backend/domain/auth/dto/response/LoginResponse.java @@ -1,5 +1,6 @@ -package ditda.backend.domain.common.auth.dto.response; +package ditda.backend.domain.auth.dto.response; +import ditda.backend.domain.user.entity.enums.UserRole; import io.swagger.v3.oas.annotations.media.Schema; @Schema(description = "로그인 응답") @@ -8,6 +9,9 @@ public record LoginResponse( @Schema(description = "로그인 유저 ID", example = "1") Long userId, + @Schema(description = "사용자 타입", example = "INSTRUCTOR") + UserRole userType, + @Schema(description = "사용자 이름", example = "홍길동") String name, diff --git a/src/main/java/ditda/backend/domain/auth/dto/response/PortfolioPresignResponse.java b/src/main/java/ditda/backend/domain/auth/dto/response/PortfolioPresignResponse.java new file mode 100644 index 0000000..2610156 --- /dev/null +++ b/src/main/java/ditda/backend/domain/auth/dto/response/PortfolioPresignResponse.java @@ -0,0 +1,13 @@ +package ditda.backend.domain.auth.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record PortfolioPresignResponse( + + @Schema(description = "S3 객체 key", example = "portfolio/tmp/5a5237a7-674a-47c2-91ca-6fdb28ba6b2d.pdf") + String key, + + @Schema(description = "S3 직접 업로드용 presigned PUT URL", example = "https://{bucket}.s3.{region}.amazonaws.com/portfolio/tmp/{uuid}.pdf?X-Amz-Signature=...") + String presignedUrl +) { +} diff --git a/src/main/java/ditda/backend/domain/common/auth/dto/response/ReissueResponse.java b/src/main/java/ditda/backend/domain/auth/dto/response/ReissueResponse.java similarity index 78% rename from src/main/java/ditda/backend/domain/common/auth/dto/response/ReissueResponse.java rename to src/main/java/ditda/backend/domain/auth/dto/response/ReissueResponse.java index 997261a..0476f58 100644 --- a/src/main/java/ditda/backend/domain/common/auth/dto/response/ReissueResponse.java +++ b/src/main/java/ditda/backend/domain/auth/dto/response/ReissueResponse.java @@ -1,4 +1,4 @@ -package ditda.backend.domain.common.auth.dto.response; +package ditda.backend.domain.auth.dto.response; import io.swagger.v3.oas.annotations.media.Schema; diff --git a/src/main/java/ditda/backend/domain/designer/auth/dto/response/DesignerSignupResponse.java b/src/main/java/ditda/backend/domain/auth/dto/response/SignupResponse.java similarity index 58% rename from src/main/java/ditda/backend/domain/designer/auth/dto/response/DesignerSignupResponse.java rename to src/main/java/ditda/backend/domain/auth/dto/response/SignupResponse.java index c9dcfa3..afa8118 100644 --- a/src/main/java/ditda/backend/domain/designer/auth/dto/response/DesignerSignupResponse.java +++ b/src/main/java/ditda/backend/domain/auth/dto/response/SignupResponse.java @@ -1,13 +1,17 @@ -package ditda.backend.domain.designer.auth.dto.response; +package ditda.backend.domain.auth.dto.response; +import ditda.backend.domain.user.entity.enums.UserRole; import io.swagger.v3.oas.annotations.media.Schema; -@Schema(description = "디자이너 회원가입 응답") -public record DesignerSignupResponse( +@Schema(description = "회원가입 응답") +public record SignupResponse( @Schema(description = "User ID") Long userId, + @Schema(description = "사용자 타입", example = "INSTRUCTOR") + UserRole userType, + @Schema(description = "사용자 이름", example = "홍길동") String name, diff --git a/src/main/java/ditda/backend/domain/common/auth/entity/RefreshToken.java b/src/main/java/ditda/backend/domain/auth/entity/RefreshToken.java similarity index 94% rename from src/main/java/ditda/backend/domain/common/auth/entity/RefreshToken.java rename to src/main/java/ditda/backend/domain/auth/entity/RefreshToken.java index 744d242..2c1bf68 100644 --- a/src/main/java/ditda/backend/domain/common/auth/entity/RefreshToken.java +++ b/src/main/java/ditda/backend/domain/auth/entity/RefreshToken.java @@ -1,10 +1,10 @@ -package ditda.backend.domain.common.auth.entity; +package ditda.backend.domain.auth.entity; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.time.LocalDateTime; -import ditda.backend.domain.common.user.entity.User; +import ditda.backend.domain.user.entity.User; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; diff --git a/src/main/java/ditda/backend/domain/designer/auth/event/DesignerSignedUpEvent.java b/src/main/java/ditda/backend/domain/auth/event/DesignerSignedUpEvent.java similarity index 73% rename from src/main/java/ditda/backend/domain/designer/auth/event/DesignerSignedUpEvent.java rename to src/main/java/ditda/backend/domain/auth/event/DesignerSignedUpEvent.java index 0874f19..7044423 100644 --- a/src/main/java/ditda/backend/domain/designer/auth/event/DesignerSignedUpEvent.java +++ b/src/main/java/ditda/backend/domain/auth/event/DesignerSignedUpEvent.java @@ -1,4 +1,4 @@ -package ditda.backend.domain.designer.auth.event; +package ditda.backend.domain.auth.event; import java.util.List; diff --git a/src/main/java/ditda/backend/domain/common/auth/exception/AuthErrorCode.java b/src/main/java/ditda/backend/domain/auth/exception/AuthErrorCode.java similarity index 93% rename from src/main/java/ditda/backend/domain/common/auth/exception/AuthErrorCode.java rename to src/main/java/ditda/backend/domain/auth/exception/AuthErrorCode.java index 759ff80..4b7bfe3 100644 --- a/src/main/java/ditda/backend/domain/common/auth/exception/AuthErrorCode.java +++ b/src/main/java/ditda/backend/domain/auth/exception/AuthErrorCode.java @@ -1,4 +1,4 @@ -package ditda.backend.domain.common.auth.exception; +package ditda.backend.domain.auth.exception; import org.springframework.http.HttpStatus; diff --git a/src/main/java/ditda/backend/domain/common/auth/facade/AuthFacade.java b/src/main/java/ditda/backend/domain/auth/facade/AuthFacade.java similarity index 70% rename from src/main/java/ditda/backend/domain/common/auth/facade/AuthFacade.java rename to src/main/java/ditda/backend/domain/auth/facade/AuthFacade.java index d861ffe..8abfc14 100644 --- a/src/main/java/ditda/backend/domain/common/auth/facade/AuthFacade.java +++ b/src/main/java/ditda/backend/domain/auth/facade/AuthFacade.java @@ -1,14 +1,14 @@ -package ditda.backend.domain.common.auth.facade; +package ditda.backend.domain.auth.facade; import org.springframework.stereotype.Component; -import ditda.backend.domain.common.auth.dto.AuthResult; -import ditda.backend.domain.common.auth.dto.TokenResult; -import ditda.backend.domain.common.auth.dto.request.LoginRequest; -import ditda.backend.domain.common.auth.notification.EmailVerificationMailer; -import ditda.backend.domain.common.auth.service.AuthService; -import ditda.backend.domain.common.auth.service.EmailVerificationService; -import ditda.backend.domain.common.user.service.UserService; +import ditda.backend.domain.auth.dto.AuthResult; +import ditda.backend.domain.auth.dto.TokenResult; +import ditda.backend.domain.auth.dto.request.LoginRequest; +import ditda.backend.domain.auth.notification.EmailVerificationMailer; +import ditda.backend.domain.auth.service.AuthService; +import ditda.backend.domain.auth.service.EmailVerificationService; +import ditda.backend.domain.user.service.UserService; import lombok.RequiredArgsConstructor; @Component diff --git a/src/main/java/ditda/backend/domain/auth/facade/DesignerAuthFacade.java b/src/main/java/ditda/backend/domain/auth/facade/DesignerAuthFacade.java new file mode 100644 index 0000000..7fc5e7e --- /dev/null +++ b/src/main/java/ditda/backend/domain/auth/facade/DesignerAuthFacade.java @@ -0,0 +1,71 @@ +package ditda.backend.domain.auth.facade; + +import java.util.List; + +import org.springframework.stereotype.Component; + +import ditda.backend.domain.auth.dto.AuthResult; +import ditda.backend.domain.auth.dto.request.DesignerSignupRequest; +import ditda.backend.domain.auth.dto.request.PortfolioPresignRequest; +import ditda.backend.domain.auth.dto.response.PortfolioPresignResponse; +import ditda.backend.domain.auth.service.DesignerAuthService; +import ditda.backend.domain.auth.service.EmailVerificationService; +import ditda.backend.domain.designer.service.PortfolioService; +import ditda.backend.domain.user.service.UserService; +import ditda.backend.global.s3.dto.PresignedUpload; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class DesignerAuthFacade { + + private final DesignerAuthService designerAuthService; + private final EmailVerificationService emailVerificationService; + private final PortfolioService portfolioService; + private final UserService userService; + + public PortfolioPresignResponse issuePortfolioPresignedUrl(PortfolioPresignRequest request) { + + // 이메일 인증 여부 확인 + emailVerificationService.validateVerified(request.email()); + + PresignedUpload presignedUpload = portfolioService.generatePresignedUpload(request.contentType()); + + return new PortfolioPresignResponse(presignedUpload.key(), presignedUpload.presignedUrl()); + } + + public AuthResult signup(DesignerSignupRequest request) { + + // 이메일 인증 여부 확인 + emailVerificationService.validateVerified(request.email()); + + // 아이디 및 이메일 검증 + userService.validateUsernameAvailable(request.username()); + userService.validateEmailAvailable(request.email()); + + // 포트폴리오 key 검증 + List tempKeys = request.portfolioKeys() == null ? List.of() : request.portfolioKeys(); + portfolioService.validateKeys(tempKeys); + + // 임시('portfolio/tmp') -> 정식('portfolio/') 승격 + List portfolioKeys = portfolioService.promote(tempKeys); + + // 회원가입 + AuthResult result; + try { + result = designerAuthService.signup(request, portfolioKeys); + } catch (Exception original) { + try { + portfolioService.deleteFiles(portfolioKeys); + } catch (Exception cleanupEx) { + original.addSuppressed(cleanupEx); + } + throw original; + } + + // 이메일 인증 마크 정리 + emailVerificationService.deleteVerified(request.email()); + + return result; + } +} diff --git a/src/main/java/ditda/backend/domain/auth/facade/InstructorAuthFacade.java b/src/main/java/ditda/backend/domain/auth/facade/InstructorAuthFacade.java new file mode 100644 index 0000000..266f463 --- /dev/null +++ b/src/main/java/ditda/backend/domain/auth/facade/InstructorAuthFacade.java @@ -0,0 +1,29 @@ +package ditda.backend.domain.auth.facade; + +import org.springframework.stereotype.Component; + +import ditda.backend.domain.auth.dto.AuthResult; +import ditda.backend.domain.auth.dto.request.InstructorSignupRequest; +import ditda.backend.domain.auth.service.EmailVerificationService; +import ditda.backend.domain.auth.service.InstructorAuthService; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class InstructorAuthFacade { + + private final InstructorAuthService instructorAuthService; + private final EmailVerificationService emailVerificationService; + + public AuthResult signup(InstructorSignupRequest request) { + + emailVerificationService.validateVerified(request.email()); + + AuthResult result = instructorAuthService.signup(request); + + emailVerificationService.deleteVerified(request.email()); + + return result; + } + +} diff --git a/src/main/java/ditda/backend/domain/auth/mapper/AuthResponseMapper.java b/src/main/java/ditda/backend/domain/auth/mapper/AuthResponseMapper.java new file mode 100644 index 0000000..6ea96c7 --- /dev/null +++ b/src/main/java/ditda/backend/domain/auth/mapper/AuthResponseMapper.java @@ -0,0 +1,36 @@ +package ditda.backend.domain.auth.mapper; + +import org.springframework.stereotype.Component; + +import ditda.backend.domain.auth.dto.AuthResult; +import ditda.backend.domain.auth.dto.response.LoginResponse; +import ditda.backend.domain.auth.dto.response.SignupResponse; +import ditda.backend.global.s3.manager.S3UrlResolver; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class AuthResponseMapper { + + private final S3UrlResolver s3UrlResolver; + + public LoginResponse toLoginResponse(AuthResult result) { + return new LoginResponse( + result.userId(), + result.userRole(), + result.name(), + s3UrlResolver.toPublicS3Url(result.profileImage()), + result.accessToken() + ); + } + + public SignupResponse toSignupResponse(AuthResult result) { + return new SignupResponse( + result.userId(), + result.userRole(), + result.name(), + s3UrlResolver.toPublicS3Url(result.profileImage()), + result.accessToken() + ); + } +} diff --git a/src/main/java/ditda/backend/domain/designer/auth/notification/DesignerSignupMailer.java b/src/main/java/ditda/backend/domain/auth/notification/DesignerSignupMailer.java similarity index 93% rename from src/main/java/ditda/backend/domain/designer/auth/notification/DesignerSignupMailer.java rename to src/main/java/ditda/backend/domain/auth/notification/DesignerSignupMailer.java index 5378d6f..a6d6d50 100644 --- a/src/main/java/ditda/backend/domain/designer/auth/notification/DesignerSignupMailer.java +++ b/src/main/java/ditda/backend/domain/auth/notification/DesignerSignupMailer.java @@ -1,4 +1,4 @@ -package ditda.backend.domain.designer.auth.notification; +package ditda.backend.domain.auth.notification; import java.util.List; import java.util.Map; diff --git a/src/main/java/ditda/backend/domain/designer/auth/notification/DesignerSignupNotifier.java b/src/main/java/ditda/backend/domain/auth/notification/DesignerSignupNotifier.java similarity index 88% rename from src/main/java/ditda/backend/domain/designer/auth/notification/DesignerSignupNotifier.java rename to src/main/java/ditda/backend/domain/auth/notification/DesignerSignupNotifier.java index 5f7a102..b2f50ef 100644 --- a/src/main/java/ditda/backend/domain/designer/auth/notification/DesignerSignupNotifier.java +++ b/src/main/java/ditda/backend/domain/auth/notification/DesignerSignupNotifier.java @@ -1,4 +1,4 @@ -package ditda.backend.domain.designer.auth.notification; +package ditda.backend.domain.auth.notification; import java.time.Duration; import java.util.List; @@ -7,9 +7,9 @@ import org.springframework.transaction.event.TransactionPhase; import org.springframework.transaction.event.TransactionalEventListener; -import ditda.backend.domain.designer.auth.event.DesignerSignedUpEvent; +import ditda.backend.domain.auth.event.DesignerSignedUpEvent; import ditda.backend.global.config.AdminProperties; -import ditda.backend.global.s3.S3PresignedUrlGenerator; +import ditda.backend.global.s3.manager.S3PresignedUrlGenerator; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/ditda/backend/domain/common/auth/notification/EmailVerificationMailer.java b/src/main/java/ditda/backend/domain/auth/notification/EmailVerificationMailer.java similarity index 90% rename from src/main/java/ditda/backend/domain/common/auth/notification/EmailVerificationMailer.java rename to src/main/java/ditda/backend/domain/auth/notification/EmailVerificationMailer.java index 1ff6bd6..e9041d0 100644 --- a/src/main/java/ditda/backend/domain/common/auth/notification/EmailVerificationMailer.java +++ b/src/main/java/ditda/backend/domain/auth/notification/EmailVerificationMailer.java @@ -1,4 +1,4 @@ -package ditda.backend.domain.common.auth.notification; +package ditda.backend.domain.auth.notification; import java.util.Map; diff --git a/src/main/java/ditda/backend/domain/common/auth/repository/RefreshTokenRepository.java b/src/main/java/ditda/backend/domain/auth/repository/RefreshTokenRepository.java similarity index 88% rename from src/main/java/ditda/backend/domain/common/auth/repository/RefreshTokenRepository.java rename to src/main/java/ditda/backend/domain/auth/repository/RefreshTokenRepository.java index faa28db..d68821b 100644 --- a/src/main/java/ditda/backend/domain/common/auth/repository/RefreshTokenRepository.java +++ b/src/main/java/ditda/backend/domain/auth/repository/RefreshTokenRepository.java @@ -1,4 +1,4 @@ -package ditda.backend.domain.common.auth.repository; +package ditda.backend.domain.auth.repository; import java.time.LocalDateTime; import java.util.Optional; @@ -8,7 +8,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -import ditda.backend.domain.common.auth.entity.RefreshToken; +import ditda.backend.domain.auth.entity.RefreshToken; public interface RefreshTokenRepository extends JpaRepository { diff --git a/src/main/java/ditda/backend/domain/common/auth/scheduler/RefreshTokenScheduler.java b/src/main/java/ditda/backend/domain/auth/scheduler/RefreshTokenScheduler.java similarity index 84% rename from src/main/java/ditda/backend/domain/common/auth/scheduler/RefreshTokenScheduler.java rename to src/main/java/ditda/backend/domain/auth/scheduler/RefreshTokenScheduler.java index 64390dd..48a8c7c 100644 --- a/src/main/java/ditda/backend/domain/common/auth/scheduler/RefreshTokenScheduler.java +++ b/src/main/java/ditda/backend/domain/auth/scheduler/RefreshTokenScheduler.java @@ -1,9 +1,9 @@ -package ditda.backend.domain.common.auth.scheduler; +package ditda.backend.domain.auth.scheduler; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; -import ditda.backend.domain.common.auth.service.AuthService; +import ditda.backend.domain.auth.service.AuthService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/ditda/backend/domain/common/auth/service/AuthService.java b/src/main/java/ditda/backend/domain/auth/service/AuthService.java similarity index 85% rename from src/main/java/ditda/backend/domain/common/auth/service/AuthService.java rename to src/main/java/ditda/backend/domain/auth/service/AuthService.java index ee487b2..371abd4 100644 --- a/src/main/java/ditda/backend/domain/common/auth/service/AuthService.java +++ b/src/main/java/ditda/backend/domain/auth/service/AuthService.java @@ -1,4 +1,4 @@ -package ditda.backend.domain.common.auth.service; +package ditda.backend.domain.auth.service; import java.time.LocalDateTime; import java.util.UUID; @@ -7,13 +7,13 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import ditda.backend.domain.common.auth.dto.AuthResult; -import ditda.backend.domain.common.auth.dto.TokenResult; -import ditda.backend.domain.common.auth.dto.request.LoginRequest; -import ditda.backend.domain.common.auth.entity.RefreshToken; -import ditda.backend.domain.common.auth.repository.RefreshTokenRepository; -import ditda.backend.domain.common.user.entity.User; -import ditda.backend.domain.common.user.service.UserService; +import ditda.backend.domain.auth.dto.AuthResult; +import ditda.backend.domain.auth.dto.TokenResult; +import ditda.backend.domain.auth.dto.request.LoginRequest; +import ditda.backend.domain.auth.entity.RefreshToken; +import ditda.backend.domain.auth.repository.RefreshTokenRepository; +import ditda.backend.domain.user.entity.User; +import ditda.backend.domain.user.service.UserService; import ditda.backend.global.apipayload.code.GeneralErrorCode; import ditda.backend.global.apipayload.exception.GeneralException; import ditda.backend.global.hash.RefreshTokenHasher; @@ -33,28 +33,23 @@ public class AuthService { private final RefreshTokenRepository refreshTokenRepository; @Transactional - public TokenResult issueTokens(Long userId) { + public TokenResult issueTokens(User user) { + + Long userId = user.getId(); // 1. 만료된 토큰 삭제 refreshTokenRepository.deleteExpiredByUserId(userId, LocalDateTime.now()); // 2. sessionId(로그인 기기 식별) 및 JWT 토큰 발급 String sessionId = UUID.randomUUID().toString(); - String accessToken = jwtTokenProvider.generateAccessToken(userId); + String accessToken = jwtTokenProvider.generateAccessToken(userId, user.getRole()); String refreshToken = jwtTokenProvider.generateRefreshToken(userId, sessionId); // 3. DB에는 해시 값으로 저장 String refreshTokenHash = refreshTokenHasher.hash(refreshToken); LocalDateTime expiresAt = jwtTokenProvider.getExpiration(refreshToken); - refreshTokenRepository.save( - RefreshToken.createRefreshToken( - userService.getReferenceById(userId), - sessionId, - refreshTokenHash, - expiresAt - ) - ); + refreshTokenRepository.save(RefreshToken.createRefreshToken(user, sessionId, refreshTokenHash, expiresAt)); return new TokenResult(accessToken, refreshToken); } @@ -71,10 +66,11 @@ public AuthResult login(LoginRequest request) { } // 3. Access / Refresh 토큰 발급 - TokenResult tokens = issueTokens(user.getId()); + TokenResult tokens = issueTokens(user); return new AuthResult( user.getId(), + user.getRole(), user.getName(), user.getProfileImage(), tokens.accessToken(), @@ -134,7 +130,8 @@ public TokenResult reissue(String refreshToken) { } // 5. 새 Access / Refresh 토큰 발급. (sessionId는 유지) - String newAccessToken = jwtTokenProvider.generateAccessToken(userId); + User user = userService.findById(userId); + String newAccessToken = jwtTokenProvider.generateAccessToken(userId, user.getRole()); String newRefreshToken = jwtTokenProvider.generateRefreshToken(userId, sessionId); String newRefreshTokenHash = refreshTokenHasher.hash(newRefreshToken); LocalDateTime newExpiresAt = jwtTokenProvider.getExpiration(newRefreshToken); diff --git a/src/main/java/ditda/backend/domain/designer/auth/service/DesignerAuthService.java b/src/main/java/ditda/backend/domain/auth/service/DesignerAuthService.java similarity index 68% rename from src/main/java/ditda/backend/domain/designer/auth/service/DesignerAuthService.java rename to src/main/java/ditda/backend/domain/auth/service/DesignerAuthService.java index 75c229b..a31a1e4 100644 --- a/src/main/java/ditda/backend/domain/designer/auth/service/DesignerAuthService.java +++ b/src/main/java/ditda/backend/domain/auth/service/DesignerAuthService.java @@ -1,4 +1,4 @@ -package ditda.backend.domain.designer.auth.service; +package ditda.backend.domain.auth.service; import java.time.LocalDateTime; import java.util.List; @@ -8,18 +8,18 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import ditda.backend.domain.common.auth.dto.TokenResult; -import ditda.backend.domain.common.auth.service.AuthService; -import ditda.backend.domain.common.term.dto.TermAgreement; -import ditda.backend.domain.common.term.service.TermService; -import ditda.backend.domain.common.user.entity.User; -import ditda.backend.domain.common.user.entity.enums.UserRole; -import ditda.backend.domain.common.user.service.UserService; -import ditda.backend.domain.designer.auth.dto.DesignerAuthResult; -import ditda.backend.domain.designer.auth.dto.request.DesignerSignupRequest; -import ditda.backend.domain.designer.auth.entity.Designer; -import ditda.backend.domain.designer.auth.event.DesignerSignedUpEvent; -import ditda.backend.domain.designer.auth.repository.DesignerRepository; +import ditda.backend.domain.auth.dto.AuthResult; +import ditda.backend.domain.auth.dto.TokenResult; +import ditda.backend.domain.auth.dto.request.DesignerSignupRequest; +import ditda.backend.domain.auth.event.DesignerSignedUpEvent; +import ditda.backend.domain.designer.entity.Designer; +import ditda.backend.domain.designer.repository.DesignerRepository; +import ditda.backend.domain.designer.service.PortfolioService; +import ditda.backend.domain.term.dto.TermAgreement; +import ditda.backend.domain.term.service.TermService; +import ditda.backend.domain.user.entity.User; +import ditda.backend.domain.user.entity.enums.UserRole; +import ditda.backend.domain.user.service.UserService; import lombok.RequiredArgsConstructor; @Service @@ -37,7 +37,7 @@ public class DesignerAuthService { private final ApplicationEventPublisher eventPublisher; @Transactional - public DesignerAuthResult signup(DesignerSignupRequest request, List portfolioKeys) { + public AuthResult signup(DesignerSignupRequest request, List portfolioKeys) { // 아이디 및 이메일 검증 userService.validateUsernameAvailable(request.username()); @@ -65,13 +65,14 @@ public DesignerAuthResult signup(DesignerSignupRequest request, List por request.bankAccount().accountNumber(), request.bankAccount().accountHolder() ); - designerRepository.save(designer); + // 왜인지 모르겠지만 save로 했을때는 user만 저장. Designer는 저장 X + designerRepository.saveAndFlush(designer); // 포트폴리오 S3 key를 DB에 일괄 저장 portfolioService.savePortfolios(designer, portfolioKeys); // accessToken&refreshToken 발급 - TokenResult tokens = authService.issueTokens(user.getId()); + TokenResult tokens = authService.issueTokens(user); // 관리자 알림 이벤트 발행 eventPublisher.publishEvent(new DesignerSignedUpEvent( @@ -81,8 +82,9 @@ public DesignerAuthResult signup(DesignerSignupRequest request, List por portfolioKeys )); - return new DesignerAuthResult( + return new AuthResult( user.getId(), + user.getRole(), user.getName(), user.getProfileImage(), tokens.accessToken(), diff --git a/src/main/java/ditda/backend/domain/common/auth/service/EmailVerificationService.java b/src/main/java/ditda/backend/domain/auth/service/EmailVerificationService.java similarity index 95% rename from src/main/java/ditda/backend/domain/common/auth/service/EmailVerificationService.java rename to src/main/java/ditda/backend/domain/auth/service/EmailVerificationService.java index 096b894..aaa0ede 100644 --- a/src/main/java/ditda/backend/domain/common/auth/service/EmailVerificationService.java +++ b/src/main/java/ditda/backend/domain/auth/service/EmailVerificationService.java @@ -1,4 +1,4 @@ -package ditda.backend.domain.common.auth.service; +package ditda.backend.domain.auth.service; import java.security.SecureRandom; import java.time.Duration; @@ -6,7 +6,7 @@ import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; -import ditda.backend.domain.common.auth.exception.AuthErrorCode; +import ditda.backend.domain.auth.exception.AuthErrorCode; import ditda.backend.global.apipayload.exception.GeneralException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/ditda/backend/domain/instructor/auth/service/InstructorAuthService.java b/src/main/java/ditda/backend/domain/auth/service/InstructorAuthService.java similarity index 58% rename from src/main/java/ditda/backend/domain/instructor/auth/service/InstructorAuthService.java rename to src/main/java/ditda/backend/domain/auth/service/InstructorAuthService.java index a60b14e..f86ff4a7 100644 --- a/src/main/java/ditda/backend/domain/instructor/auth/service/InstructorAuthService.java +++ b/src/main/java/ditda/backend/domain/auth/service/InstructorAuthService.java @@ -1,4 +1,4 @@ -package ditda.backend.domain.instructor.auth.service; +package ditda.backend.domain.auth.service; import java.time.LocalDateTime; import java.util.List; @@ -7,17 +7,16 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import ditda.backend.domain.common.auth.dto.TokenResult; -import ditda.backend.domain.common.auth.service.AuthService; -import ditda.backend.domain.common.term.dto.TermAgreement; -import ditda.backend.domain.common.term.service.TermService; -import ditda.backend.domain.common.user.entity.User; -import ditda.backend.domain.common.user.entity.enums.UserRole; -import ditda.backend.domain.common.user.service.UserService; -import ditda.backend.domain.instructor.auth.dto.InstructorAuthResult; -import ditda.backend.domain.instructor.auth.dto.request.InstructorSignupRequest; -import ditda.backend.domain.instructor.auth.entity.Instructor; -import ditda.backend.domain.instructor.auth.repository.InstructorRepository; +import ditda.backend.domain.auth.dto.AuthResult; +import ditda.backend.domain.auth.dto.TokenResult; +import ditda.backend.domain.auth.dto.request.InstructorSignupRequest; +import ditda.backend.domain.instructor.entity.Instructor; +import ditda.backend.domain.instructor.repository.InstructorRepository; +import ditda.backend.domain.term.dto.TermAgreement; +import ditda.backend.domain.term.service.TermService; +import ditda.backend.domain.user.entity.User; +import ditda.backend.domain.user.entity.enums.UserRole; +import ditda.backend.domain.user.service.UserService; import lombok.RequiredArgsConstructor; @Service @@ -33,7 +32,7 @@ public class InstructorAuthService { private final PasswordEncoder passwordEncoder; @Transactional - public InstructorAuthResult signup(InstructorSignupRequest request) { + public AuthResult signup(InstructorSignupRequest request) { userService.validateUsernameAvailable(request.username()); userService.validateEmailAvailable(request.email()); @@ -51,12 +50,14 @@ public InstructorAuthResult signup(InstructorSignupRequest request) { termService.saveTerms(user, toAgreements(request.terms())); - instructorRepository.save(Instructor.createInstructor(user)); + // 왜인지 모르겠지만 save로 했을때는 user만 저장. Instructor는 저장 X + instructorRepository.saveAndFlush(Instructor.createInstructor(user)); - TokenResult tokens = authService.issueTokens(user.getId()); + TokenResult tokens = authService.issueTokens(user); - return new InstructorAuthResult( + return new AuthResult( user.getId(), + user.getRole(), user.getName(), user.getProfileImage(), tokens.accessToken(), diff --git a/src/main/java/ditda/backend/domain/commission/application/entity/CommissionApplication.java b/src/main/java/ditda/backend/domain/commission/application/entity/CommissionApplication.java new file mode 100644 index 0000000..2cfd54d --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/application/entity/CommissionApplication.java @@ -0,0 +1,82 @@ +package ditda.backend.domain.commission.application.entity; + +import ditda.backend.domain.commission.application.entity.enums.ApplicationStatus; +import ditda.backend.domain.commission.application.exception.ApplicationErrorCode; +import ditda.backend.domain.commission.core.entity.Commission; +import ditda.backend.domain.designer.entity.Designer; +import ditda.backend.global.apipayload.exception.GeneralException; +import ditda.backend.global.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +// 한 디자이너는 한 외주에 1번만 지원 +@Table( + name = "commission_applications", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_commission_application", + columnNames = {"commission_id", "designer_id"} + ) + } +) +@Getter +@Builder +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class CommissionApplication extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "commission_application_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "commission_id", nullable = false) + private Commission commission; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "designer_id", nullable = false) + private Designer designer; + + @Enumerated(EnumType.STRING) + @Column(name = "status", length = 20, nullable = false) + private ApplicationStatus status; + + public static CommissionApplication create(Commission commission, Designer designer) { + return CommissionApplication.builder() + .commission(commission) + .designer(designer) + .status(ApplicationStatus.PENDING) + .build(); + } + + public void markDraftSelected() { + if (status != ApplicationStatus.DRAFT_SUBMITTED) { + throw new GeneralException(ApplicationErrorCode.INVALID_STATUS_FOR_DRAFT_SELECTION); + } + this.status = ApplicationStatus.DRAFT_SELECTED; + } + + public void markDraftRejected() { + if (status != ApplicationStatus.DRAFT_SUBMITTED) { + throw new GeneralException(ApplicationErrorCode.INVALID_STATUS_FOR_DRAFT_REJECTION); + } + this.status = ApplicationStatus.DRAFT_REJECTED; + } +} diff --git a/src/main/java/ditda/backend/domain/commission/application/entity/enums/ApplicationStatus.java b/src/main/java/ditda/backend/domain/commission/application/entity/enums/ApplicationStatus.java new file mode 100644 index 0000000..4a35057 --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/application/entity/enums/ApplicationStatus.java @@ -0,0 +1,17 @@ +package ditda.backend.domain.commission.application.entity.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ApplicationStatus { + PENDING("지원 완료"), + ASSIGNED("1차 시안 대상자"), + DRAFT_SUBMITTED("1차 시안 제출 완료"), + DRAFT_SELECTED("최종 선택"), + APPLICATION_REJECTED("지원 탈락"), + DRAFT_REJECTED("최종 탈락"); + + private final String description; +} diff --git a/src/main/java/ditda/backend/domain/commission/application/exception/ApplicationErrorCode.java b/src/main/java/ditda/backend/domain/commission/application/exception/ApplicationErrorCode.java new file mode 100644 index 0000000..5efea94 --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/application/exception/ApplicationErrorCode.java @@ -0,0 +1,20 @@ +package ditda.backend.domain.commission.application.exception; + +import org.springframework.http.HttpStatus; + +import ditda.backend.global.apipayload.code.BaseErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ApplicationErrorCode implements BaseErrorCode { + + INVALID_STATUS_FOR_DRAFT_SELECTION(HttpStatus.BAD_REQUEST, "APP_400_01", "1차 시안 제출 완료 상태에서만 결과를 선택할 수 있습니다."), + INVALID_STATUS_FOR_DRAFT_REJECTION(HttpStatus.BAD_REQUEST, "APP_400_02", "1차 시안 제출 완료 상태에서만 탈락 처리할 수 있습니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + +} diff --git a/src/main/java/ditda/backend/domain/commission/application/repository/CommissionApplicationRepository.java b/src/main/java/ditda/backend/domain/commission/application/repository/CommissionApplicationRepository.java new file mode 100644 index 0000000..e6acbd9 --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/application/repository/CommissionApplicationRepository.java @@ -0,0 +1,12 @@ +package ditda.backend.domain.commission.application.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; + +import ditda.backend.domain.commission.application.entity.CommissionApplication; + +public interface CommissionApplicationRepository extends JpaRepository { + + List findByCommission_Id(Long commissionId); +} diff --git a/src/main/java/ditda/backend/domain/commission/application/service/ApplicationService.java b/src/main/java/ditda/backend/domain/commission/application/service/ApplicationService.java new file mode 100644 index 0000000..ff9d3b1 --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/application/service/ApplicationService.java @@ -0,0 +1,28 @@ +package ditda.backend.domain.commission.application.service; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import ditda.backend.domain.commission.application.entity.enums.ApplicationStatus; +import ditda.backend.domain.commission.application.repository.CommissionApplicationRepository; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class ApplicationService { + + private final CommissionApplicationRepository commissionApplicationRepository; + + @Transactional + public void applySelection(Long commissionId, Long selectedApplicationId) { + + commissionApplicationRepository.findByCommission_Id(commissionId) + .forEach(app -> { + if (app.getId().equals(selectedApplicationId)) { + app.markDraftSelected(); + } else if (app.getStatus() == ApplicationStatus.DRAFT_SUBMITTED) { + app.markDraftRejected(); + } + }); + } +} diff --git a/src/main/java/ditda/backend/domain/commission/category/textbook/entity/Textbook.java b/src/main/java/ditda/backend/domain/commission/category/textbook/entity/Textbook.java new file mode 100644 index 0000000..2c201d5 --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/category/textbook/entity/Textbook.java @@ -0,0 +1,56 @@ +package ditda.backend.domain.commission.category.textbook.entity; + +import ditda.backend.domain.commission.core.entity.Commission; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.MapsId; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "textbooks") +@Getter +@Builder +@AllArgsConstructor(access = lombok.AccessLevel.PRIVATE) +@NoArgsConstructor(access = lombok.AccessLevel.PROTECTED) +public class Textbook { + + @Id + @Column(name = "commission_id") + private Long id; + + @OneToOne(fetch = FetchType.LAZY) + @MapsId + @JoinColumn(name = "commission_id") + private Commission commission; + + @Column(name = "title", length = 50, nullable = false) + private String title; + + @Column(name = "instructor_name", length = 50, nullable = false) + private String instructorName; + + @Column(name = "subject", length = 50, nullable = false) + private String subject; + + public static Textbook create( + Commission commission, + String title, + String instructorName, + String subject + ) { + return Textbook.builder() + .commission(commission) + .title(title) + .instructorName(instructorName) + .subject(subject) + .build(); + } +} diff --git a/src/main/java/ditda/backend/domain/commission/category/textbook/entity/TextbookPage.java b/src/main/java/ditda/backend/domain/commission/category/textbook/entity/TextbookPage.java new file mode 100644 index 0000000..fd39611 --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/category/textbook/entity/TextbookPage.java @@ -0,0 +1,67 @@ +package ditda.backend.domain.commission.category.textbook.entity; + +import ditda.backend.domain.commission.core.entity.Commission; +import ditda.backend.domain.commission.core.entity.enums.PageType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +// 같은 페이지 종류 중복 X +@Table( + name = "textbook_pages", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_commission_required_page_type", + columnNames = {"commission_id", "page_type"} + ) + } +) +@Getter +@Builder +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TextbookPage { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "textbook_page_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "commission_id", nullable = false) + private Commission commission; + + @Enumerated(EnumType.STRING) + @Column(name = "page_type", length = 30, nullable = false) + private PageType pageType; + + @Column(name = "page_description", length = 150) + private String pageDescription; + + public static TextbookPage create( + Commission commission, + PageType pageType, + String pageDescription + ) { + return TextbookPage.builder() + .commission(commission) + .pageType(pageType) + .pageDescription(pageDescription) + .build(); + } +} diff --git a/src/main/java/ditda/backend/domain/commission/category/textbook/handler/TextbookCategoryHandler.java b/src/main/java/ditda/backend/domain/commission/category/textbook/handler/TextbookCategoryHandler.java new file mode 100644 index 0000000..36fa90e --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/category/textbook/handler/TextbookCategoryHandler.java @@ -0,0 +1,70 @@ +package ditda.backend.domain.commission.category.textbook.handler; + +import java.util.List; + +import org.springframework.stereotype.Component; + +import ditda.backend.domain.commission.category.textbook.entity.Textbook; +import ditda.backend.domain.commission.category.textbook.entity.TextbookPage; +import ditda.backend.domain.commission.category.textbook.repository.TextbookPageRepository; +import ditda.backend.domain.commission.category.textbook.repository.TextbookRepository; +import ditda.backend.domain.commission.core.dto.request.CommissionCreateRequest; +import ditda.backend.domain.commission.core.dto.request.CommissionCreateRequest.TextbookDetail; +import ditda.backend.domain.commission.core.entity.Commission; +import ditda.backend.domain.commission.core.entity.enums.CategoryType; +import ditda.backend.domain.commission.core.exception.CommissionErrorCode; +import ditda.backend.domain.commission.core.handler.CommissionCategoryHandler; +import ditda.backend.global.apipayload.exception.GeneralException; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class TextbookCategoryHandler implements CommissionCategoryHandler { + + private final TextbookRepository textbookRepository; + private final TextbookPageRepository textbookPageRepository; + + @Override + public CategoryType category() { + return CategoryType.FLYER_TEXTBOOK_COVER_INNER; + } + + @Override + public void validate(CommissionCreateRequest request) { + + TextbookDetail detail = request.textbook(); + if (detail == null) { + throw new GeneralException(CommissionErrorCode.TEXTBOOK_DETAIL_REQUIRED); + } + } + + @Override + public String buildTitle(CommissionCreateRequest request) { + + TextbookDetail detail = request.textbook(); + return detail.textbookName() + " - " + detail.instructorName(); + } + + @Override + public void saveDetail(Commission commission, CommissionCreateRequest request) { + + TextbookDetail detail = request.textbook(); + + Textbook textbook = Textbook.create( + commission, + detail.textbookName(), + detail.instructorName(), + detail.subject() + ); + textbookRepository.save(textbook); + + List pages = detail.requiredPages().stream() + .map(p -> TextbookPage.create( + commission, + p.pageType(), + p.description() + )) + .toList(); + textbookPageRepository.saveAll(pages); + } +} diff --git a/src/main/java/ditda/backend/domain/commission/category/textbook/repository/TextbookPageRepository.java b/src/main/java/ditda/backend/domain/commission/category/textbook/repository/TextbookPageRepository.java new file mode 100644 index 0000000..79b76a6 --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/category/textbook/repository/TextbookPageRepository.java @@ -0,0 +1,8 @@ +package ditda.backend.domain.commission.category.textbook.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import ditda.backend.domain.commission.category.textbook.entity.TextbookPage; + +public interface TextbookPageRepository extends JpaRepository { +} diff --git a/src/main/java/ditda/backend/domain/commission/category/textbook/repository/TextbookRepository.java b/src/main/java/ditda/backend/domain/commission/category/textbook/repository/TextbookRepository.java new file mode 100644 index 0000000..51221fd --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/category/textbook/repository/TextbookRepository.java @@ -0,0 +1,8 @@ +package ditda.backend.domain.commission.category.textbook.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import ditda.backend.domain.commission.category.textbook.entity.Textbook; + +public interface TextbookRepository extends JpaRepository { +} diff --git a/src/main/java/ditda/backend/domain/commission/core/controller/InstructorCommissionController.java b/src/main/java/ditda/backend/domain/commission/core/controller/InstructorCommissionController.java new file mode 100644 index 0000000..f19086a --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/core/controller/InstructorCommissionController.java @@ -0,0 +1,61 @@ +package ditda.backend.domain.commission.core.controller; + +import org.springframework.security.core.annotation.AuthenticationPrincipal; +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 org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import ditda.backend.domain.commission.core.dto.request.CommissionCreateRequest; +import ditda.backend.domain.commission.core.dto.response.CommissionCreateResponse; +import ditda.backend.domain.commission.core.dto.response.PlanListResponse; +import ditda.backend.domain.commission.core.facade.InstructorCommissionFacade; +import ditda.backend.domain.payment.dto.response.DepositNotifyResponse; +import ditda.backend.global.apipayload.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/v1/instructors/commissions") +@RequiredArgsConstructor +@Tag(name = "Instructor Commission", description = "강사 새 외주 작성 API") +public class InstructorCommissionController { + + private final InstructorCommissionFacade instructorCommissionFacade; + + @Operation(summary = "플랜 조회", description = "**[새 외주 작성]** 플랜 정보를 조회합니다.") + @GetMapping("/plans") + public ApiResponse getPlans() { + + PlanListResponse response = instructorCommissionFacade.getPlans(); + + return ApiResponse.onSuccess("플랜 조회 성공", response); + } + + @Operation(summary = "새 외주 생성", description = "**[새 외주 작성]** 새로운 외주를 생성합니다.") + @PostMapping + public ApiResponse createCommission( + @AuthenticationPrincipal Long instructorId, + @Valid @RequestBody CommissionCreateRequest request + ) { + + CommissionCreateResponse response = instructorCommissionFacade.createCommission(instructorId, request); + + return ApiResponse.onSuccess("외주 생성 성공", response); + } + + @Operation(summary = "입금 통보", description = "**[새 외주 결제]** 강사가 결제(입금) 완료를 통보하고 관리자에게 확인 메일을 발송합니다.") + @PostMapping("/{commissionId}/notify-deposit") + public ApiResponse notifyDeposit( + @AuthenticationPrincipal Long instructorId, + @PathVariable Long commissionId + ) { + DepositNotifyResponse response = instructorCommissionFacade.notifyDeposit(instructorId, commissionId); + + return ApiResponse.onSuccess("입금이 접수되었습니다. 관리자 확인 후 진행됩니다.", response); + } +} diff --git a/src/main/java/ditda/backend/domain/commission/core/dto/CommissionFileToSave.java b/src/main/java/ditda/backend/domain/commission/core/dto/CommissionFileToSave.java new file mode 100644 index 0000000..521705a --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/core/dto/CommissionFileToSave.java @@ -0,0 +1,12 @@ +package ditda.backend.domain.commission.core.dto; + +import java.util.List; + +import ditda.backend.domain.commission.core.entity.enums.FileKind; + +public record CommissionFileToSave( + FileKind fileKind, + List keys, + String description +) { +} diff --git a/src/main/java/ditda/backend/domain/commission/core/dto/request/CommissionCreateRequest.java b/src/main/java/ditda/backend/domain/commission/core/dto/request/CommissionCreateRequest.java new file mode 100644 index 0000000..097fac6 --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/core/dto/request/CommissionCreateRequest.java @@ -0,0 +1,185 @@ +package ditda.backend.domain.commission.core.dto.request; + +import java.time.LocalDate; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import ditda.backend.domain.commission.core.entity.enums.CategoryType; +import ditda.backend.domain.commission.core.entity.enums.ColorRole; +import ditda.backend.domain.commission.core.entity.enums.ColorSelectionMode; +import ditda.backend.domain.commission.core.entity.enums.ConceptTag; +import ditda.backend.domain.commission.core.entity.enums.FileKind; +import ditda.backend.domain.commission.core.entity.enums.PageSize; +import ditda.backend.domain.commission.core.entity.enums.PageType; +import ditda.backend.domain.commission.core.entity.enums.PlanCode; +import ditda.backend.domain.term.entity.enums.TermType; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +@Schema(description = "새 외주 작성 요청") +public record CommissionCreateRequest( + + @Schema(description = "카테고리", example = "FLYER_TEXTBOOK_COVER_INNER") + @NotNull(message = "카테고리는 필수입니다.") + CategoryType category, + + @NotNull(message = "디자인 정보는 필수입니다.") + @Valid + DesignInfo designInfo, + + // 교재 내지/외지 카테고리 전용 (카테고리에 따라 비어있을 수 있어 @NotNull 제외 -> Handler에서 검증) + @Schema(description = "교재 내지/외지 카테고리 상세") + @Valid + TextbookDetail textbook, + + @Schema(description = "첨부 파일 목록 (presign 업로드 후 받은 key)") + @Valid + List<@NotNull @Valid FileInfo> files, + + @Schema(description = "플랜", example = "PLUS") + @NotNull(message = "플랜은 필수입니다.") + PlanCode plan, + + @NotNull(message = "마감일 정보는 필수입니다.") + @Valid + DateInfo date, + + @NotNull(message = "결제 약관 동의는 필수입니다.") + @Valid + TermRequest term +) { + + @Schema(description = "디자인 정보") + public record DesignInfo( + + @Schema(description = "사이즈", example = "A4") + @NotNull(message = "사이즈는 필수입니다.") + PageSize pageSize, + + @Schema(description = "컨셉 태그", example = "[\"CUTE\", \"ELEGANT\"]") + @NotNull(message = "컨셉은 필수입니다.") + @Size(min = 1, max = 2, message = "컨셉은 1개 이상 2개 이하여야 합니다.") + List<@NotNull ConceptTag> concepts, + + @Schema(description = "추가 컨셉 설명", example = "초등 저학년 대상이라 너무 무겁지 않게") + @Size(max = 300, message = "추가 컨셉 설명은 300자 이하여야 합니다.") + String additionalConcept, + + @Schema(description = "색상 선택 모드", example = "USER_SELECTED") + @NotNull(message = "색상 선택 모드는 필수입니다.") + ColorSelectionMode colorSelectionMode, + + @Schema(description = "색상 목록 (USER_SELECTED일 때 필수)") + @Valid + List<@NotNull @Valid ColorInfo> colors + ) { + } + + // 추후 카테고리 추가될 시, 다형 역직렬화(@JsonTypeInfo)를 통해 분리 + @Schema(description = "교재 외지/내지 카테고리 상세") + public record TextbookDetail( + + @Schema(description = "교재명", example = "수학의 정석") + @NotBlank(message = "교재명은 필수입니다.") + @Size(max = 50, message = "교재명은 50자 이하여야 합니다.") + String textbookName, + + @Schema(description = "강사명", example = "홍길동") + @NotBlank(message = "강사명은 필수입니다.") + @Size(max = 50, message = "강사명은 50자 이하여야 합니다.") + String instructorName, + + @Schema(description = "과목", example = "수학") + @NotBlank(message = "과목은 필수입니다.") + @Size(max = 50, message = "과목은 50자 이하여야 합니다.") + String subject, + + @NotEmpty(message = "외주 요청 페이지는 최소 1개 이상입니다.") + @Valid + List<@NotNull @Valid RequiredPage> requiredPages + ) { + } + + @Schema(description = "색상 정보") + public record ColorInfo( + + @Schema(description = "색상 역할", example = "MAIN") + @NotNull(message = "색상 역할은 필수입니다.") + ColorRole role, + + @Schema(description = "색상 코드(#RRGGBB)", example = "#194D33") + @NotBlank(message = "색상 코드는 필수입니다.") + @Pattern(regexp = "^#[0-9A-Fa-f]{6}$", message = "색상 코드 형식이 올바르지 않습니다.") + String colorCode + ) { + } + + @Schema(description = "필요 페이지") + public record RequiredPage( + + @Schema(description = "페이지 종류", example = "INSTRUCTOR_PROFILE") + @NotNull(message = "페이지 종류는 필수입니다.") + PageType pageType, + + @Schema(description = "페이지 설명", example = "프로필은 깔끔하게") + @Size(max = 150, message = "페이지 설명은 150자 이하여야 합니다.") + String description + ) { + } + + @Schema(description = "첨부 파일") + public record FileInfo( + + @Schema(description = "파일 종류", example = "MATERIAL") + @NotNull(message = "파일 종류는 필수입니다.") + FileKind fileKind, + + @Schema(description = "presigned URL로 업로드한 첨부파일 key 목록", + example = "[\"commission/tmp/3f2b....png\", \"commission/tmp/9a1c....jpg\"]") + @NotEmpty(message = "파일 key는 최소 1개 이상입니다.") + List<@NotBlank(message = "파일 key는 비어 있을 수 없습니다.") String> keys, + + @Schema(description = "파일 설명", example = "img.04는 강사 프로필 이미지") + @NotBlank(message = "파일 설명은 필수입니다.") + @Size(max = 300, message = "파일 설명은 300자 이하여야 합니다.") + String description + ) { + } + + @Schema(description = "마감일 정보") + public record DateInfo( + + @Schema(description = "1차 시안 마감일", example = "2026-06-15") + @NotNull(message = "1차 시안 마감일은 필수입니다.") + LocalDate firstDraftDeadline, + + @Schema(description = "최종 마감일", example = "2026-06-23") + @NotNull(message = "최종 마감일은 필수입니다.") + LocalDate finalDeadline + ) { + } + + @Schema(description = "약관 동의") + public record TermRequest( + + @Schema(description = "약관 종류", example = "SETTLEMENT") + @NotNull(message = "약관 종류는 필수입니다.") + TermType type, + + @Schema(description = "약관 버전", example = "V1.0") + @NotBlank(message = "약관 버전은 필수입니다.") + String version, + + @Schema(description = "약관 동의 여부", example = "true") + @NotNull(message = "약관 동의 여부는 필수입니다.") + @JsonProperty("isAgreed") + Boolean isAgreed + ) { + } +} diff --git a/src/main/java/ditda/backend/domain/commission/core/dto/response/CommissionCreateResponse.java b/src/main/java/ditda/backend/domain/commission/core/dto/response/CommissionCreateResponse.java new file mode 100644 index 0000000..8b7a110 --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/core/dto/response/CommissionCreateResponse.java @@ -0,0 +1,57 @@ +package ditda.backend.domain.commission.core.dto.response; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +import com.fasterxml.jackson.annotation.JsonFormat; + +import ditda.backend.domain.commission.core.entity.Commission; +import ditda.backend.domain.commission.core.entity.enums.CategoryType; +import ditda.backend.domain.commission.core.entity.enums.CommissionStatus; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "새 외주 작성 응답") +public record CommissionCreateResponse( + + @Schema(description = "외주 ID", example = "1") + Long commissionId, + + @Schema(description = "제목", example = "수학의 정석 - 수학") + String title, + + @Schema(description = "카테고리", example = "FLYER_TEXTBOOK_COVER_INNER") + CategoryType category, + + @Schema(description = "상태", example = "PENDING") + CommissionStatus status, + + @Schema(description = "지원 마감일", example = "2026-06-08") + LocalDate applicationDeadline, + + @Schema(description = "1차 시안 마감일", example = "2026-06-15") + LocalDate firstDraftDeadline, + + @Schema(description = "최종 마감일", example = "2026-06-23") + LocalDate finalDeadline, + + @Schema(description = "최대 수정 횟수", example = "3") + int maxRevision, + + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Seoul") + LocalDateTime createdAt +) { + + public static CommissionCreateResponse from(Commission commission) { + return new CommissionCreateResponse( + commission.getId(), + commission.getTitle(), + commission.getCategoryType(), + commission.getStatus(), + commission.getApplicationDeadline(), + commission.getFirstDraftDeadline(), + commission.getFinalDeadline(), + commission.getMaxRevision(), + commission.getCreatedAt() + ); + } +} diff --git a/src/main/java/ditda/backend/domain/commission/core/dto/response/PlanListResponse.java b/src/main/java/ditda/backend/domain/commission/core/dto/response/PlanListResponse.java new file mode 100644 index 0000000..d8a484d --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/core/dto/response/PlanListResponse.java @@ -0,0 +1,46 @@ +package ditda.backend.domain.commission.core.dto.response; + +import java.util.Arrays; +import java.util.List; + +import ditda.backend.domain.commission.core.entity.enums.PlanCode; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "플랜 목록 조회 응답") +public record PlanListResponse( + List plans +) { + + public static PlanListResponse from() { + List plans = Arrays.stream(PlanCode.values()) + .map(PlanResponse::from) + .toList(); + + return new PlanListResponse(plans); + } + + @Schema(description = "플랜 정보") + public record PlanResponse( + + @Schema(description = "플랜 코드", example = "BASIC") + String code, + + @Schema(description = "디자이너 수", example = "3") + int designerCount, + + @Schema(description = "가격", example = "450000") + int price, + + @Schema(description = "상세 내용", example = "디자이너 3명에 대한 시안을 받아볼 수 있습니다.") + String description + ) { + public static PlanResponse from(PlanCode planCode) { + return new PlanResponse( + planCode.name(), + planCode.getDesignerCount(), + planCode.getPrice(), + planCode.getDescription() + ); + } + } +} diff --git a/src/main/java/ditda/backend/domain/commission/core/entity/Commission.java b/src/main/java/ditda/backend/domain/commission/core/entity/Commission.java new file mode 100644 index 0000000..cacdc22 --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/core/entity/Commission.java @@ -0,0 +1,157 @@ +package ditda.backend.domain.commission.core.entity; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Objects; + +import ditda.backend.domain.commission.core.entity.enums.CategoryType; +import ditda.backend.domain.commission.core.entity.enums.ColorSelectionMode; +import ditda.backend.domain.commission.core.entity.enums.CommissionStatus; +import ditda.backend.domain.commission.core.entity.enums.PageSize; +import ditda.backend.domain.commission.core.entity.enums.PlanCode; +import ditda.backend.domain.designer.entity.Designer; +import ditda.backend.domain.designer.entity.enums.DesignerLevel; +import ditda.backend.domain.instructor.entity.Instructor; +import ditda.backend.global.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "commissions") +@Getter +@Builder +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Commission extends BaseEntity { + + // 시안 지원 제출 마감 ~ 1차 시안 마감 일수 + private static final int APPLICATION_DEADLINE_OFFSET_DAYS = 7; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "commission_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "instructor_id", nullable = false) + private Instructor instructor; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "assigned_designer_id") + private Designer assignedDesigner; + + @Enumerated(EnumType.STRING) + @Column(name = "plan_code", length = 10, nullable = false) + private PlanCode planCode; + + @Column(name = "title", length = 50, nullable = false) + private String title; + + @Enumerated(EnumType.STRING) + @Column(name = "category_type", length = 50, nullable = false) + private CategoryType categoryType; + + @Enumerated(EnumType.STRING) + @Column(name = "size", length = 10, nullable = false) + private PageSize pageSize; + + @Column(name = "additional_concept", length = 300) + private String additionalConcept; + + @Enumerated(EnumType.STRING) + @Column(name = "color_selection_mode", length = 30, nullable = false) + private ColorSelectionMode colorSelectionMode; + + @Column(name = "first_draft_deadline", nullable = false) + private LocalDate firstDraftDeadline; + + // 디자이너 지원 선착순 마감일 + @Column(name = "application_deadline", nullable = false) + private LocalDate applicationDeadline; + + @Column(name = "final_deadline", nullable = false) + private LocalDate finalDeadline; + + @Enumerated(EnumType.STRING) + @Column(name = "status", length = 30, nullable = false) + private CommissionStatus status; + + @Column(name = "max_revision", nullable = false) + private int maxRevision; + + @Column(name = "selected_at") + private LocalDateTime selectedAt; + + public static Commission create( + Instructor instructor, + PlanCode planCode, + String title, + CategoryType categoryType, + PageSize pageSize, + String additionalConcept, + ColorSelectionMode colorSelectionMode, + LocalDate firstDraftDeadline, + LocalDate finalDeadline + ) { + return Commission.builder() + .instructor(instructor) + .planCode(planCode) + .title(title) + .categoryType(categoryType) + .pageSize(pageSize) + .additionalConcept(additionalConcept) + .colorSelectionMode(colorSelectionMode) + .firstDraftDeadline(firstDraftDeadline) + .applicationDeadline(firstDraftDeadline.minusDays(APPLICATION_DEADLINE_OFFSET_DAYS)) + .finalDeadline(finalDeadline) + .status(CommissionStatus.PENDING) + .maxRevision(planCode.getBaseRevision()) + .build(); + } + + public boolean isDraftListViewable(int currentDraftCount, LocalDate currentDate) { + + boolean isFirstDeadlinePassed = currentDate.isAfter(firstDraftDeadline); + return isFirstDeadlinePassed || currentDraftCount >= planCode.getDesignerCount(); + } + + public boolean isOwnedBy(Long instructorId) { + return Objects.equals(instructor.getId(), instructorId); + } + + public boolean isSelectable() { + return status == CommissionStatus.IN_PROGRESS; + } + + public boolean isDesignerSelected() { + return assignedDesigner != null; + } + + // 디자이너 모집 정원 + public int getDesignerCount() { + return planCode.getDesignerCount(); + } + + // 마감 전 매칭 확정 인원 + // 레벨별 1명 보장 슬롯은 비어있는 레벨만큼 비워두고, 잉여 인원은 선착순(designerCount - 레벨 종류 수)만 채운다 + public int matchedCount(int distinctLevels, int totalApplicants) { + + int firstComeSlots = getDesignerCount() - DesignerLevel.values().length; + int capacity = distinctLevels + firstComeSlots; + return Math.min(capacity, totalApplicants); + } +} diff --git a/src/main/java/ditda/backend/domain/commission/core/entity/CommissionColor.java b/src/main/java/ditda/backend/domain/commission/core/entity/CommissionColor.java new file mode 100644 index 0000000..7623c29 --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/core/entity/CommissionColor.java @@ -0,0 +1,66 @@ +package ditda.backend.domain.commission.core.entity; + +import ditda.backend.domain.commission.core.entity.enums.ColorRole; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +// 하나의 외주에 MAIN/SUB1/SUB2 각 1개씩만 +@Table( + name = "commission_colors", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_commission_color_role", + columnNames = {"commission_id", "role"} + ) + } +) +@Getter +@Builder +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class CommissionColor { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "commission_color_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "commission_id", nullable = false) + private Commission commission; + + @Enumerated(EnumType.STRING) + @Column(name = "role", length = 10, nullable = false) + private ColorRole role; + + @Column(name = "color_code", length = 7, nullable = false) + private String colorCode; + + public static CommissionColor create( + Commission commission, + ColorRole role, + String colorCode + ) { + return CommissionColor.builder() + .commission(commission) + .role(role) + .colorCode(colorCode) + .build(); + } +} diff --git a/src/main/java/ditda/backend/domain/commission/core/entity/CommissionConcept.java b/src/main/java/ditda/backend/domain/commission/core/entity/CommissionConcept.java new file mode 100644 index 0000000..ac7663c --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/core/entity/CommissionConcept.java @@ -0,0 +1,61 @@ +package ditda.backend.domain.commission.core.entity; + +import ditda.backend.domain.commission.core.entity.enums.ConceptTag; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +// 같은 컨셉 태그 중복 X +@Table( + name = "commission_concepts", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_commission_concept", + columnNames = {"commission_id", "concept"} + ) + } +) +@Getter +@Builder +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class CommissionConcept { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "commission_concept_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "commission_id", nullable = false) + private Commission commission; + + @Enumerated(EnumType.STRING) + @Column(name = "concept", length = 30, nullable = false) + private ConceptTag concept; + + public static CommissionConcept create( + Commission commission, + ConceptTag concept + ) { + return CommissionConcept.builder() + .commission(commission) + .concept(concept) + .build(); + } +} diff --git a/src/main/java/ditda/backend/domain/commission/core/entity/CommissionFile.java b/src/main/java/ditda/backend/domain/commission/core/entity/CommissionFile.java new file mode 100644 index 0000000..611fbf6 --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/core/entity/CommissionFile.java @@ -0,0 +1,62 @@ +package ditda.backend.domain.commission.core.entity; + +import ditda.backend.domain.commission.core.entity.enums.FileKind; +import ditda.backend.global.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "commission_files") +@Getter +@Builder +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class CommissionFile extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "commission_file_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "commission_id", nullable = false) + private Commission commission; + + @Enumerated(EnumType.STRING) + @Column(name = "file_kind", length = 20, nullable = false) + private FileKind fileKind; + + @Column(name = "file_url", nullable = false) + private String fileUrl; + + @Column(name = "description", length = 300) + private String description; + + public static CommissionFile create( + Commission commission, + FileKind fileKind, + String fileUrl, + String description + ) { + return CommissionFile.builder() + .commission(commission) + .fileKind(fileKind) + .fileUrl(fileUrl) + .description(description) + .build(); + } +} diff --git a/src/main/java/ditda/backend/domain/commission/core/entity/enums/CategoryType.java b/src/main/java/ditda/backend/domain/commission/core/entity/enums/CategoryType.java new file mode 100644 index 0000000..d9d62df --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/core/entity/enums/CategoryType.java @@ -0,0 +1,8 @@ +package ditda.backend.domain.commission.core.entity.enums; + +public enum CategoryType { + // 유인물 - 교재 + FLYER_TEXTBOOK_COVER_INNER, // 교재 외지/내지 + + // 추후 카테고리 확장 시 추가 +} diff --git a/src/main/java/ditda/backend/domain/commission/core/entity/enums/ColorRole.java b/src/main/java/ditda/backend/domain/commission/core/entity/enums/ColorRole.java new file mode 100644 index 0000000..735f1a1 --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/core/entity/enums/ColorRole.java @@ -0,0 +1,7 @@ +package ditda.backend.domain.commission.core.entity.enums; + +public enum ColorRole { + MAIN, + SUB1, + SUB2 +} diff --git a/src/main/java/ditda/backend/domain/commission/core/entity/enums/ColorSelectionMode.java b/src/main/java/ditda/backend/domain/commission/core/entity/enums/ColorSelectionMode.java new file mode 100644 index 0000000..60c3c70 --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/core/entity/enums/ColorSelectionMode.java @@ -0,0 +1,6 @@ +package ditda.backend.domain.commission.core.entity.enums; + +public enum ColorSelectionMode { + USER_SELECTED, + DESIGNER_DELEGATED +} diff --git a/src/main/java/ditda/backend/domain/commission/core/entity/enums/CommissionStatus.java b/src/main/java/ditda/backend/domain/commission/core/entity/enums/CommissionStatus.java new file mode 100644 index 0000000..8cd9e4b --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/core/entity/enums/CommissionStatus.java @@ -0,0 +1,10 @@ +package ditda.backend.domain.commission.core.entity.enums; + +public enum CommissionStatus { + PENDING, // 결제 대기 + RECRUITING, // 디자이너 모집/지원 + IN_PROGRESS, // 1차 시안 제출 대기 / 강사 선택 대기 + EDITING, // 수정 진행 중 + COMPLETED, // 최종 확정 + CANCELLED // 취소 +} diff --git a/src/main/java/ditda/backend/domain/commission/core/entity/enums/ConceptCategory.java b/src/main/java/ditda/backend/domain/commission/core/entity/enums/ConceptCategory.java new file mode 100644 index 0000000..4338e7f --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/core/entity/enums/ConceptCategory.java @@ -0,0 +1,16 @@ +package ditda.backend.domain.commission.core.entity.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ConceptCategory { + BRIGHT("밝은"), + SOFT("부드러운"), + LUXURIOUS("고급스러운"), + INTENSE("강렬한"), + NEAT("단정한"); + + private final String description; +} diff --git a/src/main/java/ditda/backend/domain/commission/core/entity/enums/ConceptTag.java b/src/main/java/ditda/backend/domain/commission/core/entity/enums/ConceptTag.java new file mode 100644 index 0000000..dae08f2 --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/core/entity/enums/ConceptTag.java @@ -0,0 +1,29 @@ +package ditda.backend.domain.commission.core.entity.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum ConceptTag { + // 밝은 + CUTE(ConceptCategory.BRIGHT, "귀여운"), + LIGHT(ConceptCategory.BRIGHT, "경쾌한"), + CLEAR(ConceptCategory.BRIGHT, "맑은"), + // 부드러운 + NATURAL(ConceptCategory.SOFT, "내츄럴한"), + SUBTLE(ConceptCategory.SOFT, "은은한"), + WARM(ConceptCategory.SOFT, "온화한"), + // 고급스러운 + ELEGANT(ConceptCategory.LUXURIOUS, "우아한"), + REFINED(ConceptCategory.LUXURIOUS, "고상한"), + MODERN(ConceptCategory.LUXURIOUS, "모던한"), + // 강렬한 + VIVID(ConceptCategory.INTENSE, "화려한"), + DYNAMIC(ConceptCategory.INTENSE, "다이나믹한"), + // 단정한 + CALM(ConceptCategory.NEAT, "점잖은"); + + private final ConceptCategory category; + private final String description; +} diff --git a/src/main/java/ditda/backend/domain/commission/core/entity/enums/FileKind.java b/src/main/java/ditda/backend/domain/commission/core/entity/enums/FileKind.java new file mode 100644 index 0000000..b74b713 --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/core/entity/enums/FileKind.java @@ -0,0 +1,14 @@ +package ditda.backend.domain.commission.core.entity.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum FileKind { + MATERIAL(5), // 자료 첨부 + REFERENCE(3); // 레퍼런스 + + // 새 외주 작성시 카테고리별 첨부파일 최대 개수 + private final int maxCount; +} diff --git a/src/main/java/ditda/backend/domain/commission/core/entity/enums/PageSize.java b/src/main/java/ditda/backend/domain/commission/core/entity/enums/PageSize.java new file mode 100644 index 0000000..c1996ce --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/core/entity/enums/PageSize.java @@ -0,0 +1,8 @@ +package ditda.backend.domain.commission.core.entity.enums; + +public enum PageSize { + A4, + B5, + KUKBAE, // 188×257mm (국배판) + A5 +} diff --git a/src/main/java/ditda/backend/domain/commission/core/entity/enums/PageType.java b/src/main/java/ditda/backend/domain/commission/core/entity/enums/PageType.java new file mode 100644 index 0000000..10c8fc2 --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/core/entity/enums/PageType.java @@ -0,0 +1,13 @@ +package ditda.backend.domain.commission.core.entity.enums; + +public enum PageType { + INSTRUCTOR_PROFILE, // 강사 프로필 + AUTHORS_NOTE, // 저자의 말 + TABLE_OF_CONTENTS, // 목차 + UNIT_INTRO, // 단원 시작 간지 + CONCEPT, // 개념 설명 + EXAMPLE, // 대표 유형 + PROBLEM, // 문제 풀이 + NOTE, // 노트 + COVER // 표지 +} diff --git a/src/main/java/ditda/backend/domain/commission/core/entity/enums/PlanCode.java b/src/main/java/ditda/backend/domain/commission/core/entity/enums/PlanCode.java new file mode 100644 index 0000000..309d7a0 --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/core/entity/enums/PlanCode.java @@ -0,0 +1,18 @@ +package ditda.backend.domain.commission.core.entity.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum PlanCode { + + BASIC(3, 400000, 3, "디자이너 3명에 대한 시안을 받아볼 수 있습니다."), + PLUS(4, 480000, 3, "더 다양한 디자이너의 시안을 받아볼 수 있습니다."), + MAX(5, 560000, 3, "가장 많은 디자이너의 시안을 받아볼 수 있습니다."); + + private final int designerCount; + private final int price; + private final int baseRevision; + private final String description; +} diff --git a/src/main/java/ditda/backend/domain/commission/core/exception/CommissionErrorCode.java b/src/main/java/ditda/backend/domain/commission/core/exception/CommissionErrorCode.java new file mode 100644 index 0000000..80430f4 --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/core/exception/CommissionErrorCode.java @@ -0,0 +1,44 @@ +package ditda.backend.domain.commission.core.exception; + +import org.springframework.http.HttpStatus; + +import ditda.backend.global.apipayload.code.BaseErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum CommissionErrorCode implements BaseErrorCode { + + // 새 외주 작성 파일 업로드 + COMMISSION_FILE_LIMIT_EXCEEDED(HttpStatus.BAD_REQUEST, "COMMISSION_400_01", "첨부 파일 개수가 허용 범위를 초과했습니다."), + COMMISSION_FILE_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "COMMISSION_500_01", "파일 업로드에 실패했습니다."), + + // 색상 선택 (직접 색상 지정) + COLORS_REQUIRED(HttpStatus.BAD_REQUEST, "COMMISSION_400_02", "직접 색상 선택 시 색상은 필수입니다."), + INVALID_COLOR_COMPOSITION( + HttpStatus.BAD_REQUEST, + "COMMISSION_400_03", + "색상 구성이 올바르지 않습니다. MAIN, SUB1, SUB2를 각 1개씩 지정해야 합니다." + ), + + // 카테고리별 + UNSUPPORTED_CATEGORY(HttpStatus.BAD_REQUEST, "COMMISSION_400_04", "지원하지 않는 카테고리입니다."), + TEXTBOOK_DETAIL_REQUIRED(HttpStatus.BAD_REQUEST, "COMMISSION_400_05", "교재 정보는 필수입니다."), + INVALID_PAGE_TYPE_FOR_CATEGORY(HttpStatus.BAD_REQUEST, "COMMISSION_400_06", "해당 카테고리에서 지원하지 않는 페이지 종류입니다."), + + // 마감 기한 + INVALID_DEADLINE_ORDER(HttpStatus.BAD_REQUEST, "COMMISSION_400_07", "1차 시안 마감일은 최종 마감일보다 빨라야 합니다."), + FIRST_DRAFT_DEADLINE_TOO_SOON(HttpStatus.BAD_REQUEST, "COMMISSION_400_08", "1차 시안 마감일은 오늘로부터 최소 10일 이후여야 합니다."), + FINAL_DEADLINE_TOO_SOON(HttpStatus.BAD_REQUEST, "COMMISSION_400_09", "최종 마감일은 1차 시안 마감일로부터 최소 14일 이후여야 합니다."), + + // 외주 조회 및 선택 + COMMISSION_NOT_FOUND(HttpStatus.NOT_FOUND, "COMMISSION_404_01", "외주를 찾을 수 없습니다."), + COMMISSION_ACCESS_DENIED(HttpStatus.FORBIDDEN, "COMMISSION_403_01", "해당 외주에 대한 권한이 없습니다."), + COMMISSION_STATUS_INVALID(HttpStatus.CONFLICT, "COMMISSION_409_01", "외주 상태가 올바르지 않습니다."), + DESIGNER_ALREADY_SELECTED(HttpStatus.CONFLICT, "COMMISSION_409_02", "이미 디자이너가 확정된 외주입니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} diff --git a/src/main/java/ditda/backend/domain/commission/core/facade/InstructorCommissionFacade.java b/src/main/java/ditda/backend/domain/commission/core/facade/InstructorCommissionFacade.java new file mode 100644 index 0000000..e7bb23b --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/core/facade/InstructorCommissionFacade.java @@ -0,0 +1,77 @@ +package ditda.backend.domain.commission.core.facade; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.stereotype.Component; + +import ditda.backend.domain.commission.core.dto.CommissionFileToSave; +import ditda.backend.domain.commission.core.dto.request.CommissionCreateRequest; +import ditda.backend.domain.commission.core.dto.request.CommissionCreateRequest.FileInfo; +import ditda.backend.domain.commission.core.dto.response.CommissionCreateResponse; +import ditda.backend.domain.commission.core.dto.response.PlanListResponse; +import ditda.backend.domain.commission.core.handler.CommissionCategoryHandler; +import ditda.backend.domain.commission.core.service.CommissionCreateFileService; +import ditda.backend.domain.commission.core.service.InstructorCommissionService; +import ditda.backend.domain.commission.core.validator.CommissionCreateValidator; +import ditda.backend.domain.payment.dto.response.DepositNotifyResponse; +import ditda.backend.domain.payment.service.PaymentService; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class InstructorCommissionFacade { + + private final InstructorCommissionService instructorCommissionService; + private final CommissionCreateFileService commissionCreateFileService; + private final CommissionCreateValidator commissionCreateValidator; + private final PaymentService paymentService; + + public PlanListResponse getPlans() { + return instructorCommissionService.getPlans(); + } + + public CommissionCreateResponse createCommission( + Long instructorId, + CommissionCreateRequest request + ) { + + // 1. 순수 검증 + 카테고리 핸들러 확보 + CommissionCategoryHandler handler = commissionCreateValidator.validate(request); + + // 2. 파일 검증 + List files = request.files() == null + ? List.of() + : request.files(); + commissionCreateFileService.validateFiles(files); + + // 3. promote 후 영속화 + List commissionFiles = new ArrayList<>(); + List promotedKeys = new ArrayList<>(); + try { + for (FileInfo file : files) { + List permanent = commissionCreateFileService.promote(file.keys()); + commissionFiles.add(new CommissionFileToSave( + file.fileKind(), + permanent, + file.description() + )); + promotedKeys.addAll(permanent); + } + + return instructorCommissionService.createCommission(instructorId, request, handler, commissionFiles); + } catch (Exception original) { + try { + commissionCreateFileService.deleteFiles(promotedKeys); + } catch (Exception cleanupEx) { + original.addSuppressed(cleanupEx); + } + throw original; + } + } + + public DepositNotifyResponse notifyDeposit(Long instructorId, Long commissionId) { + + return paymentService.notifyDeposit(instructorId, commissionId); + } +} diff --git a/src/main/java/ditda/backend/domain/commission/core/handler/CommissionCategoryHandler.java b/src/main/java/ditda/backend/domain/commission/core/handler/CommissionCategoryHandler.java new file mode 100644 index 0000000..2db2877 --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/core/handler/CommissionCategoryHandler.java @@ -0,0 +1,20 @@ +package ditda.backend.domain.commission.core.handler; + +import ditda.backend.domain.commission.core.dto.request.CommissionCreateRequest; +import ditda.backend.domain.commission.core.entity.Commission; +import ditda.backend.domain.commission.core.entity.enums.CategoryType; + +public interface CommissionCategoryHandler { + + // 담당 카테고리 + CategoryType category(); + + // 카테고리별 전용 검증 + void validate(CommissionCreateRequest request); + + // 카테고리별 전용 제목 생성 + String buildTitle(CommissionCreateRequest request); + + // 카테고리별 전용 저장 + void saveDetail(Commission commission, CommissionCreateRequest request); +} diff --git a/src/main/java/ditda/backend/domain/commission/core/handler/CommissionCategoryHandlerResolver.java b/src/main/java/ditda/backend/domain/commission/core/handler/CommissionCategoryHandlerResolver.java new file mode 100644 index 0000000..6f4aad6 --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/core/handler/CommissionCategoryHandlerResolver.java @@ -0,0 +1,38 @@ +package ditda.backend.domain.commission.core.handler; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Component; + +import ditda.backend.domain.commission.core.entity.enums.CategoryType; +import ditda.backend.domain.commission.core.exception.CommissionErrorCode; +import ditda.backend.global.apipayload.exception.GeneralException; + +/** + * 카테고리(CategoryType)에 맞는 CommissionCategoryHandler를 찾아준다. + * 새 카테고리 추가 = 핸들러 구현체(@Component) 하나 추가 → 자동 등록 + */ +@Component +public class CommissionCategoryHandlerResolver { + + private final Map handlers; + + // CommissionCategoryHandler 구현체 전부 주입 -> 카테고리별 Map으로 변환 + public CommissionCategoryHandlerResolver(List handlers) { + this.handlers = handlers.stream() + .collect(Collectors.toMap(CommissionCategoryHandler::category, handler -> handler)); + } + + // 카테고리로 Handler 조회 (미지원 카테고리면 예외처리) + public CommissionCategoryHandler resolve(CategoryType categoryType) { + + CommissionCategoryHandler handler = handlers.get(categoryType); + if (handler == null) { + throw new GeneralException(CommissionErrorCode.UNSUPPORTED_CATEGORY); + } + return handler; + } + +} diff --git a/src/main/java/ditda/backend/domain/commission/core/repository/CommissionColorRepository.java b/src/main/java/ditda/backend/domain/commission/core/repository/CommissionColorRepository.java new file mode 100644 index 0000000..f4ac48e --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/core/repository/CommissionColorRepository.java @@ -0,0 +1,8 @@ +package ditda.backend.domain.commission.core.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import ditda.backend.domain.commission.core.entity.CommissionColor; + +public interface CommissionColorRepository extends JpaRepository { +} diff --git a/src/main/java/ditda/backend/domain/commission/core/repository/CommissionConceptRepository.java b/src/main/java/ditda/backend/domain/commission/core/repository/CommissionConceptRepository.java new file mode 100644 index 0000000..95fcf16 --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/core/repository/CommissionConceptRepository.java @@ -0,0 +1,8 @@ +package ditda.backend.domain.commission.core.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import ditda.backend.domain.commission.core.entity.CommissionConcept; + +public interface CommissionConceptRepository extends JpaRepository { +} diff --git a/src/main/java/ditda/backend/domain/commission/core/repository/CommissionFileRepository.java b/src/main/java/ditda/backend/domain/commission/core/repository/CommissionFileRepository.java new file mode 100644 index 0000000..df390d0 --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/core/repository/CommissionFileRepository.java @@ -0,0 +1,8 @@ +package ditda.backend.domain.commission.core.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import ditda.backend.domain.commission.core.entity.CommissionFile; + +public interface CommissionFileRepository extends JpaRepository { +} diff --git a/src/main/java/ditda/backend/domain/commission/core/repository/CommissionRepository.java b/src/main/java/ditda/backend/domain/commission/core/repository/CommissionRepository.java new file mode 100644 index 0000000..c433833 --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/core/repository/CommissionRepository.java @@ -0,0 +1,28 @@ +package ditda.backend.domain.commission.core.repository; + +import java.time.LocalDateTime; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import ditda.backend.domain.commission.core.entity.Commission; +import ditda.backend.domain.designer.entity.Designer; + +public interface CommissionRepository extends JpaRepository { + + @Modifying + @Query("update Commission c " + + "set c.assignedDesigner = :designer, " + + "c.status = CommissionStatus.EDITING, " + + "c.selectedAt = :now " + + "where c.id = :commissionId " + + "and c.status = CommissionStatus.IN_PROGRESS " + + "and c.assignedDesigner is null") + int selectDesignerIfAvailable( + @Param("commissionId") Long id, + @Param("designer") Designer designer, + @Param("now") LocalDateTime now + ); +} diff --git a/src/main/java/ditda/backend/domain/commission/core/service/CommissionCreateFileService.java b/src/main/java/ditda/backend/domain/commission/core/service/CommissionCreateFileService.java new file mode 100644 index 0000000..fa94c6e --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/core/service/CommissionCreateFileService.java @@ -0,0 +1,92 @@ +package ditda.backend.domain.commission.core.service; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import ditda.backend.domain.commission.core.dto.request.CommissionCreateRequest.FileInfo; +import ditda.backend.domain.commission.core.entity.Commission; +import ditda.backend.domain.commission.core.entity.CommissionFile; +import ditda.backend.domain.commission.core.entity.enums.FileKind; +import ditda.backend.domain.commission.core.exception.CommissionErrorCode; +import ditda.backend.domain.commission.core.repository.CommissionFileRepository; +import ditda.backend.global.apipayload.exception.GeneralException; +import ditda.backend.global.s3.enums.BucketType; +import ditda.backend.global.s3.enums.UploadTarget; +import ditda.backend.global.s3.manager.S3UploadManager; +import ditda.backend.global.s3.service.S3FileService; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class CommissionCreateFileService { + + private static final BucketType BUCKET = BucketType.PRIVATE; + + private final CommissionFileRepository commissionFileRepository; + private final S3UploadManager s3UploadManager; + private final S3FileService s3FileService; + + // 첨부 파일의 종류별로 keys 합산하여 검증 + // 클라이언트가 같은 종류 여러번 보내도 종류별 총량을 기준으로 검사 + public void validateFiles(List files) { + + Map> keysByKind = files.stream() + .collect(Collectors.groupingBy( + FileInfo::fileKind, + Collectors.flatMapping(file -> file.keys().stream(), Collectors.toList()) + )); + + keysByKind.forEach(this::validateKeys); + } + + @Transactional + public void saveCommissionFiles( + Commission commission, + FileKind fileKind, + List keys, + String description + ) { + + if (keys == null || keys.isEmpty()) { + return; + } + + // 파일 DB에 일괄 저장 + List commissionFiles = keys.stream() + .map(key -> CommissionFile.create(commission, fileKind, key, description)) + .toList(); + commissionFileRepository.saveAll(commissionFiles); + } + + public List promote(List tempKeys) { + return s3UploadManager.promote(BUCKET, tempKeys); + } + + public void deleteFiles(List keys) { + s3UploadManager.deleteAll(BUCKET, keys); + } + + private static UploadTarget targetOf(FileKind fileKind) { + return switch (fileKind) { + case MATERIAL -> UploadTarget.COMMISSION_MATERIAL; + case REFERENCE -> UploadTarget.COMMISSION_REFERENCE; + }; + } + + private void validateKeys(FileKind fileKind, List keys) { + + // 파일 개수 검증 + if (keys.size() > fileKind.getMaxCount()) { + throw new GeneralException( + CommissionErrorCode.COMMISSION_FILE_LIMIT_EXCEEDED, + "%s 파일은 최대 %d개까지 업로드 가능합니다.".formatted(fileKind, fileKind.getMaxCount()) + ); + } + + s3FileService.validateUploadedKeys(targetOf(fileKind), keys); + } +} diff --git a/src/main/java/ditda/backend/domain/commission/core/service/InstructorCommissionService.java b/src/main/java/ditda/backend/domain/commission/core/service/InstructorCommissionService.java new file mode 100644 index 0000000..f618670 --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/core/service/InstructorCommissionService.java @@ -0,0 +1,158 @@ +package ditda.backend.domain.commission.core.service; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import ditda.backend.domain.commission.core.dto.CommissionFileToSave; +import ditda.backend.domain.commission.core.dto.request.CommissionCreateRequest; +import ditda.backend.domain.commission.core.dto.response.CommissionCreateResponse; +import ditda.backend.domain.commission.core.dto.response.PlanListResponse; +import ditda.backend.domain.commission.core.entity.Commission; +import ditda.backend.domain.commission.core.entity.CommissionColor; +import ditda.backend.domain.commission.core.entity.CommissionConcept; +import ditda.backend.domain.commission.core.entity.enums.ColorSelectionMode; +import ditda.backend.domain.commission.core.exception.CommissionErrorCode; +import ditda.backend.domain.commission.core.handler.CommissionCategoryHandler; +import ditda.backend.domain.commission.core.repository.CommissionColorRepository; +import ditda.backend.domain.commission.core.repository.CommissionConceptRepository; +import ditda.backend.domain.commission.core.repository.CommissionRepository; +import ditda.backend.domain.designer.entity.Designer; +import ditda.backend.domain.instructor.entity.Instructor; +import ditda.backend.domain.instructor.service.InstructorService; +import ditda.backend.domain.payment.service.PaymentService; +import ditda.backend.global.apipayload.exception.GeneralException; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class InstructorCommissionService { + + private final CommissionRepository commissionRepository; + private final CommissionConceptRepository commissionConceptRepository; + private final CommissionColorRepository commissionColorRepository; + private final InstructorService instructorService; + private final CommissionCreateFileService commissionCreateFileService; + private final PaymentService paymentService; + + // 외주 플랜 정보 조회 + public PlanListResponse getPlans() { + return PlanListResponse.from(); + } + + // 새 외주 생성 + @Transactional + public CommissionCreateResponse createCommission( + Long instructorId, + CommissionCreateRequest request, + CommissionCategoryHandler handler, + List commissionFiles + ) { + + CommissionCreateRequest.DesignInfo design = request.designInfo(); + CommissionCreateRequest.DateInfo date = request.date(); + CommissionCreateRequest.TermRequest term = request.term(); + + // 1. Commission 저장 + Instructor instructor = instructorService.getById(instructorId); + String title = handler.buildTitle(request); + Commission commission = Commission.create( + instructor, + request.plan(), + title, + request.category(), + design.pageSize(), + design.additionalConcept(), + design.colorSelectionMode(), + date.firstDraftDeadline(), + date.finalDeadline() + ); + commissionRepository.save(commission); + + // 2. 공통 섹션 저장 + saveConcepts(commission, design); + saveColors(commission, design); + saveFiles(commission, commissionFiles); + + // 3. 카테고리별 전용 섹션 저장 + handler.saveDetail(commission, request); + + // 4. 결제(입금 대기 [PENDING]) + 결제 약관 저장 + String depositorName = instructor.getName(); + paymentService.createPendingPayment( + commission, + depositorName, + term.version(), + term.isAgreed() + ); + + return CommissionCreateResponse.from(commission); + } + + // 외주 조회 + 강사 확인 + @Transactional(readOnly = true) + public Commission getOwnedCommission(Long commissionId, Long instructorId) { + + Commission commission = commissionRepository.findById(commissionId) + .orElseThrow(() -> new GeneralException(CommissionErrorCode.COMMISSION_NOT_FOUND)); + + if (!commission.isOwnedBy(instructorId)) { + throw new GeneralException(CommissionErrorCode.COMMISSION_ACCESS_DENIED); + } + return commission; + } + + // 디자이너 선택 + @Transactional + public void selectDesigner(Commission commission, Designer designer, LocalDateTime now) { + + if (commission.isDesignerSelected()) { + throw new GeneralException(CommissionErrorCode.DESIGNER_ALREADY_SELECTED); + } + if (!commission.isSelectable()) { + throw new GeneralException(CommissionErrorCode.COMMISSION_STATUS_INVALID); + } + + int updated = commissionRepository.selectDesignerIfAvailable(commission.getId(), designer, now); + if (updated == 0) { + throw new GeneralException(CommissionErrorCode.DESIGNER_ALREADY_SELECTED); + } + } + + // 컨셉 저장 + private void saveConcepts(Commission commission, CommissionCreateRequest.DesignInfo design) { + + List concepts = design.concepts().stream() + .map(tag -> CommissionConcept.create(commission, tag)) + .toList(); + commissionConceptRepository.saveAll(concepts); + } + + // 색상 저장 (강사 직접 선택시) + private void saveColors(Commission commission, CommissionCreateRequest.DesignInfo design) { + + if (design.colorSelectionMode() != ColorSelectionMode.USER_SELECTED) { + return; + } + + List colors = design.colors().stream() + .map(color -> CommissionColor.create(commission, color.role(), color.colorCode())) + .toList(); + commissionColorRepository.saveAll(colors); + } + + // 첨부 파일 저장 (자료첨부, 레퍼런스) + private void saveFiles(Commission commission, List commissionFiles) { + + for (CommissionFileToSave file : commissionFiles) { + commissionCreateFileService.saveCommissionFiles( + commission, + file.fileKind(), + file.keys(), + file.description() + ); + } + } +} diff --git a/src/main/java/ditda/backend/domain/commission/core/validator/CommissionCreateValidator.java b/src/main/java/ditda/backend/domain/commission/core/validator/CommissionCreateValidator.java new file mode 100644 index 0000000..1400e92 --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/core/validator/CommissionCreateValidator.java @@ -0,0 +1,94 @@ +package ditda.backend.domain.commission.core.validator; + +import java.time.LocalDate; +import java.util.EnumSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Component; + +import ditda.backend.domain.commission.core.dto.request.CommissionCreateRequest; +import ditda.backend.domain.commission.core.dto.request.CommissionCreateRequest.ColorInfo; +import ditda.backend.domain.commission.core.dto.request.CommissionCreateRequest.DateInfo; +import ditda.backend.domain.commission.core.dto.request.CommissionCreateRequest.DesignInfo; +import ditda.backend.domain.commission.core.dto.request.CommissionCreateRequest.TermRequest; +import ditda.backend.domain.commission.core.entity.enums.ColorRole; +import ditda.backend.domain.commission.core.entity.enums.ColorSelectionMode; +import ditda.backend.domain.commission.core.exception.CommissionErrorCode; +import ditda.backend.domain.commission.core.handler.CommissionCategoryHandler; +import ditda.backend.domain.commission.core.handler.CommissionCategoryHandlerResolver; +import ditda.backend.domain.term.entity.enums.TermType; +import ditda.backend.domain.term.exception.TermErrorCode; +import ditda.backend.global.apipayload.exception.GeneralException; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class CommissionCreateValidator { + + private static final int MIN_FIRST_DRAFT_LEAD_DAYS = 10; + private static final int MIN_DRAFT_TO_FINAL_DAYS = 14; + + private final CommissionCategoryHandlerResolver commissionCategoryHandlerResolver; + + public CommissionCategoryHandler validate(CommissionCreateRequest request) { + + validateColors(request.designInfo()); + validateTerm(request.term()); + validateDeadlines(request.date()); + + CommissionCategoryHandler handler = commissionCategoryHandlerResolver.resolve(request.category()); + handler.validate(request); + return handler; + } + + // 색상 검증 (직접 색상 지정시 MAIN, SUB1, SUB2 필수) + private void validateColors(DesignInfo design) { + + if (design.colorSelectionMode() != ColorSelectionMode.USER_SELECTED) { + return; + } + + List colors = design.colors(); + // 색상이 아예 없을 경우 + if (colors == null || colors.isEmpty()) { + throw new GeneralException(CommissionErrorCode.COLORS_REQUIRED); + } + + Set roles = colors.stream() + .map(ColorInfo::role) + .collect(Collectors.toSet()); + + // MAIN, SUB1, SUB2 중 부분 누락 or 중복 + if (roles.size() != colors.size() || !roles.equals(EnumSet.allOf(ColorRole.class))) { + throw new GeneralException(CommissionErrorCode.INVALID_COLOR_COMPOSITION); + } + } + + // 결제 약관 검증 + private void validateTerm(TermRequest term) { + + if (term.type() != TermType.SETTLEMENT || !Boolean.TRUE.equals(term.isAgreed())) { + throw new GeneralException(TermErrorCode.SETTLEMENT_TERM_NOT_AGREED); + } + } + + // 마감 기한 검증 + private void validateDeadlines(DateInfo date) { + + LocalDate today = LocalDate.now(); + + if (!date.firstDraftDeadline().isBefore(date.finalDeadline())) { + throw new GeneralException(CommissionErrorCode.INVALID_DEADLINE_ORDER); + } + + if (date.firstDraftDeadline().minusDays(MIN_FIRST_DRAFT_LEAD_DAYS).isBefore(today)) { + throw new GeneralException(CommissionErrorCode.FIRST_DRAFT_DEADLINE_TOO_SOON); + } + + if (date.finalDeadline().isBefore(date.firstDraftDeadline().plusDays(MIN_DRAFT_TO_FINAL_DAYS))) { + throw new GeneralException(CommissionErrorCode.FINAL_DEADLINE_TOO_SOON); + } + } +} diff --git a/src/main/java/ditda/backend/domain/commission/dashboard/controller/InstructorDashboardController.java b/src/main/java/ditda/backend/domain/commission/dashboard/controller/InstructorDashboardController.java new file mode 100644 index 0000000..6648bf4 --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/dashboard/controller/InstructorDashboardController.java @@ -0,0 +1,54 @@ +package ditda.backend.domain.commission.dashboard.controller; + +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import ditda.backend.domain.commission.dashboard.dto.response.DraftSubmissionCommissionResponse; +import ditda.backend.domain.commission.dashboard.dto.response.MatchingCommissionResponse; +import ditda.backend.domain.commission.dashboard.dto.response.RevisingCommissionResponse; +import ditda.backend.domain.commission.dashboard.service.InstructorDashboardService; +import ditda.backend.global.apipayload.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/v1/instructors/dashboards") +@RequiredArgsConstructor +@Tag(name = "Instructor Dashboard", description = "강사 대시보드 API") +public class InstructorDashboardController { + + private final InstructorDashboardService instructorDashboardService; + + @Operation(summary = "시안 제출 현황 조회", description = "**[대시보드]** 시안 제출 현황을 조회합니다.") + @GetMapping("/draft-submissions") + public ApiResponse getDraftSubmissions( + @AuthenticationPrincipal Long instructorId + ) { + DraftSubmissionCommissionResponse response = instructorDashboardService.getDraftSubmissions(instructorId); + + return ApiResponse.onSuccess("시안 제출 현황 조회 성공", response); + } + + @Operation(summary = "매칭 중인 외주 조회", description = "**[대시보드]** 매칭 중인 외주를 조회합니다.") + @GetMapping("/matchings") + public ApiResponse getMatchingCommissions( + @AuthenticationPrincipal Long instructorId + ) { + MatchingCommissionResponse response = instructorDashboardService.getMatchingCommissions(instructorId); + + return ApiResponse.onSuccess("매칭 중인 외주 조회 성공", response); + } + + @Operation(summary = "수정 중인 외주 조회", description = "**[대시보드]** 수정 중인 외주를 조회합니다.") + @GetMapping("/revisions") + public ApiResponse getRevisingCommissions( + @AuthenticationPrincipal Long instructorId + ) { + RevisingCommissionResponse response = instructorDashboardService.getRevisingCommissions(instructorId); + + return ApiResponse.onSuccess("수정 중인 외주 조회 성공", response); + } +} diff --git a/src/main/java/ditda/backend/domain/commission/dashboard/dto/response/DraftSubmissionCommissionResponse.java b/src/main/java/ditda/backend/domain/commission/dashboard/dto/response/DraftSubmissionCommissionResponse.java new file mode 100644 index 0000000..1bfb15f --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/dashboard/dto/response/DraftSubmissionCommissionResponse.java @@ -0,0 +1,70 @@ +package ditda.backend.domain.commission.dashboard.dto.response; + +import java.time.LocalDate; +import java.util.List; + +import ditda.backend.domain.commission.core.entity.Commission; +import ditda.backend.domain.commission.core.entity.enums.CategoryType; +import ditda.backend.domain.commission.dashboard.repository.projection.DraftSubmissionView; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "시안 제출 외주 응답") +public record DraftSubmissionCommissionResponse( + List commissions +) { + public static DraftSubmissionCommissionResponse of(List views, LocalDate today) { + + List items = views.stream() + .map(view -> CommissionItem.from(view, today)) + .toList(); + + return new DraftSubmissionCommissionResponse(items); + } + + public record CommissionItem( + @Schema(description = "외주 ID", example = "1") + Long commissionId, + + @Schema(description = "제목", example = "수학의 정석 - 수학") + String title, + + @Schema(description = "카테고리", example = "FLYER_TEXTBOOK_COVER_INNER") + CategoryType category, + + @Schema(description = "1차 시안 제출 현황") + DraftSubmissionStatus draftSubmission, + + @Schema(description = "시안 확인 가능 여부", example = "true") + boolean isViewable, + + @Schema(description = "1차 시안 마감일", example = "2026-06-23") + LocalDate firstDraftDeadline + ) { + private static CommissionItem from(DraftSubmissionView view, LocalDate today) { + + Commission commission = view.getCommission(); + int submitted = view.getSubmissionCount().intValue(); + boolean isViewable = commission.isDraftListViewable(submitted, today); + + return new CommissionItem( + commission.getId(), + commission.getTitle(), + commission.getCategoryType(), + new DraftSubmissionStatus(submitted, commission.getDesignerCount()), + isViewable, + commission.getFirstDraftDeadline() + ); + } + + @Schema(description = "1차 시안 제출 현황") + public record DraftSubmissionStatus( + + @Schema(description = "제출한 디자이너 수", example = "2") + int submitted, + + @Schema(description = "총 모집 디자이너 수", example = "5") + int total + ) { + } + } +} diff --git a/src/main/java/ditda/backend/domain/commission/dashboard/dto/response/MatchingCommissionResponse.java b/src/main/java/ditda/backend/domain/commission/dashboard/dto/response/MatchingCommissionResponse.java new file mode 100644 index 0000000..f32d73b --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/dashboard/dto/response/MatchingCommissionResponse.java @@ -0,0 +1,65 @@ +package ditda.backend.domain.commission.dashboard.dto.response; + +import java.time.LocalDate; +import java.util.List; + +import ditda.backend.domain.commission.core.entity.Commission; +import ditda.backend.domain.commission.dashboard.repository.projection.MatchingView; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "매칭 중인 외주 응답") +public record MatchingCommissionResponse( + + List commissions +) { + public static MatchingCommissionResponse of(List views) { + + List items = views.stream() + .map(CommissionItem::from) + .toList(); + + return new MatchingCommissionResponse(items); + } + + public record CommissionItem( + @Schema(description = "외주 ID", example = "1") + Long commissionId, + + @Schema(description = "제목", example = "수학의 정석 - 수학") + String title, + + @Schema(description = "매칭 현황") + MatchStatusResponse matching, + + @Schema(description = "디자이너 모집 마감일", example = "2026-06-23") + LocalDate applicationDeadline + ) { + private static CommissionItem from(MatchingView view) { + + Commission commission = view.getCommission(); + int matched = commission.matchedCount( + view.getDistinctLevelCount().intValue(), + view.getTotalCount().intValue() + ); + + return new CommissionItem( + commission.getId(), + commission.getTitle(), + new MatchStatusResponse(matched, commission.getDesignerCount()), + commission.getApplicationDeadline() + ); + } + + @Schema(description = "매칭 현황") + public record MatchStatusResponse( + + @Schema(description = "매칭된 디자이너 수", example = "2") + int matched, + + @Schema(description = "총 모집 디자이너 수", example = "5") + int total + ) { + } + } +} + diff --git a/src/main/java/ditda/backend/domain/commission/dashboard/dto/response/RevisingCommissionResponse.java b/src/main/java/ditda/backend/domain/commission/dashboard/dto/response/RevisingCommissionResponse.java new file mode 100644 index 0000000..7dce095 --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/dashboard/dto/response/RevisingCommissionResponse.java @@ -0,0 +1,56 @@ +package ditda.backend.domain.commission.dashboard.dto.response; + +import java.time.LocalDate; +import java.util.List; + +import ditda.backend.domain.commission.core.entity.Commission; +import ditda.backend.domain.commission.dashboard.repository.projection.RevisingView; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "수정 중인 외주 응답") +public record RevisingCommissionResponse( + + List commissions +) { + public static RevisingCommissionResponse of(List views) { + + List items = views.stream() + .map(CommissionItem::from) + .toList(); + + return new RevisingCommissionResponse(items); + } + + public record CommissionItem( + @Schema(description = "외주 ID", example = "1") + Long commissionId, + + @Schema(description = "제목", example = "수학의 정석 - 수학") + String title, + + @Schema(description = "수정 요청 전송 여부 (true=전송완료, false=확인하기)", example = "true") + boolean isSubmitted, + + @Schema(description = "수정사항 제출 여부", example = "true") + boolean hasUpdated, + + @Schema(description = "최종 마감일", example = "2026-06-23") + LocalDate finalDeadline + ) { + + private static CommissionItem from(RevisingView view) { + + Commission commission = view.getCommission(); + + return new CommissionItem( + commission.getId(), + commission.getTitle(), + view.getSubmitted(), + view.getHasUpdated(), + commission.getFinalDeadline() + ); + } + } +} + + diff --git a/src/main/java/ditda/backend/domain/commission/dashboard/repository/DashboardCommissionRepository.java b/src/main/java/ditda/backend/domain/commission/dashboard/repository/DashboardCommissionRepository.java new file mode 100644 index 0000000..904b095 --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/dashboard/repository/DashboardCommissionRepository.java @@ -0,0 +1,63 @@ +package ditda.backend.domain.commission.dashboard.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.Repository; +import org.springframework.data.repository.query.Param; + +import ditda.backend.domain.commission.application.entity.enums.ApplicationStatus; +import ditda.backend.domain.commission.core.entity.Commission; +import ditda.backend.domain.commission.core.entity.enums.CommissionStatus; +import ditda.backend.domain.commission.dashboard.repository.projection.DraftSubmissionView; +import ditda.backend.domain.commission.dashboard.repository.projection.MatchingView; +import ditda.backend.domain.commission.dashboard.repository.projection.RevisingView; + +public interface DashboardCommissionRepository extends Repository { + + // 시안 제출 현황: commission + DRAFT_SUBMITTED 개수 + @Query("SELECT c AS commission, " + + "COUNT(a.id) AS submissionCount " + + "FROM Commission c " + + "LEFT JOIN CommissionApplication a ON a.commission = c AND a.status = :applicationStatus " + + "WHERE c.instructor.id = :instructorId AND c.status = :commissionStatus " + + "GROUP BY c.id " + + "ORDER BY c.firstDraftDeadline ASC") + List findDraftSubmissionViews( + @Param("instructorId") Long instructorId, + @Param("commissionStatus") CommissionStatus commissionStatus, + @Param("applicationStatus") ApplicationStatus applicationStatus + ); + + // 매칭 중인 외주: commission + distinct level 수 + PENDING 지원자 수 + @Query("SELECT c AS commission, " + + "COUNT(DISTINCT a.designer.level) AS distinctLevelCount, " + + "COUNT(a.id) AS totalCount " + + "FROM Commission c " + + "LEFT JOIN CommissionApplication a ON a.commission = c AND a.status = :applicationStatus " + + "WHERE c.instructor.id = :instructorId AND c.status = :commissionStatus " + + "GROUP BY c.id " + + "ORDER BY c.applicationDeadline ASC") + List findMatchingViews( + @Param("instructorId") Long instructorId, + @Param("commissionStatus") CommissionStatus commissionStatus, + @Param("applicationStatus") ApplicationStatus applicationStatus + ); + + // 수정 중인 외주: commission + submitted(응답 없는 요청 존재) + hasUpdated(미열람 응답 존재) + @Query("SELECT c AS commission, " + + "CASE WHEN COUNT(CASE WHEN rr.id IS NOT NULL AND resp.id IS NULL THEN 1 END) > 0 " + + " THEN true ELSE false END AS submitted, " + + "CASE WHEN COUNT(CASE WHEN resp.id IS NOT NULL AND resp.checked = false THEN 1 END) > 0 " + + " THEN true ELSE false END AS hasUpdated " + + "FROM Commission c " + + "LEFT JOIN RevisionRequest rr ON rr.commission = c " + + "LEFT JOIN RevisionResponse resp ON resp.revisionRequest = rr " + + "WHERE c.instructor.id = :instructorId AND c.status = :commissionStatus " + + "GROUP BY c.id " + + "ORDER BY c.finalDeadline ASC") + List findRevisingViews( + @Param("instructorId") Long instructorId, + @Param("commissionStatus") CommissionStatus commissionStatus + ); +} diff --git a/src/main/java/ditda/backend/domain/commission/dashboard/repository/projection/DraftSubmissionView.java b/src/main/java/ditda/backend/domain/commission/dashboard/repository/projection/DraftSubmissionView.java new file mode 100644 index 0000000..90ce536 --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/dashboard/repository/projection/DraftSubmissionView.java @@ -0,0 +1,10 @@ +package ditda.backend.domain.commission.dashboard.repository.projection; + +import ditda.backend.domain.commission.core.entity.Commission; + +public interface DraftSubmissionView { + + Commission getCommission(); + + Long getSubmissionCount(); +} diff --git a/src/main/java/ditda/backend/domain/commission/dashboard/repository/projection/MatchingView.java b/src/main/java/ditda/backend/domain/commission/dashboard/repository/projection/MatchingView.java new file mode 100644 index 0000000..6d28fc0 --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/dashboard/repository/projection/MatchingView.java @@ -0,0 +1,12 @@ +package ditda.backend.domain.commission.dashboard.repository.projection; + +import ditda.backend.domain.commission.core.entity.Commission; + +public interface MatchingView { + + Commission getCommission(); + + Long getDistinctLevelCount(); + + Long getTotalCount(); +} diff --git a/src/main/java/ditda/backend/domain/commission/dashboard/repository/projection/RevisingView.java b/src/main/java/ditda/backend/domain/commission/dashboard/repository/projection/RevisingView.java new file mode 100644 index 0000000..792af8e --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/dashboard/repository/projection/RevisingView.java @@ -0,0 +1,12 @@ +package ditda.backend.domain.commission.dashboard.repository.projection; + +import ditda.backend.domain.commission.core.entity.Commission; + +public interface RevisingView { + + Commission getCommission(); + + Boolean getSubmitted(); + + Boolean getHasUpdated(); +} diff --git a/src/main/java/ditda/backend/domain/commission/dashboard/service/InstructorDashboardService.java b/src/main/java/ditda/backend/domain/commission/dashboard/service/InstructorDashboardService.java new file mode 100644 index 0000000..f63c49b --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/dashboard/service/InstructorDashboardService.java @@ -0,0 +1,63 @@ +package ditda.backend.domain.commission.dashboard.service; + +import java.time.LocalDate; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import ditda.backend.domain.commission.application.entity.enums.ApplicationStatus; +import ditda.backend.domain.commission.core.entity.enums.CommissionStatus; +import ditda.backend.domain.commission.dashboard.dto.response.DraftSubmissionCommissionResponse; +import ditda.backend.domain.commission.dashboard.dto.response.MatchingCommissionResponse; +import ditda.backend.domain.commission.dashboard.dto.response.RevisingCommissionResponse; +import ditda.backend.domain.commission.dashboard.repository.DashboardCommissionRepository; +import ditda.backend.domain.commission.dashboard.repository.projection.DraftSubmissionView; +import ditda.backend.domain.commission.dashboard.repository.projection.MatchingView; +import ditda.backend.domain.commission.dashboard.repository.projection.RevisingView; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class InstructorDashboardService { + + private final DashboardCommissionRepository dashboardCommissionRepository; + + // 시안 제출 현황 조회 + public DraftSubmissionCommissionResponse getDraftSubmissions(Long instructorId) { + + List views = dashboardCommissionRepository.findDraftSubmissionViews( + instructorId, + CommissionStatus.IN_PROGRESS, + ApplicationStatus.DRAFT_SUBMITTED + ); + + LocalDate today = LocalDate.now(); + + return DraftSubmissionCommissionResponse.of(views, today); + } + + // 매칭 중인 외주 조회 + public MatchingCommissionResponse getMatchingCommissions(Long instructorId) { + + List views = dashboardCommissionRepository.findMatchingViews( + instructorId, + CommissionStatus.RECRUITING, + ApplicationStatus.PENDING + ); + + return MatchingCommissionResponse.of(views); + } + + // 수정 중인 외주 조회 + public RevisingCommissionResponse getRevisingCommissions(Long instructorId) { + + List views = dashboardCommissionRepository.findRevisingViews( + instructorId, + CommissionStatus.EDITING + ); + + return RevisingCommissionResponse.of(views); + } +} diff --git a/src/main/java/ditda/backend/domain/commission/draft/controller/DraftController.java b/src/main/java/ditda/backend/domain/commission/draft/controller/DraftController.java new file mode 100644 index 0000000..971042a --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/draft/controller/DraftController.java @@ -0,0 +1,62 @@ +package ditda.backend.domain.commission.draft.controller; + +import org.springframework.security.core.annotation.AuthenticationPrincipal; +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.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import ditda.backend.domain.commission.draft.dto.response.DraftDetailResponse; +import ditda.backend.domain.commission.draft.dto.response.DraftListResponse; +import ditda.backend.domain.commission.draft.dto.response.DraftSelectResponse; +import ditda.backend.domain.commission.draft.facade.DraftFacade; +import ditda.backend.global.apipayload.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/v1/instructors/commissions") +@RequiredArgsConstructor +@Tag(name = "Instructor Draft", description = "강사 시안 조회/선택 API") +public class DraftController { + + private final DraftFacade draftFacade; + + @Operation(summary = "1차 시안 목록 조회", description = "**[시안 제출 조회]** 외주명 클릭 시 디자이너들이 제출한 1차 시안 목록을 조회합니다.") + @GetMapping("/{commissionId}/drafts") + public ApiResponse getFirstRoundDrafts( + @AuthenticationPrincipal Long instructorId, + @PathVariable Long commissionId + ) { + + DraftListResponse response = draftFacade.getFirstRoundDrafts(instructorId, commissionId); + return ApiResponse.onSuccess("1차 시안 목록 조회 성공", response); + } + + @Operation(summary = "시안 상세 조회", description = "**[시안 상세 조회]** 시안 클릭 시 시안의 상세 정보를 반환합니다.") + @GetMapping("/{commissionId}/drafts/{draftId}") + public ApiResponse getDraftDetail( + @AuthenticationPrincipal Long instructorId, + @PathVariable Long commissionId, + @PathVariable Long draftId + ) { + + DraftDetailResponse response = draftFacade.getDraftDetail(instructorId, commissionId, draftId); + return ApiResponse.onSuccess("시안 상세 조회 성공", response); + } + + @Operation(summary = "1차 시안 선택", description = "**[시안 확인]** 시안 선택 후 제출하기 클릭 시 해당 시안의 디자이너로 확정하고 외주를 수정 단계로 전환합니다.") + @PostMapping("/{commissionId}/drafts/{draftId}/select") + public ApiResponse selectDraft( + @AuthenticationPrincipal Long instructorId, + @PathVariable Long commissionId, + @PathVariable Long draftId + ) { + + DraftSelectResponse response = draftFacade.selectDraft(instructorId, commissionId, draftId); + return ApiResponse.onSuccess("1차 시안 선택 성공", response); + } + +} diff --git a/src/main/java/ditda/backend/domain/commission/draft/dto/response/DraftDetailResponse.java b/src/main/java/ditda/backend/domain/commission/draft/dto/response/DraftDetailResponse.java new file mode 100644 index 0000000..b1c5ae4 --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/draft/dto/response/DraftDetailResponse.java @@ -0,0 +1,34 @@ +package ditda.backend.domain.commission.draft.dto.response; + +import java.util.List; + +import ditda.backend.domain.commission.draft.entity.enums.WatermarkStatus; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "시안 상세 조회 응답") +public record DraftDetailResponse( + + @Schema(description = "외주 ID", example = "1") + Long commissionId, + + @Schema(description = "시안 ID", example = "1") + Long draftId, + + @Schema(description = "시안 파일 목록") + List files +) { + + @Schema(description = "시안 파일 정보") + public record FileResponse( + + @Schema(description = "페이지 순서", example = "0") + int fileOrder, + + @Schema(description = "파일 URL (워터마크 미완료 시 null)", nullable = true) + String url, + + @Schema(description = "워터마크 상태", example = "COMPLETED") + WatermarkStatus watermarkStatus + ) { + } +} diff --git a/src/main/java/ditda/backend/domain/commission/draft/dto/response/DraftListResponse.java b/src/main/java/ditda/backend/domain/commission/draft/dto/response/DraftListResponse.java new file mode 100644 index 0000000..598e81f --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/draft/dto/response/DraftListResponse.java @@ -0,0 +1,34 @@ +package ditda.backend.domain.commission.draft.dto.response; + +import java.util.List; + +import ditda.backend.domain.commission.draft.entity.enums.WatermarkStatus; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "1차 시안 목록 조회 응답") +public record DraftListResponse( + + @Schema(description = "외주 ID", example = "1") + Long commissionId, + + @Schema(description = "외주명", example = "YBM 영어 교재 - 홍길동") + String title, + + @Schema(description = "1차 시안 목록") + List drafts +) { + + @Schema(description = "시안 정보") + public record DraftResponse( + + @Schema(description = "시안 ID", example = "1") + Long draftId, + + @Schema(description = "썸네일 URL (워터마크 미완료 시 null)", nullable = true) + String thumbnailUrl, + + @Schema(description = "썸네일 워터마크 상태", example = "COMPLETED") + WatermarkStatus watermarkStatus + ) { + } +} diff --git a/src/main/java/ditda/backend/domain/commission/draft/dto/response/DraftSelectResponse.java b/src/main/java/ditda/backend/domain/commission/draft/dto/response/DraftSelectResponse.java new file mode 100644 index 0000000..c1889fb --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/draft/dto/response/DraftSelectResponse.java @@ -0,0 +1,29 @@ +package ditda.backend.domain.commission.draft.dto.response; + +import java.time.LocalDateTime; + +import com.fasterxml.jackson.annotation.JsonFormat; + +import ditda.backend.domain.commission.application.entity.enums.ApplicationStatus; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "시안 선택 응답") +public record DraftSelectResponse( + + @Schema(description = "외주 ID", example = "1") + Long commissionId, + + @Schema(description = "선택한 시안 ID", example = "1") + Long selectedDraftId, + + @Schema(description = "선택된 시안 상태", example = "SELECTED") + ApplicationStatus status, + + @Schema(description = "남은 수정 횟수", example = "3") + int remainingRevisionCount, + + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Seoul") + LocalDateTime selectedAt + +) { +} diff --git a/src/main/java/ditda/backend/domain/commission/draft/entity/CommissionDraft.java b/src/main/java/ditda/backend/domain/commission/draft/entity/CommissionDraft.java new file mode 100644 index 0000000..ccfbabf --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/draft/entity/CommissionDraft.java @@ -0,0 +1,57 @@ +package ditda.backend.domain.commission.draft.entity; + +import ditda.backend.domain.commission.application.entity.CommissionApplication; +import ditda.backend.global.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +// 한 지원에 같은 라운드 시안 1개만 +@Table( + name = "commission_drafts", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_commission_draft_round", + columnNames = {"commission_application_id", "round"} + ) + } +) +@Getter +@Builder +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class CommissionDraft extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "commission_draft_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "commission_application_id", nullable = false) + private CommissionApplication commissionApplication; + + @Column(name = "round", nullable = false) + private int round; + + @Builder.Default + @Column(name = "is_final", nullable = false) + private boolean isFinal = false; + + public boolean isDraftFirstRound() { + return round == 0; + } +} diff --git a/src/main/java/ditda/backend/domain/commission/draft/entity/CommissionDraftFile.java b/src/main/java/ditda/backend/domain/commission/draft/entity/CommissionDraftFile.java new file mode 100644 index 0000000..5802d45 --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/draft/entity/CommissionDraftFile.java @@ -0,0 +1,83 @@ +package ditda.backend.domain.commission.draft.entity; + +import ditda.backend.domain.commission.draft.entity.enums.WatermarkStatus; +import ditda.backend.global.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +// 한 시안의 페이지 순서 중복 X +@Table( + name = "commission_draft_files", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_commission_draft_file_order", + columnNames = {"commission_draft_id", "file_order"} + ) + } +) +@Getter +@Builder +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class CommissionDraftFile extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "commission_draft_file_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "commission_draft_id", nullable = false) + private CommissionDraft commissionDraft; + + @Column(name = "file_order", nullable = false) + private int fileOrder; + + @Column(name = "file_name", length = 100, nullable = false) + private String fileName; + + @Column(name = "file_url", nullable = false) + private String fileUrl; + + @Column(name = "watermarked_file_url") + private String watermarkedFileUrl; + + @Enumerated(EnumType.STRING) + @Column(name = "watermark_status", length = 20, nullable = false) + private WatermarkStatus watermarkStatus; + + public static CommissionDraftFile create( + CommissionDraft commissionDraft, + int fileOrder, + String fileName, + String fileUrl + ) { + return CommissionDraftFile.builder() + .commissionDraft(commissionDraft) + .fileOrder(fileOrder) + .fileName(fileName) + .fileUrl(fileUrl) + .watermarkStatus(WatermarkStatus.PROCESSING) + .build(); + } + + public boolean isWatermarkCompleted() { + return this.watermarkStatus == WatermarkStatus.COMPLETED; + } +} diff --git a/src/main/java/ditda/backend/domain/commission/draft/entity/enums/WatermarkStatus.java b/src/main/java/ditda/backend/domain/commission/draft/entity/enums/WatermarkStatus.java new file mode 100644 index 0000000..9318383 --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/draft/entity/enums/WatermarkStatus.java @@ -0,0 +1,7 @@ +package ditda.backend.domain.commission.draft.entity.enums; + +public enum WatermarkStatus { + PROCESSING, // 워터마크 처리 중 + COMPLETED, // 워터마크 처리 완료 및 강사 조회 가능 + FAILED // 처리 실패 +} diff --git a/src/main/java/ditda/backend/domain/commission/draft/exception/DraftErrorCode.java b/src/main/java/ditda/backend/domain/commission/draft/exception/DraftErrorCode.java new file mode 100644 index 0000000..5c94d1b --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/draft/exception/DraftErrorCode.java @@ -0,0 +1,21 @@ +package ditda.backend.domain.commission.draft.exception; + +import org.springframework.http.HttpStatus; + +import ditda.backend.global.apipayload.code.BaseErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum DraftErrorCode implements BaseErrorCode { + + DRAFTS_NOT_READY(HttpStatus.CONFLICT, "DRAFT_409_01", "아직 모든 디자이너가 시안을 제출하지 않았습니다."), + DRAFT_NOT_FOUND(HttpStatus.NOT_FOUND, "DRAFT_404_01", "시안을 찾을 수 없습니다."), + DRAFT_INVALID_ROUND(HttpStatus.CONFLICT, "DRAFT_409_02", "해당 시안은 1차 시안이 아닙니다."), + WATERMARK_FILE_URL_MISSING(HttpStatus.INTERNAL_SERVER_ERROR, "DRAFT_500_01", "워터마크가 완료되었지만 파일 URL이 존재하지 않습니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} diff --git a/src/main/java/ditda/backend/domain/commission/draft/facade/DraftFacade.java b/src/main/java/ditda/backend/domain/commission/draft/facade/DraftFacade.java new file mode 100644 index 0000000..1b02c50 --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/draft/facade/DraftFacade.java @@ -0,0 +1,62 @@ +package ditda.backend.domain.commission.draft.facade; + +import java.time.LocalDateTime; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import ditda.backend.domain.commission.application.entity.CommissionApplication; +import ditda.backend.domain.commission.application.service.ApplicationService; +import ditda.backend.domain.commission.core.entity.Commission; +import ditda.backend.domain.commission.core.service.InstructorCommissionService; +import ditda.backend.domain.commission.draft.dto.response.DraftDetailResponse; +import ditda.backend.domain.commission.draft.dto.response.DraftListResponse; +import ditda.backend.domain.commission.draft.dto.response.DraftSelectResponse; +import ditda.backend.domain.commission.draft.service.DraftService; +import ditda.backend.domain.designer.entity.Designer; +import ditda.backend.domain.designer.entity.enums.ExpReward; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class DraftFacade { + + private final InstructorCommissionService instructorCommissionService; + private final DraftService draftService; + private final ApplicationService applicationService; + + // 1차 시안 목록 조회 + public DraftListResponse getFirstRoundDrafts(Long instructorId, Long commissionId) { + return draftService.getFirstRoundDrafts(instructorId, commissionId); + } + + // 시안 상세 조회 + public DraftDetailResponse getDraftDetail(Long instructorId, Long commissionId, Long draftId) { + return draftService.getDraftDetail(instructorId, commissionId, draftId); + } + + // 1차 시안 선택 + @Transactional + public DraftSelectResponse selectDraft(Long instructorId, Long commissionId, Long draftId) { + + Commission commission = instructorCommissionService.getOwnedCommission(commissionId, instructorId); + + CommissionApplication selected = draftService.getApplicationForSelection(commission, draftId); + Designer designer = selected.getDesigner(); + + LocalDateTime now = LocalDateTime.now(); + instructorCommissionService.selectDesigner(commission, designer, now); + + applicationService.applySelection(commissionId, selected.getId()); + + designer.gainExp(ExpReward.DRAFT_SELECTED.getAmount()); + + return new DraftSelectResponse( + commissionId, + draftId, + selected.getStatus(), + commission.getMaxRevision(), + now + ); + } +} diff --git a/src/main/java/ditda/backend/domain/commission/draft/mapper/DraftResponseMapper.java b/src/main/java/ditda/backend/domain/commission/draft/mapper/DraftResponseMapper.java new file mode 100644 index 0000000..5336dff --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/draft/mapper/DraftResponseMapper.java @@ -0,0 +1,84 @@ +package ditda.backend.domain.commission.draft.mapper; + +import java.util.List; +import java.util.Map; + +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import ditda.backend.domain.commission.core.entity.Commission; +import ditda.backend.domain.commission.draft.dto.response.DraftDetailResponse; +import ditda.backend.domain.commission.draft.dto.response.DraftListResponse; +import ditda.backend.domain.commission.draft.entity.CommissionDraft; +import ditda.backend.domain.commission.draft.entity.CommissionDraftFile; +import ditda.backend.domain.commission.draft.exception.DraftErrorCode; +import ditda.backend.global.apipayload.exception.GeneralException; +import ditda.backend.global.s3.manager.S3PresignedUrlGenerator; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class DraftResponseMapper { + + private final S3PresignedUrlGenerator s3PresignedUrlGenerator; + + public DraftListResponse toDraftListResponse( + Commission commission, + List drafts, + Map thumbnailByDraftId + ) { + + List items = drafts.stream() + .map(d -> toDraftResponse(d, thumbnailByDraftId.get(d.getId()))) + .toList(); + + return new DraftListResponse(commission.getId(), commission.getTitle(), items); + } + + public DraftDetailResponse toDraftDetailResponse( + Long commissionId, + Long draftId, + List files + ) { + + List fileResponses = files.stream() + .map(this::toFileResponse) + .toList(); + + return new DraftDetailResponse(commissionId, draftId, fileResponses); + } + + private DraftListResponse.DraftResponse toDraftResponse( + CommissionDraft draft, + CommissionDraftFile thumbnail + ) { + + return new DraftListResponse.DraftResponse( + draft.getId(), + thumbnail == null ? null : resolveUrl(thumbnail), + thumbnail == null ? null : thumbnail.getWatermarkStatus()); + } + + private DraftDetailResponse.FileResponse toFileResponse(CommissionDraftFile file) { + + return new DraftDetailResponse.FileResponse( + file.getFileOrder(), + resolveUrl(file), + file.getWatermarkStatus() + ); + } + + private String resolveUrl(CommissionDraftFile file) { + + if (!file.isWatermarkCompleted()) { + return null; + } + + String watermarkedFileUrl = file.getWatermarkedFileUrl(); + if (!StringUtils.hasText(watermarkedFileUrl)) { + throw new GeneralException(DraftErrorCode.WATERMARK_FILE_URL_MISSING); + } + + return s3PresignedUrlGenerator.generatePrivateGetUrl(watermarkedFileUrl); + } +} diff --git a/src/main/java/ditda/backend/domain/commission/draft/repository/CommissionDraftFileRepository.java b/src/main/java/ditda/backend/domain/commission/draft/repository/CommissionDraftFileRepository.java new file mode 100644 index 0000000..74e6b97 --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/draft/repository/CommissionDraftFileRepository.java @@ -0,0 +1,19 @@ +package ditda.backend.domain.commission.draft.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import ditda.backend.domain.commission.draft.entity.CommissionDraftFile; + +public interface CommissionDraftFileRepository extends JpaRepository { + + @Query("select f from CommissionDraftFile f " + + "where f.commissionDraft.id in :draftIds " + + "and f.fileOrder = 0 ") + List findThumbnails(@Param("draftIds") List draftIds); + + List findByCommissionDraftIdOrderByFileOrderAsc(Long draftId); +} diff --git a/src/main/java/ditda/backend/domain/commission/draft/repository/CommissionDraftRepository.java b/src/main/java/ditda/backend/domain/commission/draft/repository/CommissionDraftRepository.java new file mode 100644 index 0000000..4e72143 --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/draft/repository/CommissionDraftRepository.java @@ -0,0 +1,44 @@ +package ditda.backend.domain.commission.draft.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import ditda.backend.domain.commission.draft.entity.CommissionDraft; + +public interface CommissionDraftRepository extends JpaRepository { + + @Query("select d from CommissionDraft d " + + "join d.commissionApplication ca " + + "where ca.commission.id = :commissionId " + + "and d.round = 0 " + + "order by d.id asc") + List findFirstRoundDrafts(@Param("commissionId") Long commissionId); + + @Query("select count(d) from CommissionDraft d " + + "join d.commissionApplication ca " + + "where ca.commission.id = :commissionId " + + "and d.round = 0") + int countFirstRoundDrafts(@Param("commissionId") Long commissionId); + + @Query("select d from CommissionDraft d " + + "join d.commissionApplication ca " + + "where d.id = :draftId " + + "and ca.commission.id = :commissionId") + Optional findDraftInCommission( + @Param("draftId") Long draftId, + @Param("commissionId") Long commissionId + ); + + @Query("select count(d) > 0 from CommissionDraft d " + + "join d.commissionApplication ca " + + "where d.id = :draftId " + + "and ca.commission.id = :commissionId") + boolean existsDraftInCommission( + @Param("draftId") Long draftId, + @Param("commissionId") Long commissionId + ); +} diff --git a/src/main/java/ditda/backend/domain/commission/draft/service/DraftService.java b/src/main/java/ditda/backend/domain/commission/draft/service/DraftService.java new file mode 100644 index 0000000..b82e13c --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/draft/service/DraftService.java @@ -0,0 +1,110 @@ +package ditda.backend.domain.commission.draft.service; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import ditda.backend.domain.commission.application.entity.CommissionApplication; +import ditda.backend.domain.commission.core.entity.Commission; +import ditda.backend.domain.commission.core.service.InstructorCommissionService; +import ditda.backend.domain.commission.draft.dto.response.DraftDetailResponse; +import ditda.backend.domain.commission.draft.dto.response.DraftListResponse; +import ditda.backend.domain.commission.draft.entity.CommissionDraft; +import ditda.backend.domain.commission.draft.entity.CommissionDraftFile; +import ditda.backend.domain.commission.draft.exception.DraftErrorCode; +import ditda.backend.domain.commission.draft.mapper.DraftResponseMapper; +import ditda.backend.domain.commission.draft.repository.CommissionDraftFileRepository; +import ditda.backend.domain.commission.draft.repository.CommissionDraftRepository; +import ditda.backend.global.apipayload.exception.GeneralException; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class DraftService { + + private final InstructorCommissionService instructorCommissionService; + private final CommissionDraftRepository commissionDraftRepository; + private final CommissionDraftFileRepository commissionDraftFileRepository; + private final DraftResponseMapper draftResponseMapper; + + // 1차 시안 목록 조회 + @Transactional(readOnly = true) + public DraftListResponse getFirstRoundDrafts(Long instructorId, Long commissionId) { + + // 1. 외주 조회 + 강사 확인 + Commission commission = instructorCommissionService.getOwnedCommission(commissionId, instructorId); + + // 2. 1차 시안 (round = 0) 목록 확인 + List drafts = commissionDraftRepository.findFirstRoundDrafts(commissionId); + + // 마감 전에는 요구 인원이 다 차야 조회 가능, 마감 후에는 덜 차도 조회 가능 + if (!commission.isDraftListViewable(drafts.size(), LocalDate.now())) { + throw new GeneralException(DraftErrorCode.DRAFTS_NOT_READY); + } + + Map thumbnailByDraftId = drafts.isEmpty() + ? Map.of() + : commissionDraftFileRepository + .findThumbnails(drafts.stream().map(CommissionDraft::getId).toList()) + .stream() + .collect(Collectors.toMap(f -> f.getCommissionDraft().getId(), f -> f)); + + return draftResponseMapper.toDraftListResponse(commission, drafts, thumbnailByDraftId); + } + + // 시안 상세 조회 (1차 시안 및 수정본 포함) + @Transactional(readOnly = true) + public DraftDetailResponse getDraftDetail(Long instructorId, Long commissionId, Long draftId) { + + // 1. 외주 조회 + 강사 확인 + Commission commission = instructorCommissionService.getOwnedCommission(commissionId, instructorId); + + CommissionDraft draft = commissionDraftRepository.findById(draftId) + .orElseThrow(() -> new GeneralException(DraftErrorCode.DRAFT_NOT_FOUND)); + + // 1차 시안의 경우에만 + if (draft.isDraftFirstRound()) { + // 마감 전에는 요구 인원이 다 차야 조회 가능, 마감 후에는 덜 차도 조회 가능 + int draftCount = commissionDraftRepository.countFirstRoundDrafts(commissionId); + if (!commission.isDraftListViewable(draftCount, LocalDate.now())) { + throw new GeneralException(DraftErrorCode.DRAFTS_NOT_READY); + } + } + + // TODO: 수정본 상세 조회 제약조건 필요 + + // 2. 시안 조회 + if (!commissionDraftRepository.existsDraftInCommission(draftId, commissionId)) { + throw new GeneralException(DraftErrorCode.DRAFT_NOT_FOUND); + } + + List files = commissionDraftFileRepository + .findByCommissionDraftIdOrderByFileOrderAsc(draftId); + + return draftResponseMapper.toDraftDetailResponse(commissionId, draftId, files); + } + + // 1차 시안 + public CommissionApplication getApplicationForSelection(Commission commission, Long draftId) { + + // 마감 전에는 요구 인원이 다 차야 조회 가능, 마감 후에는 덜 차도 조회 가능 + int draftCount = commissionDraftRepository.countFirstRoundDrafts(commission.getId()); + if (!commission.isDraftListViewable(draftCount, LocalDate.now())) { + throw new GeneralException(DraftErrorCode.DRAFTS_NOT_READY); + } + + CommissionDraft draft = commissionDraftRepository + .findDraftInCommission(draftId, commission.getId()) + .orElseThrow(() -> new GeneralException(DraftErrorCode.DRAFT_NOT_FOUND)); + + if (!draft.isDraftFirstRound()) { + throw new GeneralException(DraftErrorCode.DRAFT_INVALID_ROUND); + } + + return draft.getCommissionApplication(); + } +} diff --git a/src/main/java/ditda/backend/domain/commission/revision/entity/RevisionDetail.java b/src/main/java/ditda/backend/domain/commission/revision/entity/RevisionDetail.java new file mode 100644 index 0000000..f138e3b --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/revision/entity/RevisionDetail.java @@ -0,0 +1,57 @@ +package ditda.backend.domain.commission.revision.entity; + +import ditda.backend.domain.commission.revision.entity.enums.RevisionCategory; +import ditda.backend.global.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "revision_details") +@Getter +@Builder +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class RevisionDetail extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "revision_detail_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "revision_request_id", nullable = false) + private RevisionRequest revisionRequest; + + @Enumerated(EnumType.STRING) + @Column(name = "category", length = 20, nullable = false) + private RevisionCategory category; + + @Column(name = "comment", length = 300, nullable = false) + private String comment; + + public static RevisionDetail create( + RevisionRequest revisionRequest, + RevisionCategory category, + String comment + ) { + return RevisionDetail.builder() + .revisionRequest(revisionRequest) + .category(category) + .comment(comment) + .build(); + } +} diff --git a/src/main/java/ditda/backend/domain/commission/revision/entity/RevisionRequest.java b/src/main/java/ditda/backend/domain/commission/revision/entity/RevisionRequest.java new file mode 100644 index 0000000..6c73fac --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/revision/entity/RevisionRequest.java @@ -0,0 +1,58 @@ +package ditda.backend.domain.commission.revision.entity; + +import ditda.backend.domain.commission.core.entity.Commission; +import ditda.backend.domain.commission.draft.entity.CommissionDraft; +import ditda.backend.global.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +// 한 시안에 대한 수정요청 1개만 +@Table( + name = "revision_requests", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_revision_request_target_draft", + columnNames = {"target_draft_id"} + ) + } +) +@Getter +@Builder +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class RevisionRequest extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "revision_request_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "commission_id", nullable = false) + private Commission commission; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "target_draft_id", nullable = false) + private CommissionDraft targetDraft; + + public static RevisionRequest create(Commission commission, CommissionDraft targetDraft) { + return RevisionRequest.builder() + .commission(commission) + .targetDraft(targetDraft) + .build(); + } +} diff --git a/src/main/java/ditda/backend/domain/commission/revision/entity/RevisionResponse.java b/src/main/java/ditda/backend/domain/commission/revision/entity/RevisionResponse.java new file mode 100644 index 0000000..2c1986d --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/revision/entity/RevisionResponse.java @@ -0,0 +1,58 @@ +package ditda.backend.domain.commission.revision.entity; + +import ditda.backend.domain.commission.draft.entity.CommissionDraft; +import ditda.backend.global.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.MapsId; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "revision_responses") +@Getter +@Builder +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class RevisionResponse extends BaseEntity { + + @Id + @Column(name = "revision_request_id") + private Long id; + + @OneToOne(fetch = FetchType.LAZY) + @MapsId + @JoinColumn(name = "revision_request_id") + private RevisionRequest revisionRequest; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "commission_draft_id", nullable = false, unique = true) + private CommissionDraft commissionDraft; + + @Column(name = "designer_comment", length = 500, nullable = false) + private String designerComment; + + @Builder.Default + @Column(name = "checked", nullable = false) + private boolean checked = false; + + public static RevisionResponse create( + RevisionRequest revisionRequest, + CommissionDraft commissionDraft, + String designerComment + ) { + return RevisionResponse.builder() + .revisionRequest(revisionRequest) + .commissionDraft(commissionDraft) + .designerComment(designerComment) + .build(); + } +} diff --git a/src/main/java/ditda/backend/domain/commission/revision/entity/enums/RevisionCategory.java b/src/main/java/ditda/backend/domain/commission/revision/entity/enums/RevisionCategory.java new file mode 100644 index 0000000..c3df948 --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/revision/entity/enums/RevisionCategory.java @@ -0,0 +1,9 @@ +package ditda.backend.domain.commission.revision.entity.enums; + +public enum RevisionCategory { + LAYOUT, // 레이아웃 수정 + TYPOGRAPHY, // 타이포 수정 + DESIGN, // 디자인 수정 + COLOR, // 색상 수정 + ETC // 기타 +} diff --git a/src/main/java/ditda/backend/domain/commission/revision/repository/RevisionDetailRepository.java b/src/main/java/ditda/backend/domain/commission/revision/repository/RevisionDetailRepository.java new file mode 100644 index 0000000..1a2207b --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/revision/repository/RevisionDetailRepository.java @@ -0,0 +1,8 @@ +package ditda.backend.domain.commission.revision.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import ditda.backend.domain.commission.revision.entity.RevisionDetail; + +public interface RevisionDetailRepository extends JpaRepository { +} diff --git a/src/main/java/ditda/backend/domain/commission/revision/repository/RevisionRequestRepository.java b/src/main/java/ditda/backend/domain/commission/revision/repository/RevisionRequestRepository.java new file mode 100644 index 0000000..d011fba --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/revision/repository/RevisionRequestRepository.java @@ -0,0 +1,8 @@ +package ditda.backend.domain.commission.revision.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import ditda.backend.domain.commission.revision.entity.RevisionRequest; + +public interface RevisionRequestRepository extends JpaRepository { +} diff --git a/src/main/java/ditda/backend/domain/commission/revision/repository/RevisionResponseRepository.java b/src/main/java/ditda/backend/domain/commission/revision/repository/RevisionResponseRepository.java new file mode 100644 index 0000000..4f6db6f --- /dev/null +++ b/src/main/java/ditda/backend/domain/commission/revision/repository/RevisionResponseRepository.java @@ -0,0 +1,8 @@ +package ditda.backend.domain.commission.revision.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import ditda.backend.domain.commission.revision.entity.RevisionResponse; + +public interface RevisionResponseRepository extends JpaRepository { +} diff --git a/src/main/java/ditda/backend/domain/common/auth/mapper/AuthResponseMapper.java b/src/main/java/ditda/backend/domain/common/auth/mapper/AuthResponseMapper.java deleted file mode 100644 index f65e694..0000000 --- a/src/main/java/ditda/backend/domain/common/auth/mapper/AuthResponseMapper.java +++ /dev/null @@ -1,24 +0,0 @@ -package ditda.backend.domain.common.auth.mapper; - -import org.springframework.stereotype.Component; - -import ditda.backend.domain.common.auth.dto.AuthResult; -import ditda.backend.domain.common.auth.dto.response.LoginResponse; -import ditda.backend.global.s3.S3UrlResolver; -import lombok.RequiredArgsConstructor; - -@Component -@RequiredArgsConstructor -public class AuthResponseMapper { - - private final S3UrlResolver s3UrlResolver; - - public LoginResponse toLoginResponse(AuthResult result) { - return new LoginResponse( - result.userId(), - result.name(), - s3UrlResolver.toPublicS3Url(result.profileImage()), - result.accessToken() - ); - } -} diff --git a/src/main/java/ditda/backend/domain/common/term/dto/TermAgreement.java b/src/main/java/ditda/backend/domain/common/term/dto/TermAgreement.java deleted file mode 100644 index f86f53b..0000000 --- a/src/main/java/ditda/backend/domain/common/term/dto/TermAgreement.java +++ /dev/null @@ -1,10 +0,0 @@ -package ditda.backend.domain.common.term.dto; - -import ditda.backend.domain.common.term.entity.enums.TermType; - -public record TermAgreement( - TermType type, - String version, - boolean isAgreed -) { -} diff --git a/src/main/java/ditda/backend/domain/common/user/entity/enums/UserRole.java b/src/main/java/ditda/backend/domain/common/user/entity/enums/UserRole.java deleted file mode 100644 index c98040b..0000000 --- a/src/main/java/ditda/backend/domain/common/user/entity/enums/UserRole.java +++ /dev/null @@ -1,6 +0,0 @@ -package ditda.backend.domain.common.user.entity.enums; - -public enum UserRole { - DESIGNER, - INSTRUCTOR -} diff --git a/src/main/java/ditda/backend/domain/designer/auth/controller/DesignerAuthController.java b/src/main/java/ditda/backend/domain/designer/auth/controller/DesignerAuthController.java deleted file mode 100644 index d697861..0000000 --- a/src/main/java/ditda/backend/domain/designer/auth/controller/DesignerAuthController.java +++ /dev/null @@ -1,66 +0,0 @@ -package ditda.backend.domain.designer.auth.controller; - -import java.util.List; - -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestPart; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.multipart.MultipartFile; - -import ditda.backend.domain.designer.auth.dto.DesignerAuthResult; -import ditda.backend.domain.designer.auth.dto.request.DesignerSignupRequest; -import ditda.backend.domain.designer.auth.dto.response.DesignerSignupResponse; -import ditda.backend.domain.designer.auth.facade.DesignerAuthFacade; -import ditda.backend.domain.designer.auth.mapper.DesignerAuthResponseMapper; -import ditda.backend.global.apipayload.response.ApiResponse; -import ditda.backend.global.jwt.utils.CookieUtils; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Encoding; -import io.swagger.v3.oas.annotations.parameters.RequestBody; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.servlet.http.HttpServletResponse; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; - -@RestController -@RequestMapping("/api/v1/designers/auth") -@RequiredArgsConstructor -@Tag(name = "Designer Auth", description = "디자이너 회원가입 API") -public class DesignerAuthController { - - private final DesignerAuthFacade designerAuthFacade; - private final CookieUtils cookieUtils; - private final DesignerAuthResponseMapper authResponseMapper; - - @Operation( - summary = "디자이너 회원가입", - description = "**[회원가입]** 회원가입 후 자동 로그인 처리됩니다. `data`(JSON) + `portfolioFiles`(파일 최대 3개, 각 30MB)" - ) - @RequestBody( - content = @Content( - mediaType = MediaType.MULTIPART_FORM_DATA_VALUE, - encoding = @Encoding(name = "data", contentType = MediaType.APPLICATION_JSON_VALUE) - ) - ) - @PostMapping(value = "/signup", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - public ApiResponse signup( - @Valid @RequestPart("data") DesignerSignupRequest request, - @RequestPart(value = "portfolioFiles", required = false) List portfolioFiles, - HttpServletResponse response - ) { - - DesignerAuthResult result = designerAuthFacade.signup(request, portfolioFiles); - - addRefreshTokenCookie(response, result.refreshToken()); - - return ApiResponse.onSuccess("디자이너 회원가입 성공", authResponseMapper.toSignupResponse(result)); - } - - private void addRefreshTokenCookie(HttpServletResponse response, String refreshToken) { - response.addHeader(HttpHeaders.SET_COOKIE, cookieUtils.createRefreshTokenCookie(refreshToken).toString()); - } -} diff --git a/src/main/java/ditda/backend/domain/designer/auth/dto/DesignerAuthResult.java b/src/main/java/ditda/backend/domain/designer/auth/dto/DesignerAuthResult.java deleted file mode 100644 index c50df81..0000000 --- a/src/main/java/ditda/backend/domain/designer/auth/dto/DesignerAuthResult.java +++ /dev/null @@ -1,15 +0,0 @@ -package ditda.backend.domain.designer.auth.dto; - -public record DesignerAuthResult( - - Long userId, - - String name, - - String profileImage, - - String accessToken, - - String refreshToken -) { -} diff --git a/src/main/java/ditda/backend/domain/designer/auth/facade/DesignerAuthFacade.java b/src/main/java/ditda/backend/domain/designer/auth/facade/DesignerAuthFacade.java deleted file mode 100644 index 0e379e2..0000000 --- a/src/main/java/ditda/backend/domain/designer/auth/facade/DesignerAuthFacade.java +++ /dev/null @@ -1,52 +0,0 @@ -package ditda.backend.domain.designer.auth.facade; - -import java.util.List; - -import org.springframework.stereotype.Component; -import org.springframework.web.multipart.MultipartFile; - -import ditda.backend.domain.common.auth.service.EmailVerificationService; -import ditda.backend.domain.common.user.service.UserService; -import ditda.backend.domain.designer.auth.dto.DesignerAuthResult; -import ditda.backend.domain.designer.auth.dto.request.DesignerSignupRequest; -import ditda.backend.domain.designer.auth.service.DesignerAuthService; -import ditda.backend.domain.designer.auth.service.PortfolioService; -import lombok.RequiredArgsConstructor; - -@Component -@RequiredArgsConstructor -public class DesignerAuthFacade { - - private final DesignerAuthService designerAuthService; - private final EmailVerificationService emailVerificationService; - private final PortfolioService portfolioService; - private final UserService userService; - - public DesignerAuthResult signup(DesignerSignupRequest request, List portfolioFiles) { - - // 이메일 인증 여부 확인 - emailVerificationService.validateVerified(request.email()); - - // 아이디 및 이메일 검증 - userService.validateUsernameAvailable(request.username()); - userService.validateEmailAvailable(request.email()); - - // 포트폴리오 파일 검증 및 S3 업로드 - portfolioService.validateFiles(portfolioFiles); - List portfolioKeys = portfolioService.uploadFiles(portfolioFiles); - - // 회원가입 - DesignerAuthResult result; - try { - result = designerAuthService.signup(request, portfolioKeys); - } catch (Exception e) { - portfolioService.deleteFiles(portfolioKeys); - throw e; - } - - // 이메일 인증 마크 정리 - emailVerificationService.deleteVerified(request.email()); - - return result; - } -} diff --git a/src/main/java/ditda/backend/domain/designer/auth/mapper/DesignerAuthResponseMapper.java b/src/main/java/ditda/backend/domain/designer/auth/mapper/DesignerAuthResponseMapper.java deleted file mode 100644 index 466fc5b..0000000 --- a/src/main/java/ditda/backend/domain/designer/auth/mapper/DesignerAuthResponseMapper.java +++ /dev/null @@ -1,24 +0,0 @@ -package ditda.backend.domain.designer.auth.mapper; - -import org.springframework.stereotype.Component; - -import ditda.backend.domain.designer.auth.dto.DesignerAuthResult; -import ditda.backend.domain.designer.auth.dto.response.DesignerSignupResponse; -import ditda.backend.global.s3.S3UrlResolver; -import lombok.RequiredArgsConstructor; - -@Component -@RequiredArgsConstructor -public class DesignerAuthResponseMapper { - - private final S3UrlResolver s3UrlResolver; - - public DesignerSignupResponse toSignupResponse(DesignerAuthResult result) { - return new DesignerSignupResponse( - result.userId(), - result.name(), - s3UrlResolver.toPublicS3Url(result.profileImage()), - result.accessToken() - ); - } -} diff --git a/src/main/java/ditda/backend/domain/designer/auth/service/PortfolioService.java b/src/main/java/ditda/backend/domain/designer/auth/service/PortfolioService.java deleted file mode 100644 index be4fefe..0000000 --- a/src/main/java/ditda/backend/domain/designer/auth/service/PortfolioService.java +++ /dev/null @@ -1,89 +0,0 @@ -package ditda.backend.domain.designer.auth.service; - -import java.util.List; -import java.util.Set; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.util.unit.DataSize; -import org.springframework.web.multipart.MultipartFile; - -import ditda.backend.domain.designer.auth.entity.Designer; -import ditda.backend.domain.designer.auth.entity.Portfolio; -import ditda.backend.domain.designer.auth.exception.DesignerErrorCode; -import ditda.backend.domain.designer.auth.repository.PortfolioRepository; -import ditda.backend.global.apipayload.exception.GeneralException; -import ditda.backend.global.s3.S3FileUploader; -import ditda.backend.global.s3.enums.BucketType; -import ditda.backend.global.s3.exception.S3UploadException; -import lombok.RequiredArgsConstructor; - -@Service -@RequiredArgsConstructor -public class PortfolioService { - - private static final int MAX_PORTFOLIO_COUNT = 3; - private static final String S3_KEY_PREFIX = "portfolio"; - private static final Set ALLOWED_CONTENT_TYPES = Set.of( - "application/pdf", - "image/png" - ); - - private final S3FileUploader s3FileUploader; - private final PortfolioRepository portfolioRepository; - - @Value("${spring.servlet.multipart.max-file-size}") - private DataSize maxFileSize; - - public void validateFiles(List files) { - if (files == null || files.isEmpty()) { - return; - } - - // 파일 최대 개수 검증 - long realCount = files.stream().filter(f -> !f.isEmpty()).count(); - if (realCount > MAX_PORTFOLIO_COUNT) { - throw new GeneralException(DesignerErrorCode.PORTFOLIO_FILE_LIMIT_EXCEEDED); - } - - // 파일 사이즈 및 타입 검증 - for (MultipartFile file : files) { - if (file.isEmpty()) { - continue; - } - - if (file.getSize() > maxFileSize.toBytes()) { - throw new GeneralException(DesignerErrorCode.PORTFOLIO_FILE_SIZE_EXCEEDED); - } - if (!ALLOWED_CONTENT_TYPES.contains(file.getContentType())) { - throw new GeneralException(DesignerErrorCode.INVALID_PORTFOLIO_FILE); - } - } - } - - public List uploadFiles(List files) { - try { - return s3FileUploader.uploadAll(BucketType.PRIVATE, S3_KEY_PREFIX, files); - } catch (S3UploadException e) { - throw new GeneralException(DesignerErrorCode.PORTFOLIO_UPLOAD_FAILED); - } - } - - @Transactional - public void savePortfolios(Designer designer, List portfolioKeys) { - if (portfolioKeys.isEmpty()) { - return; - } - - // 파일을 DB에 일괄 저장 - List portfolios = portfolioKeys.stream() - .map(key -> Portfolio.createPortfolio(designer, key)) - .toList(); - portfolioRepository.saveAll(portfolios); - } - - public void deleteFiles(List portfolioKeys) { - s3FileUploader.deleteAll(BucketType.PRIVATE, portfolioKeys); - } -} diff --git a/src/main/java/ditda/backend/domain/designer/auth/entity/Designer.java b/src/main/java/ditda/backend/domain/designer/entity/Designer.java similarity index 69% rename from src/main/java/ditda/backend/domain/designer/auth/entity/Designer.java rename to src/main/java/ditda/backend/domain/designer/entity/Designer.java index 47eaf49..d337089 100644 --- a/src/main/java/ditda/backend/domain/designer/auth/entity/Designer.java +++ b/src/main/java/ditda/backend/domain/designer/entity/Designer.java @@ -1,7 +1,10 @@ -package ditda.backend.domain.designer.auth.entity; +package ditda.backend.domain.designer.entity; -import ditda.backend.domain.common.user.entity.User; -import ditda.backend.domain.designer.auth.entity.enums.BankName; +import org.springframework.data.domain.Persistable; + +import ditda.backend.domain.designer.entity.enums.BankName; +import ditda.backend.domain.designer.entity.enums.DesignerLevel; +import ditda.backend.domain.user.entity.User; import ditda.backend.global.encryption.AesEncryptConverter; import ditda.backend.global.entity.BaseEntity; import jakarta.persistence.Column; @@ -27,7 +30,7 @@ @Builder @AllArgsConstructor(access = AccessLevel.PRIVATE) @NoArgsConstructor(access = AccessLevel.PROTECTED) -public class Designer extends BaseEntity { +public class Designer extends BaseEntity implements Persistable { @Id @Column(name = "designer_id") @@ -38,9 +41,10 @@ public class Designer extends BaseEntity { @JoinColumn(name = "designer_id") private User user; + @Enumerated(EnumType.STRING) @Builder.Default - @Column(name = "level", nullable = false) - private int level = 1; + @Column(name = "level", length = 20, nullable = false) + private DesignerLevel level = DesignerLevel.LEVEL_1; @Builder.Default @Column(name = "exp", nullable = false) @@ -57,6 +61,11 @@ public class Designer extends BaseEntity { @Column(name = "account_holder", length = 50, nullable = false) private String accountHolder; + @Override + public boolean isNew() { + return getCreatedAt() == null; + } + public static Designer createDesigner(User user, BankName bankName, String accountNumber, String accountHolder) { return Designer.builder() .user(user) @@ -65,4 +74,12 @@ public static Designer createDesigner(User user, BankName bankName, String accou .accountHolder(accountHolder) .build(); } + + public void gainExp(int amount) { + this.exp += amount; + while (level.canLevelUp(this.exp)) { + this.exp -= level.getRequiredExp(); + this.level = level.next(); + } + } } diff --git a/src/main/java/ditda/backend/domain/designer/auth/entity/Portfolio.java b/src/main/java/ditda/backend/domain/designer/entity/Portfolio.java similarity index 95% rename from src/main/java/ditda/backend/domain/designer/auth/entity/Portfolio.java rename to src/main/java/ditda/backend/domain/designer/entity/Portfolio.java index 5063d5c..9568631 100644 --- a/src/main/java/ditda/backend/domain/designer/auth/entity/Portfolio.java +++ b/src/main/java/ditda/backend/domain/designer/entity/Portfolio.java @@ -1,4 +1,4 @@ -package ditda.backend.domain.designer.auth.entity; +package ditda.backend.domain.designer.entity; import ditda.backend.global.entity.BaseEntity; import jakarta.persistence.Column; diff --git a/src/main/java/ditda/backend/domain/designer/auth/entity/enums/BankName.java b/src/main/java/ditda/backend/domain/designer/entity/enums/BankName.java similarity index 89% rename from src/main/java/ditda/backend/domain/designer/auth/entity/enums/BankName.java rename to src/main/java/ditda/backend/domain/designer/entity/enums/BankName.java index 358729a..6a577ad 100644 --- a/src/main/java/ditda/backend/domain/designer/auth/entity/enums/BankName.java +++ b/src/main/java/ditda/backend/domain/designer/entity/enums/BankName.java @@ -1,4 +1,4 @@ -package ditda.backend.domain.designer.auth.entity.enums; +package ditda.backend.domain.designer.entity.enums; import lombok.Getter; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/ditda/backend/domain/designer/entity/enums/DesignerLevel.java b/src/main/java/ditda/backend/domain/designer/entity/enums/DesignerLevel.java new file mode 100644 index 0000000..71d4f09 --- /dev/null +++ b/src/main/java/ditda/backend/domain/designer/entity/enums/DesignerLevel.java @@ -0,0 +1,31 @@ +package ditda.backend.domain.designer.entity.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum DesignerLevel { + + LEVEL_1(500), // LEVEL_2 까지 500 + LEVEL_2(1000), // LEVEL_3 까지 1000 + LEVEL_3(0); // 만렙 + + private final int requiredExp; + + public boolean isMax() { + return this == LEVEL_3; + } + + public boolean canLevelUp(int exp) { + return !isMax() && exp >= requiredExp; + } + + public DesignerLevel next() { + return switch (this) { + case LEVEL_1 -> LEVEL_2; + case LEVEL_2 -> LEVEL_3; + case LEVEL_3 -> LEVEL_3; + }; + } +} diff --git a/src/main/java/ditda/backend/domain/designer/entity/enums/ExpReward.java b/src/main/java/ditda/backend/domain/designer/entity/enums/ExpReward.java new file mode 100644 index 0000000..525e8f5 --- /dev/null +++ b/src/main/java/ditda/backend/domain/designer/entity/enums/ExpReward.java @@ -0,0 +1,16 @@ +package ditda.backend.domain.designer.entity.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ExpReward { + + PORTFOLIO(50), + FIRST_DRAFT_SUBMIT(100), + DRAFT_SELECTED(150), + REVISION(200); + + private final int amount; +} diff --git a/src/main/java/ditda/backend/domain/designer/auth/exception/DesignerErrorCode.java b/src/main/java/ditda/backend/domain/designer/exception/DesignerErrorCode.java similarity index 50% rename from src/main/java/ditda/backend/domain/designer/auth/exception/DesignerErrorCode.java rename to src/main/java/ditda/backend/domain/designer/exception/DesignerErrorCode.java index 717bf26..f74d304 100644 --- a/src/main/java/ditda/backend/domain/designer/auth/exception/DesignerErrorCode.java +++ b/src/main/java/ditda/backend/domain/designer/exception/DesignerErrorCode.java @@ -1,4 +1,4 @@ -package ditda.backend.domain.designer.auth.exception; +package ditda.backend.domain.designer.exception; import org.springframework.http.HttpStatus; @@ -10,10 +10,7 @@ @AllArgsConstructor public enum DesignerErrorCode implements BaseErrorCode { - PORTFOLIO_FILE_LIMIT_EXCEEDED(HttpStatus.BAD_REQUEST, "DESIGNER_400_01", "포트폴리오 파일은 최대 3개까지 업로드 가능합니다."), - INVALID_PORTFOLIO_FILE(HttpStatus.BAD_REQUEST, "DESIGNER_400_02", "지원하지 않는 파일 형식입니다."), - PORTFOLIO_FILE_SIZE_EXCEEDED(HttpStatus.CONTENT_TOO_LARGE, "DESIGNER_413_01", "포트폴리오 파일 크기가 제한을 초과했습니다."), - PORTFOLIO_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "DESIGNER_500_01", "포트폴리오 파일 업로드에 실패했습니다."); + PORTFOLIO_FILE_LIMIT_EXCEEDED(HttpStatus.BAD_REQUEST, "DESIGNER_400_01", "포트폴리오 파일은 최대 3개까지 업로드 가능합니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/ditda/backend/domain/designer/auth/repository/DesignerRepository.java b/src/main/java/ditda/backend/domain/designer/repository/DesignerRepository.java similarity index 55% rename from src/main/java/ditda/backend/domain/designer/auth/repository/DesignerRepository.java rename to src/main/java/ditda/backend/domain/designer/repository/DesignerRepository.java index ac23d38..cfbc997 100644 --- a/src/main/java/ditda/backend/domain/designer/auth/repository/DesignerRepository.java +++ b/src/main/java/ditda/backend/domain/designer/repository/DesignerRepository.java @@ -1,8 +1,8 @@ -package ditda.backend.domain.designer.auth.repository; +package ditda.backend.domain.designer.repository; import org.springframework.data.jpa.repository.JpaRepository; -import ditda.backend.domain.designer.auth.entity.Designer; +import ditda.backend.domain.designer.entity.Designer; public interface DesignerRepository extends JpaRepository { } diff --git a/src/main/java/ditda/backend/domain/designer/auth/repository/PortfolioRepository.java b/src/main/java/ditda/backend/domain/designer/repository/PortfolioRepository.java similarity index 55% rename from src/main/java/ditda/backend/domain/designer/auth/repository/PortfolioRepository.java rename to src/main/java/ditda/backend/domain/designer/repository/PortfolioRepository.java index d1c784c..7288954 100644 --- a/src/main/java/ditda/backend/domain/designer/auth/repository/PortfolioRepository.java +++ b/src/main/java/ditda/backend/domain/designer/repository/PortfolioRepository.java @@ -1,8 +1,8 @@ -package ditda.backend.domain.designer.auth.repository; +package ditda.backend.domain.designer.repository; import org.springframework.data.jpa.repository.JpaRepository; -import ditda.backend.domain.designer.auth.entity.Portfolio; +import ditda.backend.domain.designer.entity.Portfolio; public interface PortfolioRepository extends JpaRepository { } diff --git a/src/main/java/ditda/backend/domain/designer/service/PortfolioService.java b/src/main/java/ditda/backend/domain/designer/service/PortfolioService.java new file mode 100644 index 0000000..f092a54 --- /dev/null +++ b/src/main/java/ditda/backend/domain/designer/service/PortfolioService.java @@ -0,0 +1,68 @@ +package ditda.backend.domain.designer.service; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import ditda.backend.domain.designer.entity.Designer; +import ditda.backend.domain.designer.entity.Portfolio; +import ditda.backend.domain.designer.exception.DesignerErrorCode; +import ditda.backend.domain.designer.repository.PortfolioRepository; +import ditda.backend.global.apipayload.exception.GeneralException; +import ditda.backend.global.s3.dto.PresignedUpload; +import ditda.backend.global.s3.enums.BucketType; +import ditda.backend.global.s3.enums.UploadTarget; +import ditda.backend.global.s3.manager.S3UploadManager; +import ditda.backend.global.s3.service.S3FileService; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class PortfolioService { + + private static final int MAX_PORTFOLIO_COUNT = 3; + private static final BucketType BUCKET = BucketType.PRIVATE; + + private final S3UploadManager s3UploadManager; + private final S3FileService s3FileService; + private final PortfolioRepository portfolioRepository; + + public PresignedUpload generatePresignedUpload(String contentType) { + + return s3FileService.issuePresignedUpload(UploadTarget.PORTFOLIO, contentType); + } + + public void validateKeys(List keys) { + + // 파일 개수 검증 + if (keys.size() > MAX_PORTFOLIO_COUNT) { + throw new GeneralException(DesignerErrorCode.PORTFOLIO_FILE_LIMIT_EXCEEDED); + } + + // distinct, 형식, 크기 검증 + s3FileService.validateUploadedKeys(UploadTarget.PORTFOLIO, keys); + } + + @Transactional + public void savePortfolios(Designer designer, List portfolioKeys) { + if (portfolioKeys.isEmpty()) { + return; + } + + // 파일을 DB에 일괄 저장 + List portfolios = portfolioKeys.stream() + .map(key -> Portfolio.createPortfolio(designer, key)) + .toList(); + portfolioRepository.saveAll(portfolios); + } + + public List promote(List tempKeys) { + return s3UploadManager.promote(BUCKET, tempKeys); + } + + public void deleteFiles(List portfolioKeys) { + s3UploadManager.deleteAll(BUCKET, portfolioKeys); + } + +} diff --git a/src/main/java/ditda/backend/domain/instructor/auth/dto/InstructorAuthResult.java b/src/main/java/ditda/backend/domain/instructor/auth/dto/InstructorAuthResult.java deleted file mode 100644 index 1bc94b7..0000000 --- a/src/main/java/ditda/backend/domain/instructor/auth/dto/InstructorAuthResult.java +++ /dev/null @@ -1,15 +0,0 @@ -package ditda.backend.domain.instructor.auth.dto; - -public record InstructorAuthResult( - - Long userId, - - String name, - - String profileImage, - - String accessToken, - - String refreshToken -) { -} diff --git a/src/main/java/ditda/backend/domain/instructor/auth/dto/response/InstructorSignupResponse.java b/src/main/java/ditda/backend/domain/instructor/auth/dto/response/InstructorSignupResponse.java deleted file mode 100644 index f989f47..0000000 --- a/src/main/java/ditda/backend/domain/instructor/auth/dto/response/InstructorSignupResponse.java +++ /dev/null @@ -1,20 +0,0 @@ -package ditda.backend.domain.instructor.auth.dto.response; - -import io.swagger.v3.oas.annotations.media.Schema; - -@Schema(description = "강사 회원가입 응답") -public record InstructorSignupResponse( - - @Schema(description = "User ID") - Long userId, - - @Schema(description = "사용자 이름", example = "홍길동") - String name, - - @Schema(description = "프로필 이미지", example = "https://example.com/profile/default.png") - String profileImageUrl, - - @Schema(description = "Access Token") - String accessToken -) { -} diff --git a/src/main/java/ditda/backend/domain/instructor/auth/facade/InstructorAuthFacade.java b/src/main/java/ditda/backend/domain/instructor/auth/facade/InstructorAuthFacade.java deleted file mode 100644 index 43d7f08..0000000 --- a/src/main/java/ditda/backend/domain/instructor/auth/facade/InstructorAuthFacade.java +++ /dev/null @@ -1,29 +0,0 @@ -package ditda.backend.domain.instructor.auth.facade; - -import org.springframework.stereotype.Component; - -import ditda.backend.domain.common.auth.service.EmailVerificationService; -import ditda.backend.domain.instructor.auth.dto.InstructorAuthResult; -import ditda.backend.domain.instructor.auth.dto.request.InstructorSignupRequest; -import ditda.backend.domain.instructor.auth.service.InstructorAuthService; -import lombok.RequiredArgsConstructor; - -@Component -@RequiredArgsConstructor -public class InstructorAuthFacade { - - private final InstructorAuthService instructorAuthService; - private final EmailVerificationService emailVerificationService; - - public InstructorAuthResult signup(InstructorSignupRequest request) { - - emailVerificationService.validateVerified(request.email()); - - InstructorAuthResult result = instructorAuthService.signup(request); - - emailVerificationService.deleteVerified(request.email()); - - return result; - } - -} diff --git a/src/main/java/ditda/backend/domain/instructor/auth/mapper/InstructorAuthResponseMapper.java b/src/main/java/ditda/backend/domain/instructor/auth/mapper/InstructorAuthResponseMapper.java deleted file mode 100644 index f368efd..0000000 --- a/src/main/java/ditda/backend/domain/instructor/auth/mapper/InstructorAuthResponseMapper.java +++ /dev/null @@ -1,24 +0,0 @@ -package ditda.backend.domain.instructor.auth.mapper; - -import org.springframework.stereotype.Component; - -import ditda.backend.domain.instructor.auth.dto.InstructorAuthResult; -import ditda.backend.domain.instructor.auth.dto.response.InstructorSignupResponse; -import ditda.backend.global.s3.S3UrlResolver; -import lombok.RequiredArgsConstructor; - -@Component -@RequiredArgsConstructor -public class InstructorAuthResponseMapper { - - private final S3UrlResolver s3UrlResolver; - - public InstructorSignupResponse toSignupResponse(InstructorAuthResult result) { - return new InstructorSignupResponse( - result.userId(), - result.name(), - s3UrlResolver.toPublicS3Url(result.profileImage()), - result.accessToken() - ); - } -} diff --git a/src/main/java/ditda/backend/domain/instructor/auth/entity/Instructor.java b/src/main/java/ditda/backend/domain/instructor/entity/Instructor.java similarity index 72% rename from src/main/java/ditda/backend/domain/instructor/auth/entity/Instructor.java rename to src/main/java/ditda/backend/domain/instructor/entity/Instructor.java index cca971c..c703c42 100644 --- a/src/main/java/ditda/backend/domain/instructor/auth/entity/Instructor.java +++ b/src/main/java/ditda/backend/domain/instructor/entity/Instructor.java @@ -1,6 +1,8 @@ -package ditda.backend.domain.instructor.auth.entity; +package ditda.backend.domain.instructor.entity; -import ditda.backend.domain.common.user.entity.User; +import org.springframework.data.domain.Persistable; + +import ditda.backend.domain.user.entity.User; import ditda.backend.global.entity.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -22,7 +24,7 @@ @Builder @AllArgsConstructor(access = AccessLevel.PRIVATE) @NoArgsConstructor(access = AccessLevel.PROTECTED) -public class Instructor extends BaseEntity { +public class Instructor extends BaseEntity implements Persistable { @Id @Column(name = "instructor_id") @@ -33,9 +35,19 @@ public class Instructor extends BaseEntity { @JoinColumn(name = "instructor_id") private User user; + @Override + public boolean isNew() { + return getCreatedAt() == null; + } + public static Instructor createInstructor(User user) { return Instructor.builder() .user(user) .build(); } + + public String getName() { + return user.getName(); + } + } diff --git a/src/main/java/ditda/backend/domain/instructor/exception/InstructorErrorCode.java b/src/main/java/ditda/backend/domain/instructor/exception/InstructorErrorCode.java new file mode 100644 index 0000000..5e8df51 --- /dev/null +++ b/src/main/java/ditda/backend/domain/instructor/exception/InstructorErrorCode.java @@ -0,0 +1,18 @@ +package ditda.backend.domain.instructor.exception; + +import org.springframework.http.HttpStatus; + +import ditda.backend.global.apipayload.code.BaseErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum InstructorErrorCode implements BaseErrorCode { + + INSTRUCTOR_NOT_FOUND(HttpStatus.NOT_FOUND, "INSTRUCTOR_404_01", "강사를 찾을 수 없습니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} diff --git a/src/main/java/ditda/backend/domain/instructor/auth/repository/InstructorRepository.java b/src/main/java/ditda/backend/domain/instructor/repository/InstructorRepository.java similarity index 55% rename from src/main/java/ditda/backend/domain/instructor/auth/repository/InstructorRepository.java rename to src/main/java/ditda/backend/domain/instructor/repository/InstructorRepository.java index 80148b2..73fffa1 100644 --- a/src/main/java/ditda/backend/domain/instructor/auth/repository/InstructorRepository.java +++ b/src/main/java/ditda/backend/domain/instructor/repository/InstructorRepository.java @@ -1,8 +1,8 @@ -package ditda.backend.domain.instructor.auth.repository; +package ditda.backend.domain.instructor.repository; import org.springframework.data.jpa.repository.JpaRepository; -import ditda.backend.domain.instructor.auth.entity.Instructor; +import ditda.backend.domain.instructor.entity.Instructor; public interface InstructorRepository extends JpaRepository { } diff --git a/src/main/java/ditda/backend/domain/instructor/service/InstructorService.java b/src/main/java/ditda/backend/domain/instructor/service/InstructorService.java new file mode 100644 index 0000000..d4bd54c --- /dev/null +++ b/src/main/java/ditda/backend/domain/instructor/service/InstructorService.java @@ -0,0 +1,24 @@ +package ditda.backend.domain.instructor.service; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import ditda.backend.domain.instructor.entity.Instructor; +import ditda.backend.domain.instructor.exception.InstructorErrorCode; +import ditda.backend.domain.instructor.repository.InstructorRepository; +import ditda.backend.global.apipayload.exception.GeneralException; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class InstructorService { + + private final InstructorRepository instructorRepository; + + @Transactional(readOnly = true) + public Instructor getById(Long instructorId) { + + return instructorRepository.findById(instructorId) + .orElseThrow(() -> new GeneralException(InstructorErrorCode.INSTRUCTOR_NOT_FOUND)); + } +} diff --git a/src/main/java/ditda/backend/domain/payment/dto/response/DepositNotifyResponse.java b/src/main/java/ditda/backend/domain/payment/dto/response/DepositNotifyResponse.java new file mode 100644 index 0000000..ef018dc --- /dev/null +++ b/src/main/java/ditda/backend/domain/payment/dto/response/DepositNotifyResponse.java @@ -0,0 +1,31 @@ +package ditda.backend.domain.payment.dto.response; + +import java.time.LocalDateTime; + +import com.fasterxml.jackson.annotation.JsonFormat; + +import ditda.backend.domain.payment.entity.enums.PaymentStatus; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "입금 통보 응답") +public record DepositNotifyResponse( + + @Schema(description = "외주 ID", example = "1") + Long commissionId, + + @Schema(description = "결제 상태", example = "PENDING") + PaymentStatus status, + + @Schema(description = "입금 통보 시각", example = "2026-05-26 15:30:00") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Seoul") + LocalDateTime depositNotifiedAt +) { + + public static DepositNotifyResponse of( + Long commissionId, + PaymentStatus status, + LocalDateTime depositNotifiedAt + ) { + return new DepositNotifyResponse(commissionId, status, depositNotifiedAt); + } +} diff --git a/src/main/java/ditda/backend/domain/payment/entity/Payment.java b/src/main/java/ditda/backend/domain/payment/entity/Payment.java new file mode 100644 index 0000000..531f723 --- /dev/null +++ b/src/main/java/ditda/backend/domain/payment/entity/Payment.java @@ -0,0 +1,70 @@ +package ditda.backend.domain.payment.entity; + +import java.time.LocalDateTime; + +import ditda.backend.domain.commission.core.entity.Commission; +import ditda.backend.domain.payment.entity.enums.PaymentStatus; +import ditda.backend.global.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "payments") +@Getter +@Builder +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Payment extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "payment_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "commission_id", nullable = false) + private Commission commission; + + @Column(name = "amount", nullable = false) + private int amount; + + @Column(name = "depositor_name", length = 50) + private String depositorName; + + @Enumerated(EnumType.STRING) + @Column(name = "status", length = 30, nullable = false) + private PaymentStatus status; + + @Column(name = "paid_at") + private LocalDateTime paidAt; + + @Column(name = "deposit_notified_at") + private LocalDateTime depositNotifiedAt; + + public static Payment create(Commission commission, String depositorName) { + return Payment.builder() + .commission(commission) + .amount(commission.getPlanCode().getPrice()) + .depositorName(depositorName) + .status(PaymentStatus.PENDING) + .build(); + } + + public void markDepositNotified() { + this.depositNotifiedAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/ditda/backend/domain/payment/entity/enums/PaymentStatus.java b/src/main/java/ditda/backend/domain/payment/entity/enums/PaymentStatus.java new file mode 100644 index 0000000..9cd8d0d --- /dev/null +++ b/src/main/java/ditda/backend/domain/payment/entity/enums/PaymentStatus.java @@ -0,0 +1,8 @@ +package ditda.backend.domain.payment.entity.enums; + +public enum PaymentStatus { + PENDING, // 외주 신청 + 입금 확인 대기 + COMPLETED, // 외주 신청 + 입금 확인 + REFUNDED, // 환불 + FAILED // 실패 +} diff --git a/src/main/java/ditda/backend/domain/payment/event/DepositNotifiedEvent.java b/src/main/java/ditda/backend/domain/payment/event/DepositNotifiedEvent.java new file mode 100644 index 0000000..47622ff --- /dev/null +++ b/src/main/java/ditda/backend/domain/payment/event/DepositNotifiedEvent.java @@ -0,0 +1,13 @@ +package ditda.backend.domain.payment.event; + +import java.time.LocalDateTime; + +public record DepositNotifiedEvent( + Long commissionId, + String commissionTitle, + String instructorName, + String depositorName, + int amount, + LocalDateTime notifiedAt +) { +} diff --git a/src/main/java/ditda/backend/domain/payment/exception/PaymentErrorCode.java b/src/main/java/ditda/backend/domain/payment/exception/PaymentErrorCode.java new file mode 100644 index 0000000..a1f0037 --- /dev/null +++ b/src/main/java/ditda/backend/domain/payment/exception/PaymentErrorCode.java @@ -0,0 +1,20 @@ +package ditda.backend.domain.payment.exception; + +import org.springframework.http.HttpStatus; + +import ditda.backend.global.apipayload.code.BaseErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum PaymentErrorCode implements BaseErrorCode { + + PAYMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "PAYMENT_404_01", "결제 정보를 찾을 수 없습니다."), + PAYMENT_ACCESS_DENIED(HttpStatus.FORBIDDEN, "PAYMENT_403_01", "해당 외주에 대한 권한이 없습니다."), + DEPOSIT_NOTIFY_NOT_ALLOWED(HttpStatus.BAD_REQUEST, "PAYMENT_400_01", "입금 확인 대기 상태에서만 입금 통보가 가능합니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} diff --git a/src/main/java/ditda/backend/domain/payment/notification/DepositMailer.java b/src/main/java/ditda/backend/domain/payment/notification/DepositMailer.java new file mode 100644 index 0000000..d2c1d61 --- /dev/null +++ b/src/main/java/ditda/backend/domain/payment/notification/DepositMailer.java @@ -0,0 +1,38 @@ +package ditda.backend.domain.payment.notification; + +import java.time.LocalDateTime; +import java.util.Map; + +import org.springframework.stereotype.Component; + +import ditda.backend.global.email.EmailSender; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class DepositMailer { + + private static final String SUBJECT = "[DITDA] 외주 입금 확인 요청"; + private static final String TEMPLATE = "email/deposit-notification"; + + private final EmailSender emailSender; + + public void sendAdminNotification( + String adminEmail, + Long commissionId, + String commissionTitle, + String instructorName, + String depositorName, + int amount, + LocalDateTime notifiedAt + ) { + emailSender.send(adminEmail, SUBJECT, TEMPLATE, Map.of( + "commissionId", commissionId, + "title", commissionTitle, + "instructorName", instructorName, + "depositorName", depositorName, + "amount", amount, + "notifiedAt", notifiedAt + )); + } +} diff --git a/src/main/java/ditda/backend/domain/payment/notification/DepositNotifier.java b/src/main/java/ditda/backend/domain/payment/notification/DepositNotifier.java new file mode 100644 index 0000000..76c788f --- /dev/null +++ b/src/main/java/ditda/backend/domain/payment/notification/DepositNotifier.java @@ -0,0 +1,37 @@ +package ditda.backend.domain.payment.notification; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import ditda.backend.domain.payment.event.DepositNotifiedEvent; +import ditda.backend.global.config.AdminProperties; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class DepositNotifier { + + private final DepositMailer depositMailer; + private final AdminProperties adminProperties; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onDepositNotifier(DepositNotifiedEvent event) { + + try { + depositMailer.sendAdminNotification( + adminProperties.getNotificationEmail(), + event.commissionId(), + event.commissionTitle(), + event.instructorName(), + event.depositorName(), + event.amount(), + event.notifiedAt()); + } catch (Exception exception) { + log.error("Failed to dispatch deposit notification. commissionId={}", event.commissionId(), exception); + // TODO : 디스코드 웹훅 + } + } +} diff --git a/src/main/java/ditda/backend/domain/payment/repository/PaymentRepository.java b/src/main/java/ditda/backend/domain/payment/repository/PaymentRepository.java new file mode 100644 index 0000000..b173b59 --- /dev/null +++ b/src/main/java/ditda/backend/domain/payment/repository/PaymentRepository.java @@ -0,0 +1,12 @@ +package ditda.backend.domain.payment.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import ditda.backend.domain.payment.entity.Payment; + +public interface PaymentRepository extends JpaRepository { + + Optional findByCommissionId(Long commissionId); +} diff --git a/src/main/java/ditda/backend/domain/payment/service/PaymentService.java b/src/main/java/ditda/backend/domain/payment/service/PaymentService.java new file mode 100644 index 0000000..f3b8ea2 --- /dev/null +++ b/src/main/java/ditda/backend/domain/payment/service/PaymentService.java @@ -0,0 +1,83 @@ +package ditda.backend.domain.payment.service; + +import java.util.Objects; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import ditda.backend.domain.commission.core.entity.Commission; +import ditda.backend.domain.payment.dto.response.DepositNotifyResponse; +import ditda.backend.domain.payment.entity.Payment; +import ditda.backend.domain.payment.entity.enums.PaymentStatus; +import ditda.backend.domain.payment.event.DepositNotifiedEvent; +import ditda.backend.domain.payment.exception.PaymentErrorCode; +import ditda.backend.domain.payment.repository.PaymentRepository; +import ditda.backend.domain.term.service.TermService; +import ditda.backend.global.apipayload.exception.GeneralException; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class PaymentService { + + private final PaymentRepository paymentRepository; + private final ApplicationEventPublisher eventPublisher; + private final TermService termService; + + // 강사 새 외주 작성시 결제 대기 상태 생성 + @Transactional + public void createPendingPayment( + Commission commission, + String depositorName, + String termVersion, + boolean isTermAgreed + ) { + + Payment payment = Payment.create( + commission, + depositorName + ); + paymentRepository.save(payment); + + termService.savePaymentTerm(payment, termVersion, isTermAgreed); + } + + @Transactional + public DepositNotifyResponse notifyDeposit(Long instructorId, Long commissionId) { + + Payment payment = paymentRepository.findByCommissionId(commissionId) + .orElseThrow(() -> new GeneralException(PaymentErrorCode.PAYMENT_NOT_FOUND)); + + Commission commission = payment.getCommission(); + + if (!Objects.equals(commission.getInstructor().getId(), instructorId)) { + throw new GeneralException(PaymentErrorCode.PAYMENT_ACCESS_DENIED); + } + + if (payment.getStatus() != PaymentStatus.PENDING) { + throw new GeneralException(PaymentErrorCode.DEPOSIT_NOTIFY_NOT_ALLOWED); + } + + if (payment.getDepositNotifiedAt() != null) { + throw new GeneralException(PaymentErrorCode.DEPOSIT_NOTIFY_NOT_ALLOWED); + } + + payment.markDepositNotified(); + + eventPublisher.publishEvent(new DepositNotifiedEvent( + commission.getId(), + commission.getTitle(), + commission.getInstructor().getName(), + payment.getDepositorName(), + payment.getAmount(), + payment.getDepositNotifiedAt() + )); + + return DepositNotifyResponse.of( + commission.getId(), + payment.getStatus(), + payment.getDepositNotifiedAt() + ); + } +} diff --git a/src/main/java/ditda/backend/domain/term/dto/TermAgreement.java b/src/main/java/ditda/backend/domain/term/dto/TermAgreement.java new file mode 100644 index 0000000..732fe8d --- /dev/null +++ b/src/main/java/ditda/backend/domain/term/dto/TermAgreement.java @@ -0,0 +1,10 @@ +package ditda.backend.domain.term.dto; + +import ditda.backend.domain.term.entity.enums.TermType; + +public record TermAgreement( + TermType type, + String version, + boolean isAgreed +) { +} diff --git a/src/main/java/ditda/backend/domain/term/entity/PaymentTerm.java b/src/main/java/ditda/backend/domain/term/entity/PaymentTerm.java new file mode 100644 index 0000000..670b6ba --- /dev/null +++ b/src/main/java/ditda/backend/domain/term/entity/PaymentTerm.java @@ -0,0 +1,62 @@ +package ditda.backend.domain.term.entity; + +import ditda.backend.domain.payment.entity.Payment; +import ditda.backend.domain.term.entity.enums.TermType; +import ditda.backend.global.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "payment_terms") +@Getter +@Builder +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PaymentTerm extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "payment_term_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "payment_id", nullable = false) + private Payment payment; + + @Enumerated(EnumType.STRING) + @Builder.Default + @Column(name = "term_type", nullable = false, length = 50) + private TermType termType = TermType.SETTLEMENT; + + @Column(name = "version", length = 20, nullable = false) + private String version; + + @Column(name = "is_agreed", nullable = false) + private boolean isAgreed; + + public static PaymentTerm create( + Payment payment, + String version, + boolean isAgreed + ) { + return PaymentTerm.builder() + .payment(payment) + .version(version) + .isAgreed(isAgreed) + .build(); + } +} diff --git a/src/main/java/ditda/backend/domain/common/term/entity/UserTerm.java b/src/main/java/ditda/backend/domain/term/entity/UserTerm.java similarity index 90% rename from src/main/java/ditda/backend/domain/common/term/entity/UserTerm.java rename to src/main/java/ditda/backend/domain/term/entity/UserTerm.java index a1aba2c..a8469e7 100644 --- a/src/main/java/ditda/backend/domain/common/term/entity/UserTerm.java +++ b/src/main/java/ditda/backend/domain/term/entity/UserTerm.java @@ -1,7 +1,7 @@ -package ditda.backend.domain.common.term.entity; +package ditda.backend.domain.term.entity; -import ditda.backend.domain.common.term.entity.enums.TermType; -import ditda.backend.domain.common.user.entity.User; +import ditda.backend.domain.term.entity.enums.TermType; +import ditda.backend.domain.user.entity.User; import ditda.backend.global.entity.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; diff --git a/src/main/java/ditda/backend/domain/common/term/entity/enums/TermType.java b/src/main/java/ditda/backend/domain/term/entity/enums/TermType.java similarity index 63% rename from src/main/java/ditda/backend/domain/common/term/entity/enums/TermType.java rename to src/main/java/ditda/backend/domain/term/entity/enums/TermType.java index 974f45a..494076c 100644 --- a/src/main/java/ditda/backend/domain/common/term/entity/enums/TermType.java +++ b/src/main/java/ditda/backend/domain/term/entity/enums/TermType.java @@ -1,4 +1,4 @@ -package ditda.backend.domain.common.term.entity.enums; +package ditda.backend.domain.term.entity.enums; public enum TermType { SERVICE, diff --git a/src/main/java/ditda/backend/domain/common/term/exception/TermErrorCode.java b/src/main/java/ditda/backend/domain/term/exception/TermErrorCode.java similarity index 66% rename from src/main/java/ditda/backend/domain/common/term/exception/TermErrorCode.java rename to src/main/java/ditda/backend/domain/term/exception/TermErrorCode.java index d1fe6bb..1c79ac9 100644 --- a/src/main/java/ditda/backend/domain/common/term/exception/TermErrorCode.java +++ b/src/main/java/ditda/backend/domain/term/exception/TermErrorCode.java @@ -1,4 +1,4 @@ -package ditda.backend.domain.common.term.exception; +package ditda.backend.domain.term.exception; import org.springframework.http.HttpStatus; @@ -10,7 +10,8 @@ @AllArgsConstructor public enum TermErrorCode implements BaseErrorCode { - REQUIRED_TERMS_NOT_AGREED(HttpStatus.BAD_REQUEST, "TERM_400_01", "필수 약관에 동의해야 합니다."); + REQUIRED_TERMS_NOT_AGREED(HttpStatus.BAD_REQUEST, "TERM_400_01", "필수 약관에 동의해야 합니다."), + SETTLEMENT_TERM_NOT_AGREED(HttpStatus.BAD_REQUEST, "TERM_400_02", "결제 약관 동의는 필수입니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/ditda/backend/domain/term/repository/PaymentTermRepository.java b/src/main/java/ditda/backend/domain/term/repository/PaymentTermRepository.java new file mode 100644 index 0000000..dae0db3 --- /dev/null +++ b/src/main/java/ditda/backend/domain/term/repository/PaymentTermRepository.java @@ -0,0 +1,8 @@ +package ditda.backend.domain.term.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import ditda.backend.domain.term.entity.PaymentTerm; + +public interface PaymentTermRepository extends JpaRepository { +} diff --git a/src/main/java/ditda/backend/domain/common/term/repository/UserTermRepository.java b/src/main/java/ditda/backend/domain/term/repository/UserTermRepository.java similarity index 56% rename from src/main/java/ditda/backend/domain/common/term/repository/UserTermRepository.java rename to src/main/java/ditda/backend/domain/term/repository/UserTermRepository.java index 4084c46..8e51f01 100644 --- a/src/main/java/ditda/backend/domain/common/term/repository/UserTermRepository.java +++ b/src/main/java/ditda/backend/domain/term/repository/UserTermRepository.java @@ -1,8 +1,8 @@ -package ditda.backend.domain.common.term.repository; +package ditda.backend.domain.term.repository; import org.springframework.data.jpa.repository.JpaRepository; -import ditda.backend.domain.common.term.entity.UserTerm; +import ditda.backend.domain.term.entity.UserTerm; public interface UserTermRepository extends JpaRepository { } diff --git a/src/main/java/ditda/backend/domain/common/term/service/TermService.java b/src/main/java/ditda/backend/domain/term/service/TermService.java similarity index 58% rename from src/main/java/ditda/backend/domain/common/term/service/TermService.java rename to src/main/java/ditda/backend/domain/term/service/TermService.java index 00aabfb..7db0650 100644 --- a/src/main/java/ditda/backend/domain/common/term/service/TermService.java +++ b/src/main/java/ditda/backend/domain/term/service/TermService.java @@ -1,4 +1,4 @@ -package ditda.backend.domain.common.term.service; +package ditda.backend.domain.term.service; import java.util.List; import java.util.Set; @@ -6,12 +6,15 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import ditda.backend.domain.common.term.dto.TermAgreement; -import ditda.backend.domain.common.term.entity.UserTerm; -import ditda.backend.domain.common.term.entity.enums.TermType; -import ditda.backend.domain.common.term.exception.TermErrorCode; -import ditda.backend.domain.common.term.repository.UserTermRepository; -import ditda.backend.domain.common.user.entity.User; +import ditda.backend.domain.payment.entity.Payment; +import ditda.backend.domain.term.dto.TermAgreement; +import ditda.backend.domain.term.entity.PaymentTerm; +import ditda.backend.domain.term.entity.UserTerm; +import ditda.backend.domain.term.entity.enums.TermType; +import ditda.backend.domain.term.exception.TermErrorCode; +import ditda.backend.domain.term.repository.PaymentTermRepository; +import ditda.backend.domain.term.repository.UserTermRepository; +import ditda.backend.domain.user.entity.User; import ditda.backend.global.apipayload.exception.GeneralException; import lombok.RequiredArgsConstructor; @@ -26,6 +29,18 @@ public class TermService { TermType.DISINTERMEDIATION ); private final UserTermRepository userTermRepository; + private final PaymentTermRepository paymentTermRepository; + + @Transactional + public void savePaymentTerm(Payment payment, String version, boolean isAgreed) { + + PaymentTerm paymentTerm = PaymentTerm.create( + payment, + version, + isAgreed + ); + paymentTermRepository.save(paymentTerm); + } @Transactional public void saveTerms(User user, List agreements) { diff --git a/src/main/java/ditda/backend/domain/common/user/entity/User.java b/src/main/java/ditda/backend/domain/user/entity/User.java similarity index 94% rename from src/main/java/ditda/backend/domain/common/user/entity/User.java rename to src/main/java/ditda/backend/domain/user/entity/User.java index a461c40..b930366 100644 --- a/src/main/java/ditda/backend/domain/common/user/entity/User.java +++ b/src/main/java/ditda/backend/domain/user/entity/User.java @@ -1,8 +1,8 @@ -package ditda.backend.domain.common.user.entity; +package ditda.backend.domain.user.entity; import java.time.LocalDateTime; -import ditda.backend.domain.common.user.entity.enums.UserRole; +import ditda.backend.domain.user.entity.enums.UserRole; import ditda.backend.global.entity.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; diff --git a/src/main/java/ditda/backend/domain/user/entity/enums/UserRole.java b/src/main/java/ditda/backend/domain/user/entity/enums/UserRole.java new file mode 100644 index 0000000..4796c5d --- /dev/null +++ b/src/main/java/ditda/backend/domain/user/entity/enums/UserRole.java @@ -0,0 +1,6 @@ +package ditda.backend.domain.user.entity.enums; + +public enum UserRole { + DESIGNER, + INSTRUCTOR +} diff --git a/src/main/java/ditda/backend/domain/common/user/exception/UserErrorCode.java b/src/main/java/ditda/backend/domain/user/exception/UserErrorCode.java similarity index 92% rename from src/main/java/ditda/backend/domain/common/user/exception/UserErrorCode.java rename to src/main/java/ditda/backend/domain/user/exception/UserErrorCode.java index 94031e1..4bc9678 100644 --- a/src/main/java/ditda/backend/domain/common/user/exception/UserErrorCode.java +++ b/src/main/java/ditda/backend/domain/user/exception/UserErrorCode.java @@ -1,4 +1,4 @@ -package ditda.backend.domain.common.user.exception; +package ditda.backend.domain.user.exception; import org.springframework.http.HttpStatus; diff --git a/src/main/java/ditda/backend/domain/common/user/repository/UserRepository.java b/src/main/java/ditda/backend/domain/user/repository/UserRepository.java similarity index 73% rename from src/main/java/ditda/backend/domain/common/user/repository/UserRepository.java rename to src/main/java/ditda/backend/domain/user/repository/UserRepository.java index 5f7fb94..9f66fa6 100644 --- a/src/main/java/ditda/backend/domain/common/user/repository/UserRepository.java +++ b/src/main/java/ditda/backend/domain/user/repository/UserRepository.java @@ -1,10 +1,10 @@ -package ditda.backend.domain.common.user.repository; +package ditda.backend.domain.user.repository; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; -import ditda.backend.domain.common.user.entity.User; +import ditda.backend.domain.user.entity.User; public interface UserRepository extends JpaRepository { boolean existsByUsername(String username); diff --git a/src/main/java/ditda/backend/domain/common/user/service/UserService.java b/src/main/java/ditda/backend/domain/user/service/UserService.java similarity index 79% rename from src/main/java/ditda/backend/domain/common/user/service/UserService.java rename to src/main/java/ditda/backend/domain/user/service/UserService.java index e7c287e..75527cb 100644 --- a/src/main/java/ditda/backend/domain/common/user/service/UserService.java +++ b/src/main/java/ditda/backend/domain/user/service/UserService.java @@ -1,14 +1,14 @@ -package ditda.backend.domain.common.user.service; +package ditda.backend.domain.user.service; import java.time.LocalDateTime; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import ditda.backend.domain.common.user.entity.User; -import ditda.backend.domain.common.user.entity.enums.UserRole; -import ditda.backend.domain.common.user.exception.UserErrorCode; -import ditda.backend.domain.common.user.repository.UserRepository; +import ditda.backend.domain.user.entity.User; +import ditda.backend.domain.user.entity.enums.UserRole; +import ditda.backend.domain.user.exception.UserErrorCode; +import ditda.backend.domain.user.repository.UserRepository; import ditda.backend.global.apipayload.exception.GeneralException; import lombok.RequiredArgsConstructor; @@ -23,6 +23,11 @@ public User getReferenceById(Long userId) { return userRepository.getReferenceById(userId); } + public User findById(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new GeneralException(UserErrorCode.USER_NOT_FOUND)); + } + public boolean existsByUsername(String username) { return userRepository.existsByUsername(username); } diff --git a/src/main/java/ditda/backend/global/apipayload/code/GeneralErrorCode.java b/src/main/java/ditda/backend/global/apipayload/code/GeneralErrorCode.java index 9b035b0..ea64ff0 100644 --- a/src/main/java/ditda/backend/global/apipayload/code/GeneralErrorCode.java +++ b/src/main/java/ditda/backend/global/apipayload/code/GeneralErrorCode.java @@ -21,8 +21,8 @@ public enum GeneralErrorCode implements BaseErrorCode { INVALID_PARAMETER(HttpStatus.BAD_REQUEST, "REQ_400_02", "파라미터 형식이 잘못되었습니다."), INVALID_BODY_TYPE(HttpStatus.BAD_REQUEST, "REQ_400_03", "요청 본문의 형식이 잘못되었거나, 허용되지 않은 값이 포함되어 있습니다."), UNSUPPORTED_CONTENT_TYPE(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "REQ_415_01", "지원하지 않는 Content-Type입니다."), - DUPLICATE_RESOURCE(HttpStatus.CONFLICT, "REQ_409_01", "이미 존재하는 데이터입니다."), FILE_SIZE_EXCEEDED(HttpStatus.CONTENT_TOO_LARGE, "REQ_413_01", "파일 크기가 제한을 초과했습니다."), + DUPLICATE_RESOURCE(HttpStatus.CONFLICT, "REQ_409_01", "이미 존재하는 데이터입니다."), // API/라우팅 에러 API_NOT_FOUND(HttpStatus.NOT_FOUND, "API_404_01", "존재하지 않는 API입니다."), diff --git a/src/main/java/ditda/backend/global/config/SecurityConfig.java b/src/main/java/ditda/backend/global/config/SecurityConfig.java index 89af4d2..721a090 100644 --- a/src/main/java/ditda/backend/global/config/SecurityConfig.java +++ b/src/main/java/ditda/backend/global/config/SecurityConfig.java @@ -59,8 +59,11 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-ui.html").permitAll() .requestMatchers(HttpMethod.POST, "/api/v1/auth/**").permitAll() .requestMatchers(HttpMethod.POST, "/api/v1/instructors/auth/signup").permitAll() - .requestMatchers(HttpMethod.POST, "/api/v1/designers/auth/signup").permitAll() + .requestMatchers(HttpMethod.POST, "/api/v1/designers/auth/signup", + "/api/v1/designers/auth/signup/portfolio/presigned-url").permitAll() .requestMatchers(HttpMethod.GET, "/actuator/health").permitAll() + .requestMatchers("/api/v1/instructors/**").hasRole("INSTRUCTOR") + .requestMatchers("/api/v1/designers/**").hasRole("DESIGNER") .anyRequest().authenticated() ) .addFilterBefore(jwtTokenFilter, UsernamePasswordAuthenticationFilter.class); diff --git a/src/main/java/ditda/backend/global/jwt/JwtTokenProvider.java b/src/main/java/ditda/backend/global/jwt/JwtTokenProvider.java index 183d1b3..ed9015c 100644 --- a/src/main/java/ditda/backend/global/jwt/JwtTokenProvider.java +++ b/src/main/java/ditda/backend/global/jwt/JwtTokenProvider.java @@ -9,6 +9,8 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; +import ditda.backend.domain.user.entity.enums.UserRole; +import ditda.backend.global.jwt.dto.AccessTokenPayload; import ditda.backend.global.jwt.dto.RefreshTokenPayload; import io.jsonwebtoken.Claims; import io.jsonwebtoken.ExpiredJwtException; @@ -26,6 +28,12 @@ @Slf4j public class JwtTokenProvider { + private static final String SESSION_ID_CLAIM = "sid"; + private static final String TOKEN_TYPE_CLAIM = "type"; + private static final String ROLE_CLAIM = "role"; + private static final String ACCESS_TOKEN_TYPE = "access_token"; + private static final String REFRESH_TOKEN_TYPE = "refresh_token"; + @Value("${jwt.secret}") private String secret; @@ -37,11 +45,6 @@ public class JwtTokenProvider { private SecretKey key; - private static final String SESSION_ID_CLAIM = "sid"; - private static final String TOKEN_TYPE_CLAIM = "type"; - private static final String ACCESS_TOKEN_TYPE = "access_token"; - private static final String REFRESH_TOKEN_TYPE = "refresh_token"; - @PostConstruct public void init() { @@ -49,7 +52,7 @@ public void init() { key = Keys.hmacShaKeyFor(keyBytes); } - public String generateAccessToken(Long userId) { + public String generateAccessToken(Long userId, UserRole role) { Date now = new Date(); Date expiryTime = new Date(now.getTime() + accessTokenExpiration); @@ -57,6 +60,7 @@ public String generateAccessToken(Long userId) { return Jwts.builder() .subject(userId.toString()) .claim(TOKEN_TYPE_CLAIM, ACCESS_TOKEN_TYPE) + .claim(ROLE_CLAIM, role.name()) .issuedAt(now) .expiration(expiryTime) .signWith(key, Jwts.SIG.HS256) @@ -78,7 +82,35 @@ public String generateRefreshToken(Long userId, String sessionId) { .compact(); } - public String getSessionId(Claims claims) { + public AccessTokenPayload getAccessTokenPayload(String token) { + + Claims claims = validateAccessToken(token); + + Long userId = Long.parseLong(claims.getSubject()); + UserRole role = getRole(claims); + + return new AccessTokenPayload(userId, role); + } + + public RefreshTokenPayload getRefreshTokenPayload(String token) { + + Claims claims = validateRefreshToken(token); + + Long userId = Long.parseLong(claims.getSubject()); + String sessionId = getSessionId(claims); + + return new RefreshTokenPayload(userId, sessionId); + } + + public LocalDateTime getExpiration(String token) { + + return getClaims(token).getExpiration() + .toInstant() + .atZone(ZoneId.systemDefault()) + .toLocalDateTime(); + } + + private String getSessionId(Claims claims) { String sessionId = claims.get(SESSION_ID_CLAIM, String.class); @@ -89,7 +121,7 @@ public String getSessionId(Claims claims) { return sessionId; } - public Claims validateAccessToken(String token) { + private Claims validateAccessToken(String token) { Claims claims = validateToken(token); if (!ACCESS_TOKEN_TYPE.equals(claims.get(TOKEN_TYPE_CLAIM, String.class))) { @@ -99,7 +131,7 @@ public Claims validateAccessToken(String token) { return claims; } - public Claims validateRefreshToken(String token) { + private Claims validateRefreshToken(String token) { Claims claims = validateToken(token); if (!REFRESH_TOKEN_TYPE.equals(claims.get(TOKEN_TYPE_CLAIM, String.class))) { @@ -109,14 +141,6 @@ public Claims validateRefreshToken(String token) { return claims; } - public LocalDateTime getExpiration(String token) { - - return getClaims(token).getExpiration() - .toInstant() - .atZone(ZoneId.systemDefault()) - .toLocalDateTime(); - } - private Claims getClaims(String token) { return Jwts.parser() @@ -126,14 +150,15 @@ private Claims getClaims(String token) { .getPayload(); } - public RefreshTokenPayload getRefreshTokenPayload(String token) { + private UserRole getRole(Claims claims) { - Claims claims = validateRefreshToken(token); + String role = claims.get(ROLE_CLAIM, String.class); - Long userId = Long.parseLong(claims.getSubject()); - String sessionId = getSessionId(claims); + if (role == null || role.isBlank()) { + throw new JwtException("Access Token에 role 정보가 없습니다."); + } - return new RefreshTokenPayload(userId, sessionId); + return UserRole.valueOf(role); } private Claims validateToken(String token) { diff --git a/src/main/java/ditda/backend/global/jwt/dto/AccessTokenPayload.java b/src/main/java/ditda/backend/global/jwt/dto/AccessTokenPayload.java new file mode 100644 index 0000000..03d367c --- /dev/null +++ b/src/main/java/ditda/backend/global/jwt/dto/AccessTokenPayload.java @@ -0,0 +1,11 @@ +package ditda.backend.global.jwt.dto; + +import ditda.backend.domain.user.entity.enums.UserRole; + +public record AccessTokenPayload( + + Long userId, + + UserRole role +) { +} diff --git a/src/main/java/ditda/backend/global/jwt/filter/JwtTokenFilter.java b/src/main/java/ditda/backend/global/jwt/filter/JwtTokenFilter.java index 1a617dc..543dccc 100644 --- a/src/main/java/ditda/backend/global/jwt/filter/JwtTokenFilter.java +++ b/src/main/java/ditda/backend/global/jwt/filter/JwtTokenFilter.java @@ -12,8 +12,8 @@ import org.springframework.web.filter.OncePerRequestFilter; import ditda.backend.global.jwt.JwtTokenProvider; +import ditda.backend.global.jwt.dto.AccessTokenPayload; import ditda.backend.global.jwt.exceptions.JwtErrorType; -import io.jsonwebtoken.Claims; import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.JwtException; import jakarta.servlet.FilterChain; @@ -41,14 +41,13 @@ protected void doFilterInternal( if (StringUtils.hasText(token)) { try { - Claims claims = jwtTokenProvider.validateAccessToken(token); - Long memberId = Long.parseLong(claims.getSubject()); + AccessTokenPayload payload = jwtTokenProvider.getAccessTokenPayload(token); Authentication authentication = new UsernamePasswordAuthenticationToken( - memberId, + payload.userId(), null, - List.of(new SimpleGrantedAuthority("ROLE_USER")) + List.of(new SimpleGrantedAuthority("ROLE_" + payload.role().name())) ); SecurityContextHolder.getContext().setAuthentication(authentication); diff --git a/src/main/java/ditda/backend/global/s3/S3FileUploader.java b/src/main/java/ditda/backend/global/s3/S3FileUploader.java deleted file mode 100644 index 12a1629..0000000 --- a/src/main/java/ditda/backend/global/s3/S3FileUploader.java +++ /dev/null @@ -1,100 +0,0 @@ -package ditda.backend.global.s3; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; -import java.util.UUID; - -import org.springframework.stereotype.Component; -import org.springframework.web.multipart.MultipartFile; - -import ditda.backend.global.s3.enums.BucketType; -import ditda.backend.global.s3.exception.S3UploadException; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import software.amazon.awssdk.core.exception.SdkException; -import software.amazon.awssdk.core.sync.RequestBody; -import software.amazon.awssdk.services.s3.S3Client; - -@Slf4j -@Component -@RequiredArgsConstructor -public class S3FileUploader { - - private final S3Client s3Client; - private final S3Properties s3Properties; - - public String upload(BucketType bucketType, String prefix, MultipartFile file) { - String bucket = resolveBucket(bucketType); - String key = generateKey(prefix, file); - - try { - s3Client.putObject( - req -> req.bucket(bucket) - .key(key) - .contentType(file.getContentType()), - RequestBody.fromInputStream(file.getInputStream(), file.getSize()) - ); - return key; - } catch (IOException | SdkException e) { - log.error("Failed to upload file to S3. bucket={}, originalName={}", bucket, file.getOriginalFilename(), e); - throw new S3UploadException(file.getOriginalFilename(), e); - } - } - - public List uploadAll(BucketType bucketType, String prefix, List files) { - if (files == null || files.isEmpty()) { - return List.of(); - } - - List uploadedKeys = new ArrayList<>(); - try { - for (MultipartFile file : files) { - if (file.isEmpty()) { - continue; - } - uploadedKeys.add(upload(bucketType, prefix, file)); - } - return uploadedKeys; - } catch (Exception e) { - // 부분 업로드된 파일 보상 삭제 - deleteAll(bucketType, uploadedKeys); - throw e; - } - } - - public void deleteAll(BucketType bucketType, List keys) { - if (keys == null || keys.isEmpty()) { - return; - } - - String bucket = resolveBucket(bucketType); - for (String key : keys) { - try { - s3Client.deleteObject(req -> req.bucket(bucket).key(key)); - } catch (Exception e) { - log.error("S3 delete failed. bucket={}, key={}", bucket, key, e); - } - } - } - - private String resolveBucket(BucketType bucketType) { - return switch (bucketType) { - case PUBLIC -> s3Properties.getPublicBucket(); - case PRIVATE -> s3Properties.getPrivateBucket(); - }; - } - - private String generateKey(String prefix, MultipartFile file) { - String extension = extractExtension(file.getOriginalFilename()); - return "%s/%s%s".formatted(prefix, UUID.randomUUID(), extension); - } - - // 파일명에서 확장자 추출 - private String extractExtension(String filename) { - if (filename == null || !filename.contains(".")) { - return ""; - } - return filename.substring(filename.lastIndexOf('.')); - } -} diff --git a/src/main/java/ditda/backend/global/s3/S3PresignedUrlGenerator.java b/src/main/java/ditda/backend/global/s3/S3PresignedUrlGenerator.java deleted file mode 100644 index f2d6025..0000000 --- a/src/main/java/ditda/backend/global/s3/S3PresignedUrlGenerator.java +++ /dev/null @@ -1,39 +0,0 @@ -package ditda.backend.global.s3; - -import java.time.Duration; - -import org.springframework.stereotype.Component; - -import lombok.RequiredArgsConstructor; -import software.amazon.awssdk.services.s3.model.GetObjectRequest; -import software.amazon.awssdk.services.s3.presigner.S3Presigner; -import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; -import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; - -@Component -@RequiredArgsConstructor -public class S3PresignedUrlGenerator { - - private final S3Presigner s3Presigner; - private final S3Properties s3Properties; - - public String generatePrivateGetUrl(String key) { - Duration ttl = Duration.ofMinutes(s3Properties.getPresignedUrlTtlMinutes()); - return generatePrivateGetUrl(key, ttl); - } - - public String generatePrivateGetUrl(String key, Duration ttl) { - GetObjectRequest getObjectRequest = GetObjectRequest.builder() - .bucket(s3Properties.getPrivateBucket()) - .key(key) - .build(); - - GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() - .signatureDuration(ttl) - .getObjectRequest(getObjectRequest) - .build(); - - PresignedGetObjectRequest presignedRequest = s3Presigner.presignGetObject(presignRequest); - return presignedRequest.url().toString(); - } -} diff --git a/src/main/java/ditda/backend/global/s3/S3Properties.java b/src/main/java/ditda/backend/global/s3/config/S3Properties.java similarity index 51% rename from src/main/java/ditda/backend/global/s3/S3Properties.java rename to src/main/java/ditda/backend/global/s3/config/S3Properties.java index 05d79cb..d8e4470 100644 --- a/src/main/java/ditda/backend/global/s3/S3Properties.java +++ b/src/main/java/ditda/backend/global/s3/config/S3Properties.java @@ -1,8 +1,10 @@ -package ditda.backend.global.s3; +package ditda.backend.global.s3.config; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; +import org.springframework.util.unit.DataSize; +import ditda.backend.global.s3.enums.BucketType; import lombok.Getter; import lombok.Setter; @@ -12,9 +14,18 @@ @ConfigurationProperties(prefix = "app.s3") public class S3Properties { + private DataSize maxFileSize = DataSize.ofMegabytes(30); + private String publicBucket; private String privateBucket; private int presignedUrlTtlMinutes = 10; + + public String getBucket(BucketType bucketType) { + return switch (bucketType) { + case PUBLIC -> publicBucket; + case PRIVATE -> privateBucket; + }; + } } diff --git a/src/main/java/ditda/backend/global/s3/controller/S3FileController.java b/src/main/java/ditda/backend/global/s3/controller/S3FileController.java new file mode 100644 index 0000000..5bbf52c --- /dev/null +++ b/src/main/java/ditda/backend/global/s3/controller/S3FileController.java @@ -0,0 +1,39 @@ +package ditda.backend.global.s3.controller; + +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 ditda.backend.global.apipayload.response.ApiResponse; +import ditda.backend.global.s3.dto.PresignedUpload; +import ditda.backend.global.s3.dto.request.PresignRequest; +import ditda.backend.global.s3.dto.response.PresignResponse; +import ditda.backend.global.s3.service.S3FileService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/v1/files") +@RequiredArgsConstructor +@Tag(name = "File", description = "공용 파일 업로드 API") +public class S3FileController { + + private final S3FileService s3FileService; + + @Operation( + summary = "파일 업로드 URL 발급", + description = "업로드 대상에 맞는 presigned PUT URL을 발급합니다. " + + "PUT URL로 S3에 직접 업로드한 뒤, 응답의 key를 각 도메인 요청에 담아 전송합니다." + ) + @PostMapping("/presigned-url") + public ApiResponse issuePresignedUrl( + @Valid @RequestBody PresignRequest request + ) { + + PresignedUpload upload = s3FileService.issuePresignedUpload(request.target(), request.contentType()); + return ApiResponse.onSuccess("파일 업로드 URL 발급 성공", PresignResponse.from(upload)); + } +} diff --git a/src/main/java/ditda/backend/global/s3/dto/PresignedUpload.java b/src/main/java/ditda/backend/global/s3/dto/PresignedUpload.java new file mode 100644 index 0000000..2dc64c6 --- /dev/null +++ b/src/main/java/ditda/backend/global/s3/dto/PresignedUpload.java @@ -0,0 +1,4 @@ +package ditda.backend.global.s3.dto; + +public record PresignedUpload(String key, String presignedUrl) { +} diff --git a/src/main/java/ditda/backend/global/s3/dto/request/PresignRequest.java b/src/main/java/ditda/backend/global/s3/dto/request/PresignRequest.java new file mode 100644 index 0000000..8ed759a --- /dev/null +++ b/src/main/java/ditda/backend/global/s3/dto/request/PresignRequest.java @@ -0,0 +1,19 @@ +package ditda.backend.global.s3.dto.request; + +import ditda.backend.global.s3.enums.UploadTarget; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "파일 업로드 presigned URL 발급 요청") +public record PresignRequest( + + @Schema(description = "업로드 대상", example = "COMMISSION_MATERIAL") + @NotNull(message = "업로드 대상은 필수입니다.") + UploadTarget target, + + @Schema(description = "파일 Content-Type", example = "image/png") + @NotBlank(message = "Content-Type은 필수입니다.") + String contentType +) { +} diff --git a/src/main/java/ditda/backend/global/s3/dto/response/PresignResponse.java b/src/main/java/ditda/backend/global/s3/dto/response/PresignResponse.java new file mode 100644 index 0000000..b512046 --- /dev/null +++ b/src/main/java/ditda/backend/global/s3/dto/response/PresignResponse.java @@ -0,0 +1,19 @@ +package ditda.backend.global.s3.dto.response; + +import ditda.backend.global.s3.dto.PresignedUpload; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "파일 업로드 presigned URL 발급 응답") +public record PresignResponse( + + @Schema(description = "S3 객체 key", example = "commission/tmp/{uuid}.png") + String key, + + @Schema(description = "S3 직접 업로드용 presigned PUT URL") + String presignedUrl +) { + + public static PresignResponse from(PresignedUpload upload) { + return new PresignResponse(upload.key(), upload.presignedUrl()); + } +} diff --git a/src/main/java/ditda/backend/global/s3/enums/S3ContentType.java b/src/main/java/ditda/backend/global/s3/enums/S3ContentType.java new file mode 100644 index 0000000..09b0d59 --- /dev/null +++ b/src/main/java/ditda/backend/global/s3/enums/S3ContentType.java @@ -0,0 +1,25 @@ +package ditda.backend.global.s3.enums; + +import java.util.Arrays; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum S3ContentType { + + PNG("image/png", ".png"), + JPEG("image/jpeg", ".jpeg"), + PDF("application/pdf", ".pdf"); + + private final String contentType; + private final String extension; + + public static S3ContentType from(String contentType) { + return Arrays.stream(values()) + .filter(t -> t.contentType.equals(contentType)) + .findFirst() + .orElse(null); + } +} diff --git a/src/main/java/ditda/backend/global/s3/enums/UploadTarget.java b/src/main/java/ditda/backend/global/s3/enums/UploadTarget.java new file mode 100644 index 0000000..e7b39af --- /dev/null +++ b/src/main/java/ditda/backend/global/s3/enums/UploadTarget.java @@ -0,0 +1,24 @@ +package ditda.backend.global.s3.enums; + +import java.util.EnumSet; +import java.util.Set; + +import lombok.Getter; + +@Getter +public enum UploadTarget { + + COMMISSION_MATERIAL("commission/material", BucketType.PRIVATE, EnumSet.of(S3ContentType.PNG)), + COMMISSION_REFERENCE("commission/reference", BucketType.PRIVATE, EnumSet.of(S3ContentType.PNG)), + PORTFOLIO("portfolio", BucketType.PRIVATE, EnumSet.of(S3ContentType.PNG, S3ContentType.PDF)); + + private final String dir; + private final BucketType bucketType; + private final Set allowed; + + UploadTarget(String dir, BucketType bucketType, Set allowed) { + this.dir = dir; + this.bucketType = bucketType; + this.allowed = Set.copyOf(allowed); + } +} diff --git a/src/main/java/ditda/backend/global/s3/exception/S3ErrorCode.java b/src/main/java/ditda/backend/global/s3/exception/S3ErrorCode.java new file mode 100644 index 0000000..4421410 --- /dev/null +++ b/src/main/java/ditda/backend/global/s3/exception/S3ErrorCode.java @@ -0,0 +1,24 @@ +package ditda.backend.global.s3.exception; + +import org.springframework.http.HttpStatus; + +import ditda.backend.global.apipayload.code.BaseErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum S3ErrorCode implements BaseErrorCode { + + // 업로드 파일 검증 에러 + INVALID_FILE(HttpStatus.BAD_REQUEST, "S3_400_01", "유효하지 않거나 업로드되지 않은 파일입니다."), + FILE_SIZE_EXCEEDED(HttpStatus.CONTENT_TOO_LARGE, "S3_413_01", "파일 크기가 제한을 초과했습니다."), + UNSUPPORTED_CONTENT_TYPE(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "S3_415_01", "지원하지 않는 파일 형식입니다."), + + // presigned URL 발급 에러 + FILE_URL_GENERATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "S3_500_01", "파일 URL 생성에 실패했습니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} diff --git a/src/main/java/ditda/backend/global/s3/exception/S3UploadException.java b/src/main/java/ditda/backend/global/s3/exception/S3UploadException.java deleted file mode 100644 index 94e2bf0..0000000 --- a/src/main/java/ditda/backend/global/s3/exception/S3UploadException.java +++ /dev/null @@ -1,8 +0,0 @@ -package ditda.backend.global.s3.exception; - -public class S3UploadException extends RuntimeException { - - public S3UploadException(String filename, Throwable cause) { - super("Failed to upload file to S3: " + filename, cause); - } -} diff --git a/src/main/java/ditda/backend/global/s3/manager/S3FileManager.java b/src/main/java/ditda/backend/global/s3/manager/S3FileManager.java new file mode 100644 index 0000000..5948126 --- /dev/null +++ b/src/main/java/ditda/backend/global/s3/manager/S3FileManager.java @@ -0,0 +1,60 @@ +package ditda.backend.global.s3.manager; + +import java.util.List; + +import org.springframework.stereotype.Component; + +import ditda.backend.global.s3.config.S3Properties; +import ditda.backend.global.s3.enums.BucketType; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.S3Exception; + +@Slf4j +@Component +@RequiredArgsConstructor +public class S3FileManager { + + private final S3Client s3Client; + private final S3Properties s3Properties; + + public Long getObjectSize(BucketType bucketType, String key) { + String bucket = s3Properties.getBucket(bucketType); + + try { + return s3Client.headObject(req -> req.bucket(bucket).key(key)).contentLength(); + } catch (S3Exception e) { + if (e.statusCode() == 404) { // 객체 없음 오류 + return null; + } + + throw e; + } + } + + public void deleteAll(BucketType bucketType, List keys) { + if (keys == null || keys.isEmpty()) { + return; + } + + String bucket = s3Properties.getBucket(bucketType); + for (String key : keys) { + try { + s3Client.deleteObject(req -> req.bucket(bucket).key(key)); + } catch (Exception e) { + log.error("S3 delete failed. bucket={}, key={}", bucket, key, e); + } + } + } + + public void copy(BucketType bucketType, String sourceKey, String destinationKey) { + String bucket = s3Properties.getBucket(bucketType); + + s3Client.copyObject(req -> req + .sourceBucket(bucket) + .sourceKey(sourceKey) + .destinationBucket(bucket) + .destinationKey(destinationKey)); + } +} diff --git a/src/main/java/ditda/backend/global/s3/manager/S3PresignedUrlGenerator.java b/src/main/java/ditda/backend/global/s3/manager/S3PresignedUrlGenerator.java new file mode 100644 index 0000000..0050c17 --- /dev/null +++ b/src/main/java/ditda/backend/global/s3/manager/S3PresignedUrlGenerator.java @@ -0,0 +1,73 @@ +package ditda.backend.global.s3.manager; + +import java.time.Duration; + +import org.springframework.stereotype.Component; + +import ditda.backend.global.apipayload.exception.GeneralException; +import ditda.backend.global.s3.config.S3Properties; +import ditda.backend.global.s3.enums.BucketType; +import ditda.backend.global.s3.exception.S3ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import software.amazon.awssdk.core.exception.SdkException; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; +import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; + +@Component +@Slf4j +@RequiredArgsConstructor +public class S3PresignedUrlGenerator { + + private final S3Presigner s3Presigner; + private final S3Properties s3Properties; + + public String generatePrivateGetUrl(String key) { + Duration ttl = Duration.ofMinutes(s3Properties.getPresignedUrlTtlMinutes()); + return generatePrivateGetUrl(key, ttl); + } + + public String generatePrivateGetUrl(String key, Duration ttl) { + try { + GetObjectRequest getObjectRequest = GetObjectRequest.builder() + .bucket(s3Properties.getPrivateBucket()) + .key(key) + .build(); + + GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() + .signatureDuration(ttl) + .getObjectRequest(getObjectRequest) + .build(); + + PresignedGetObjectRequest presignedRequest = s3Presigner.presignGetObject(presignRequest); + return presignedRequest.url().toString(); + } catch (SdkException exception) { + log.error("Failed to generate private presigned url. key={}", key, exception); + throw new GeneralException(S3ErrorCode.FILE_URL_GENERATION_FAILED); + } + } + + public String generatePutUrl(BucketType bucketType, String key, String contentType) { + Duration ttl = Duration.ofMinutes(s3Properties.getPresignedUrlTtlMinutes()); + return generatePutUrl(bucketType, key, contentType, ttl); + } + + public String generatePutUrl(BucketType bucketType, String key, String contentType, Duration ttl) { + PutObjectRequest putObjectRequest = PutObjectRequest.builder() + .bucket(s3Properties.getBucket(bucketType)) + .key(key) + .contentType(contentType) + .build(); + + PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder() + .signatureDuration(ttl) + .putObjectRequest(putObjectRequest) + .build(); + + return s3Presigner.presignPutObject(presignRequest).url().toString(); + } +} diff --git a/src/main/java/ditda/backend/global/s3/manager/S3UploadManager.java b/src/main/java/ditda/backend/global/s3/manager/S3UploadManager.java new file mode 100644 index 0000000..cc53e41 --- /dev/null +++ b/src/main/java/ditda/backend/global/s3/manager/S3UploadManager.java @@ -0,0 +1,60 @@ +package ditda.backend.global.s3.manager; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import org.springframework.stereotype.Component; + +import ditda.backend.global.s3.dto.PresignedUpload; +import ditda.backend.global.s3.enums.BucketType; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class S3UploadManager { + + private static final String TEMP_DIR = "tmp"; + + private final S3PresignedUrlGenerator s3PresignedUrlGenerator; + private final S3FileManager s3FileManager; + + public PresignedUpload issueTempUpload(BucketType bucketType, String dir, String extension, String contentType) { + String key = "%s/%s/%s%s".formatted(dir, TEMP_DIR, UUID.randomUUID(), extension); + String presignedUrl = s3PresignedUrlGenerator.generatePutUrl(bucketType, key, contentType); + return new PresignedUpload(key, presignedUrl); + } + + public boolean isTempKey(String key, String dir) { + return key != null && key.startsWith(dir + "/" + TEMP_DIR + "/"); + } + + public Long getObjectSize(BucketType bucketType, String key) { + return s3FileManager.getObjectSize(bucketType, key); + } + + public List promote(BucketType bucketType, List tempKeys) { + if (tempKeys.isEmpty()) { + return List.of(); + } + + List permanentKeys = new ArrayList<>(); + try { + for (String tempKey : tempKeys) { + String permanentKey = tempKey.replaceFirst("/" + TEMP_DIR + "/", "/"); // '/tmp/' -> '/' + s3FileManager.copy(bucketType, tempKey, permanentKey); + permanentKeys.add(permanentKey); + } + } catch (Exception e) { + s3FileManager.deleteAll(bucketType, permanentKeys); // 승격된 객체 되돌림 + throw e; + } + + s3FileManager.deleteAll(bucketType, tempKeys); // tmp 객체 정리 + return permanentKeys; + } + + public void deleteAll(BucketType bucketType, List keys) { + s3FileManager.deleteAll(bucketType, keys); + } +} diff --git a/src/main/java/ditda/backend/global/s3/S3UrlResolver.java b/src/main/java/ditda/backend/global/s3/manager/S3UrlResolver.java similarity index 85% rename from src/main/java/ditda/backend/global/s3/S3UrlResolver.java rename to src/main/java/ditda/backend/global/s3/manager/S3UrlResolver.java index f8d53d6..6bc5949 100644 --- a/src/main/java/ditda/backend/global/s3/S3UrlResolver.java +++ b/src/main/java/ditda/backend/global/s3/manager/S3UrlResolver.java @@ -1,7 +1,8 @@ -package ditda.backend.global.s3; +package ditda.backend.global.s3.manager; import org.springframework.stereotype.Component; +import ditda.backend.global.s3.config.S3Properties; import lombok.RequiredArgsConstructor; import software.amazon.awssdk.services.s3.S3Client; diff --git a/src/main/java/ditda/backend/global/s3/service/S3FileService.java b/src/main/java/ditda/backend/global/s3/service/S3FileService.java new file mode 100644 index 0000000..6da572c --- /dev/null +++ b/src/main/java/ditda/backend/global/s3/service/S3FileService.java @@ -0,0 +1,70 @@ +package ditda.backend.global.s3.service; + +import java.util.List; + +import org.springframework.stereotype.Service; + +import ditda.backend.global.apipayload.exception.GeneralException; +import ditda.backend.global.s3.config.S3Properties; +import ditda.backend.global.s3.dto.PresignedUpload; +import ditda.backend.global.s3.enums.S3ContentType; +import ditda.backend.global.s3.enums.UploadTarget; +import ditda.backend.global.s3.exception.S3ErrorCode; +import ditda.backend.global.s3.manager.S3UploadManager; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class S3FileService { + + private final S3UploadManager s3UploadManager; + private final S3Properties s3Properties; + + public PresignedUpload issuePresignedUpload(UploadTarget target, String contentType) { + + // 파일 형식 검증 + S3ContentType type = S3ContentType.from(contentType); + if (type == null || !target.getAllowed().contains(type)) { + throw new GeneralException(S3ErrorCode.UNSUPPORTED_CONTENT_TYPE); + } + + // temp key 및 presigned URL 발급 + return s3UploadManager.issueTempUpload( + target.getBucketType(), + target.getDir(), + type.getExtension(), + contentType + ); + } + + // temp key 및 파일 사이즈 검증 + public void validateUploadedKeys(UploadTarget target, List keys) { + + // 빈 키 여부 검증 + if (keys == null) { + throw new GeneralException(S3ErrorCode.INVALID_FILE); + } + + // 중복 키 검증 + if (keys.size() != keys.stream().distinct().count()) { + throw new GeneralException(S3ErrorCode.INVALID_FILE); + } + + long maxBytes = s3Properties.getMaxFileSize().toBytes(); + for (String key : keys) { + // temp key 형식 검증 + if (!s3UploadManager.isTempKey(key, target.getDir())) { + throw new GeneralException(S3ErrorCode.INVALID_FILE); + } + + // 파일 크기 검증 + Long size = s3UploadManager.getObjectSize(target.getBucketType(), key); + if (size == null) { + throw new GeneralException(S3ErrorCode.INVALID_FILE); + } + if (size > maxBytes) { + throw new GeneralException(S3ErrorCode.FILE_SIZE_EXCEEDED); + } + } + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 324f3c6..69519e1 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -31,12 +31,6 @@ spring: starttls: enable: true - servlet: - multipart: - max-file-size: 30MB - max-request-size: 100MB - enabled: true - jwt: secret: ${JWT_SECRET_KEY} access-token-expiration: ${JWT_ACCESS_TOKEN_EXPIRATION} @@ -53,6 +47,7 @@ admin: app: s3: + max-file-size: 30MB public-bucket: ${S3_PUBLIC_BUCKET} private-bucket: ${S3_PRIVATE_BUCKET} presigned-url-ttl-minutes: ${S3_PRESIGNED_URL_TTL_MINUTES:10} diff --git a/src/main/resources/templates/email/deposit-notification.html b/src/main/resources/templates/email/deposit-notification.html new file mode 100644 index 0000000..ac11ff9 --- /dev/null +++ b/src/main/resources/templates/email/deposit-notification.html @@ -0,0 +1,51 @@ + + + + + + +
+ +
+ ditda +
+ +
+

외주 입금 확인 요청

+

강사가 입금 완료를 통보했습니다.
입금 내역 확인 후 진행 처리 부탁드립니다.

+
+ +
+
+ 외주 ID + +
+
+ 외주명 + +
+
+ 강사 + +
+
+ 입금자명 + +
+
+ 금액 + +
+
+ 통보 시각 + +
+
+ +

+ 본 메일은 자동 발송된 알림입니다. +

+
+ + \ No newline at end of file diff --git a/src/test/java/ditda/backend/domain/commission/core/entity/CommissionTest.java b/src/test/java/ditda/backend/domain/commission/core/entity/CommissionTest.java new file mode 100644 index 0000000..e3e6c6b --- /dev/null +++ b/src/test/java/ditda/backend/domain/commission/core/entity/CommissionTest.java @@ -0,0 +1,34 @@ +package ditda.backend.domain.commission.core.entity; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import ditda.backend.domain.commission.core.entity.enums.PlanCode; + +class CommissionTest { + + @DisplayName("플랜·레벨 분포별 매칭 확정 인원을 계산") + @ParameterizedTest(name = "[{0}] 레벨종류 {1}, 지원자 {2} → matched {3}") + @CsvSource({ + // plan, distinctLevels, totalApplicants, expected + "BASIC, 2, 4, 2", // L1:3/L2:1/L3:0 + "BASIC, 0, 0, 0", // 지원자 없음 + "PLUS, 2, 4, 3", // L1:3/L2:1/L3:0 + "PLUS, 3, 3, 3", // 각 레벨 1명씩 + "MAX, 3, 4, 4", // L1:2/L2:1/L3:1 + "MAX, 2, 4, 4", // L1:3/L2:1/L3:0 + "MAX, 1, 5, 3", // L1만 5명 + "MAX, 3, 5, 5" // 전 레벨 + 정원 + }) + void matchedCount_boundaryValues(PlanCode plan, int distinctLevels, int totalApplicants, int expected) { + + Commission commission = Commission.builder() + .planCode(plan) + .build(); + + assertThat(commission.matchedCount(distinctLevels, totalApplicants)).isEqualTo(expected); + } +} diff --git a/src/test/java/ditda/backend/domain/commission/dashboard/service/InstructorDashboardServiceTest.java b/src/test/java/ditda/backend/domain/commission/dashboard/service/InstructorDashboardServiceTest.java new file mode 100644 index 0000000..d893c93 --- /dev/null +++ b/src/test/java/ditda/backend/domain/commission/dashboard/service/InstructorDashboardServiceTest.java @@ -0,0 +1,104 @@ +package ditda.backend.domain.commission.dashboard.service; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import java.time.LocalDate; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import ditda.backend.domain.commission.application.entity.enums.ApplicationStatus; +import ditda.backend.domain.commission.core.entity.Commission; +import ditda.backend.domain.commission.core.entity.enums.CommissionStatus; +import ditda.backend.domain.commission.core.entity.enums.PlanCode; +import ditda.backend.domain.commission.dashboard.dto.response.MatchingCommissionResponse; +import ditda.backend.domain.commission.dashboard.repository.DashboardCommissionRepository; +import ditda.backend.domain.commission.dashboard.repository.projection.MatchingView; + +@ExtendWith(MockitoExtension.class) +class InstructorDashboardServiceTest { + + private static final Long INSTRUCTOR_ID = 100L; + + @Mock + private DashboardCommissionRepository dashboardCommissionRepository; + + @InjectMocks + private InstructorDashboardService instructorDashboardService; + + @Test + @DisplayName("매칭 중인 외주별로 레벨 종류 수와 전체 지원자 수를 결합해 matched를 계산 - 성공") + void getMatchingCommissions_matchedCal() { + + // given + LocalDate deadline = LocalDate.of(2026, 6, 23); + Commission max = Commission.builder() + .id(1L) + .title("MAX 외주") + .planCode(PlanCode.MAX) + .applicationDeadline(deadline) + .finalDeadline(deadline) + .build(); + Commission basic = Commission.builder() + .id(2L) + .title("BASIC 외주") + .planCode(PlanCode.BASIC) + .applicationDeadline(deadline) + .finalDeadline(deadline) + .build(); + + MatchingView maxView = mock(MatchingView.class); + given(maxView.getCommission()).willReturn(max); + given(maxView.getDistinctLevelCount()).willReturn(3L); // 레벨 3종류 + given(maxView.getTotalCount()).willReturn(4L); // 지원자 4명 + + MatchingView basicView = mock(MatchingView.class); + given(basicView.getCommission()).willReturn(basic); + given(basicView.getDistinctLevelCount()).willReturn(2L); // 레벨 2종류 + given(basicView.getTotalCount()).willReturn(4L); // 지원자 4명 + + given(dashboardCommissionRepository.findMatchingViews( + INSTRUCTOR_ID, CommissionStatus.RECRUITING, ApplicationStatus.PENDING + )).willReturn(List.of(maxView, basicView)); + + // when + MatchingCommissionResponse response = instructorDashboardService.getMatchingCommissions(INSTRUCTOR_ID); + + // then + assertThat(response.commissions()).hasSize(2); + + MatchingCommissionResponse.CommissionItem maxItem = response.commissions().getFirst(); + assertThat(maxItem.commissionId()).isEqualTo(1L); + assertThat(maxItem.title()).isEqualTo("MAX 외주"); + assertThat(maxItem.applicationDeadline()).isEqualTo(deadline); + assertThat(maxItem.matching().matched()).isEqualTo(4); + assertThat(maxItem.matching().total()).isEqualTo(5); + + MatchingCommissionResponse.CommissionItem basicItem = response.commissions().get(1); + assertThat(basicItem.commissionId()).isEqualTo(2L); + assertThat(basicItem.matching().matched()).isEqualTo(2); + assertThat(basicItem.matching().total()).isEqualTo(3); + } + + @Test + @DisplayName("매칭 중인 외주가 없으면 빈 목록을 반환 - 성공") + void getMatchingCommissions_emptyList() { + + // given + given(dashboardCommissionRepository.findMatchingViews( + INSTRUCTOR_ID, CommissionStatus.RECRUITING, ApplicationStatus.PENDING + )).willReturn(List.of()); + + // when + MatchingCommissionResponse response = instructorDashboardService.getMatchingCommissions(INSTRUCTOR_ID); + + // then + assertThat(response.commissions()).isEmpty(); + } +}