diff --git a/docker-compose.yml b/docker-compose.yml index d0c8031..82fbad4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,7 +17,7 @@ services: image: ghcr.io/2024-ajouchong/2024-ajouchong/ajouchong:a9cab46 container_name: ajouchong-backend environment: - SPRING_DATASOURCE_URL: ${SPRING_DATASOURCE_URL} + SPRING_DATASOURCE_URL: jdbc:postgresql://database:5432/ajouchong SPRING_DATASOURCE_USERNAME: ${SPRING_DATASOURCE_USERNAME} SPRING_DATASOURCE_PASSWORD: ${SPRING_DATASOURCE_PASSWORD} JWT_SECRET: ${JWT_SECRET} @@ -25,7 +25,7 @@ services: CLIENT_ID: ${CLIENT_ID} CLIENT_SECRET: ${CLIENT_SECRET} TOKEN_URI: ${TOKEN_URI} - RESOURCE_URI: ${RESOURCE_URI] + RESOURCE_URI: ${RESOURCE_URI} URI: ${URI} depends_on: - database diff --git a/src/main/java/com/ajouchong/config/SecurityConfig.java b/src/main/java/com/ajouchong/config/SecurityConfig.java index e8765d6..3b07bb5 100644 --- a/src/main/java/com/ajouchong/config/SecurityConfig.java +++ b/src/main/java/com/ajouchong/config/SecurityConfig.java @@ -34,7 +34,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth -> auth .requestMatchers("/favicon.ico", "/img/**").permitAll() - .requestMatchers("/api/admin").hasRole(MemberRole.ADMIN.name()) // ADMIN 권한 필요 + .requestMatchers("/api/admin/**").hasRole(MemberRole.ADMIN.name()) // ADMIN 권한 필요 // .requestMatchers("/api/auth/profile").authenticated() // 인증 필요 .anyRequest().permitAll() // 그 외 요청 허용 ); @@ -60,7 +60,7 @@ public CorsConfigurationSource corsConfigurationSource() { "https://admin.ajouchong.com" )); - configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); + configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); configuration.setAllowedHeaders(List.of("*")); configuration.setExposedHeaders(List.of("Authorization", "Set-Cookie")); // 쿠키 반환 허용 configuration.setAllowCredentials(true); diff --git a/src/main/java/com/ajouchong/controller/admin/PromotionAdminController.java b/src/main/java/com/ajouchong/controller/admin/PromotionAdminController.java new file mode 100644 index 0000000..ff30296 --- /dev/null +++ b/src/main/java/com/ajouchong/controller/admin/PromotionAdminController.java @@ -0,0 +1,47 @@ +package com.ajouchong.controller.admin; + +import com.ajouchong.common.ApiResponse; +import com.ajouchong.dto.request.PromotionPartnerRequestDto; +import com.ajouchong.dto.response.PromotionPartnerResponseDto; +import com.ajouchong.service.PromotionPartnerService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/admin/promotion") +@RequiredArgsConstructor +public class PromotionAdminController { + + private final PromotionPartnerService promotionPartnerService; + + @GetMapping + public ApiResponse> getAllPromotionPartners() { + List partners = promotionPartnerService.getAllPartnersForAdmin(); + return new ApiResponse<>(1, "제휴 항목 목록 조회 성공", partners); + } + + @PostMapping + public ApiResponse createPromotionPartner( + @Valid @RequestBody PromotionPartnerRequestDto requestDto) { + PromotionPartnerResponseDto response = promotionPartnerService.createPartner(requestDto); + return new ApiResponse<>(1, "제휴 항목 생성 성공", response); + } + + @PutMapping("/{id}") + public ApiResponse updatePromotionPartner( + @PathVariable Long id, + @Valid @RequestBody PromotionPartnerRequestDto requestDto) { + PromotionPartnerResponseDto response = promotionPartnerService.updatePartner(id, requestDto); + return new ApiResponse<>(1, "제휴 항목 수정 성공", response); + } + + @DeleteMapping("/{id}") + public ApiResponse deletePromotionPartner(@PathVariable Long id) { + promotionPartnerService.deletePartner(id); + return new ApiResponse<>(1, "제휴 항목 삭제 성공", null); + } +} + diff --git a/src/main/java/com/ajouchong/controller/admin/RentalAdminController.java b/src/main/java/com/ajouchong/controller/admin/RentalAdminController.java new file mode 100644 index 0000000..efa6872 --- /dev/null +++ b/src/main/java/com/ajouchong/controller/admin/RentalAdminController.java @@ -0,0 +1,78 @@ +package com.ajouchong.controller.admin; + +import com.ajouchong.common.ApiResponse; +import com.ajouchong.dto.request.RentalItemRequestDto; +import com.ajouchong.dto.request.RentalRecordCreateRequestDto; +import com.ajouchong.dto.request.RentalRecordReturnRequestDto; +import com.ajouchong.dto.response.RentalItemResponseDto; +import com.ajouchong.dto.response.RentalRecordResponseDto; +import com.ajouchong.service.RentalService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/admin/rental") +@RequiredArgsConstructor +public class RentalAdminController { + + private final RentalService rentalService; + + @GetMapping("/items") + public ApiResponse> getAllItems() { + return new ApiResponse<>(1, "대여 품목 목록 조회 성공", rentalService.getAllItemsForAdmin()); + } + + @PostMapping("/items") + public ApiResponse createItem(@Valid @RequestBody RentalItemRequestDto requestDto) { + return new ApiResponse<>(1, "대여 품목 등록 성공", rentalService.createItem(requestDto)); + } + + @PutMapping("/items/{id}") + public ApiResponse updateItem( + @PathVariable Long id, + @Valid @RequestBody RentalItemRequestDto requestDto) { + return new ApiResponse<>(1, "대여 품목 수정 성공", rentalService.updateItem(id, requestDto)); + } + + @DeleteMapping("/items/{id}") + public ApiResponse deleteItem(@PathVariable Long id) { + rentalService.deleteItem(id); + return new ApiResponse<>(1, "대여 품목 삭제 성공", null); + } + + @PatchMapping("/items/{id}/quantity") + public ApiResponse adjustItemQuantity( + @PathVariable Long id, + @RequestParam Integer delta) { + return new ApiResponse<>(1, "현재 수량 조정 성공", rentalService.adjustCurrentQuantity(id, delta)); + } + + @PostMapping("/items/upload-image") + public ApiResponse> uploadItemImage(@RequestPart("file") MultipartFile file) { + String imageUrl = rentalService.uploadRentalItemImage(file); + return new ApiResponse<>(1, "이미지 업로드 성공", Map.of("imageUrl", imageUrl)); + } + + @GetMapping("/records") + public ApiResponse> getAllRecords() { + return new ApiResponse<>(1, "대여 명부 조회 성공", rentalService.getAllRecordsForAdmin()); + } + + @PostMapping("/records") + public ApiResponse createRecord( + @Valid @RequestBody RentalRecordCreateRequestDto requestDto) { + return new ApiResponse<>(1, "대여 기록 등록 성공", rentalService.createRecord(requestDto)); + } + + @PatchMapping("/records/{id}/return") + public ApiResponse returnRecord( + @PathVariable Long id, + @Valid @RequestBody RentalRecordReturnRequestDto requestDto) { + return new ApiResponse<>(1, "반납 처리 성공", rentalService.markAsReturned(id, requestDto)); + } +} diff --git a/src/main/java/com/ajouchong/controller/user/PromotionUserController.java b/src/main/java/com/ajouchong/controller/user/PromotionUserController.java new file mode 100644 index 0000000..eeb7be5 --- /dev/null +++ b/src/main/java/com/ajouchong/controller/user/PromotionUserController.java @@ -0,0 +1,26 @@ +package com.ajouchong.controller.user; + +import com.ajouchong.common.ApiResponse; +import com.ajouchong.dto.response.PromotionPartnerResponseDto; +import com.ajouchong.service.PromotionPartnerService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api/promotion") +@RequiredArgsConstructor +public class PromotionUserController { + + private final PromotionPartnerService promotionPartnerService; + + @GetMapping + public ApiResponse> getActivePromotionPartners() { + List partners = promotionPartnerService.getActivePartners(); + return new ApiResponse<>(1, "제휴 항목 조회 성공", partners); + } +} + diff --git a/src/main/java/com/ajouchong/controller/user/RentalUserController.java b/src/main/java/com/ajouchong/controller/user/RentalUserController.java new file mode 100644 index 0000000..d75908c --- /dev/null +++ b/src/main/java/com/ajouchong/controller/user/RentalUserController.java @@ -0,0 +1,25 @@ +package com.ajouchong.controller.user; + +import com.ajouchong.common.ApiResponse; +import com.ajouchong.dto.response.RentalItemResponseDto; +import com.ajouchong.service.RentalService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api/rental") +@RequiredArgsConstructor +public class RentalUserController { + + private final RentalService rentalService; + + @GetMapping("/items") + public ApiResponse> getRentalItems() { + return new ApiResponse<>(1, "대여 품목 조회 성공", rentalService.getActiveItems()); + } +} + diff --git a/src/main/java/com/ajouchong/dto/request/PromotionPartnerRequestDto.java b/src/main/java/com/ajouchong/dto/request/PromotionPartnerRequestDto.java new file mode 100644 index 0000000..13ba722 --- /dev/null +++ b/src/main/java/com/ajouchong/dto/request/PromotionPartnerRequestDto.java @@ -0,0 +1,31 @@ +package com.ajouchong.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Data +public class PromotionPartnerRequestDto { + + @NotBlank(message = "업체명은 필수입니다.") + private String name; + + @NotBlank(message = "카테고리는 필수입니다.") + private String category; + + @NotBlank(message = "혜택 내용은 필수입니다.") + private String benefit; + + @NotBlank(message = "위치는 필수입니다.") + private String location; + + private String homepageUrl; + private String note; + + @NotNull(message = "노출 순서는 필수입니다.") + private Integer displayOrder; + + @NotNull(message = "활성 상태는 필수입니다.") + private Boolean active; +} + diff --git a/src/main/java/com/ajouchong/dto/request/RentalItemRequestDto.java b/src/main/java/com/ajouchong/dto/request/RentalItemRequestDto.java new file mode 100644 index 0000000..ac8d8f2 --- /dev/null +++ b/src/main/java/com/ajouchong/dto/request/RentalItemRequestDto.java @@ -0,0 +1,35 @@ +package com.ajouchong.dto.request; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Data +public class RentalItemRequestDto { + + @NotBlank(message = "품목명은 필수입니다.") + private String name; + + @NotBlank(message = "카테고리는 필수입니다.") + private String category; + + @NotNull(message = "총 수량은 필수입니다.") + @Min(value = 0, message = "총 수량은 0 이상이어야 합니다.") + private Integer totalQuantity; + + @NotNull(message = "현재 수량은 필수입니다.") + @Min(value = 0, message = "현재 수량은 0 이상이어야 합니다.") + private Integer currentQuantity; + + private String imageUrl; + private String note; + + @NotNull(message = "노출 순서는 필수입니다.") + @Min(value = 1, message = "노출 순서는 1 이상이어야 합니다.") + private Integer displayOrder; + + @NotNull(message = "활성 상태는 필수입니다.") + private Boolean active; +} + diff --git a/src/main/java/com/ajouchong/dto/request/RentalRecordCreateRequestDto.java b/src/main/java/com/ajouchong/dto/request/RentalRecordCreateRequestDto.java new file mode 100644 index 0000000..174ca3c --- /dev/null +++ b/src/main/java/com/ajouchong/dto/request/RentalRecordCreateRequestDto.java @@ -0,0 +1,43 @@ +package com.ajouchong.dto.request; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.time.LocalDate; + +@Data +public class RentalRecordCreateRequestDto { + + @NotNull(message = "대여 품목은 필수입니다.") + private Long rentalItemId; + + @NotNull(message = "대여 수량은 필수입니다.") + @Min(value = 1, message = "대여 수량은 1 이상이어야 합니다.") + private Integer quantity; + + @NotBlank(message = "담당자 이름은 필수입니다.") + private String managerName; + + @NotNull(message = "대여 일자는 필수입니다.") + private LocalDate rentalDate; + + @NotBlank(message = "대여자 이름은 필수입니다.") + private String borrowerName; + + @NotBlank(message = "대여자 학과는 필수입니다.") + private String borrowerDepartment; + + @NotBlank(message = "대여자 학번은 필수입니다.") + private String borrowerStudentId; + + @NotBlank(message = "대여자 전화번호는 필수입니다.") + private String borrowerPhone; + + @NotBlank(message = "손해 배상 동의 서명은 필수입니다.") + private String borrowerSignature; + + private String note; +} + diff --git a/src/main/java/com/ajouchong/dto/request/RentalRecordReturnRequestDto.java b/src/main/java/com/ajouchong/dto/request/RentalRecordReturnRequestDto.java new file mode 100644 index 0000000..30beeff --- /dev/null +++ b/src/main/java/com/ajouchong/dto/request/RentalRecordReturnRequestDto.java @@ -0,0 +1,12 @@ +package com.ajouchong.dto.request; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class RentalRecordReturnRequestDto { + + @NotBlank(message = "담당자 확인은 필수입니다.") + private String managerConfirmation; +} + diff --git a/src/main/java/com/ajouchong/dto/response/PromotionPartnerResponseDto.java b/src/main/java/com/ajouchong/dto/response/PromotionPartnerResponseDto.java new file mode 100644 index 0000000..1597d7d --- /dev/null +++ b/src/main/java/com/ajouchong/dto/response/PromotionPartnerResponseDto.java @@ -0,0 +1,23 @@ +package com.ajouchong.dto.response; + +import lombok.Builder; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@Builder +public class PromotionPartnerResponseDto { + private Long id; + private String name; + private String category; + private String benefit; + private String location; + private String homepageUrl; + private String note; + private Integer displayOrder; + private Boolean active; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} + diff --git a/src/main/java/com/ajouchong/dto/response/RentalItemResponseDto.java b/src/main/java/com/ajouchong/dto/response/RentalItemResponseDto.java new file mode 100644 index 0000000..9a9ad54 --- /dev/null +++ b/src/main/java/com/ajouchong/dto/response/RentalItemResponseDto.java @@ -0,0 +1,23 @@ +package com.ajouchong.dto.response; + +import lombok.Builder; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@Builder +public class RentalItemResponseDto { + private Long id; + private String name; + private String category; + private Integer totalQuantity; + private Integer currentQuantity; + private String imageUrl; + private String note; + private Integer displayOrder; + private Boolean active; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} + diff --git a/src/main/java/com/ajouchong/dto/response/RentalRecordResponseDto.java b/src/main/java/com/ajouchong/dto/response/RentalRecordResponseDto.java new file mode 100644 index 0000000..b56a59f --- /dev/null +++ b/src/main/java/com/ajouchong/dto/response/RentalRecordResponseDto.java @@ -0,0 +1,28 @@ +package com.ajouchong.dto.response; + +import lombok.Builder; +import lombok.Data; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Data +@Builder +public class RentalRecordResponseDto { + private Long id; + private Long rentalItemId; + private String rentalItemName; + private Integer quantity; + private String managerName; + private LocalDate rentalDate; + private String borrowerName; + private String borrowerDepartment; + private String borrowerStudentId; + private String borrowerPhone; + private String borrowerSignature; + private LocalDateTime returnedAt; + private String managerConfirmation; + private String note; + private LocalDateTime createdAt; +} + diff --git a/src/main/java/com/ajouchong/entity/PromotionPartner.java b/src/main/java/com/ajouchong/entity/PromotionPartner.java new file mode 100644 index 0000000..7d8b4e2 --- /dev/null +++ b/src/main/java/com/ajouchong/entity/PromotionPartner.java @@ -0,0 +1,62 @@ +package com.ajouchong.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "promotion_partners") +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PromotionPartner { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private String category; + + @Column(nullable = false, columnDefinition = "TEXT") + private String benefit; + + @Column(nullable = false) + private String location; + + private String homepageUrl; + + @Column(columnDefinition = "TEXT") + private String note; + + @Column(nullable = false) + private Integer displayOrder; + + @Column(nullable = false) + private Boolean active; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + LocalDateTime now = LocalDateTime.now(); + createdAt = now; + updatedAt = now; + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} + diff --git a/src/main/java/com/ajouchong/entity/RentalItem.java b/src/main/java/com/ajouchong/entity/RentalItem.java new file mode 100644 index 0000000..6298e75 --- /dev/null +++ b/src/main/java/com/ajouchong/entity/RentalItem.java @@ -0,0 +1,62 @@ +package com.ajouchong.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "rental_items") +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RentalItem { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private String name; + + @Column(nullable = false) + private String category; + + @Column(nullable = false) + private Integer totalQuantity; + + @Column(nullable = false) + private Integer currentQuantity; + + private String imageUrl; + + @Column(columnDefinition = "TEXT") + private String note; + + @Column(nullable = false) + private Integer displayOrder; + + @Column(nullable = false) + private Boolean active; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + LocalDateTime now = LocalDateTime.now(); + createdAt = now; + updatedAt = now; + } + + @PreUpdate + protected void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} + diff --git a/src/main/java/com/ajouchong/entity/RentalRecord.java b/src/main/java/com/ajouchong/entity/RentalRecord.java new file mode 100644 index 0000000..8cc3afa --- /dev/null +++ b/src/main/java/com/ajouchong/entity/RentalRecord.java @@ -0,0 +1,65 @@ +package com.ajouchong.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Entity +@Table(name = "rental_records") +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RentalRecord { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "rental_item_id", nullable = false) + private RentalItem rentalItem; + + @Column(nullable = false) + private Integer quantity; + + @Column(nullable = false) + private String managerName; + + @Column(nullable = false) + private LocalDate rentalDate; + + @Column(nullable = false) + private String borrowerName; + + @Column(nullable = false) + private String borrowerDepartment; + + @Column(nullable = false) + private String borrowerStudentId; + + @Column(nullable = false) + private String borrowerPhone; + + @Column(nullable = false) + private String borrowerSignature; + + private LocalDateTime returnedAt; + + private String managerConfirmation; + + @Column(columnDefinition = "TEXT") + private String note; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + } +} + diff --git a/src/main/java/com/ajouchong/jwt/JwtAuthenticationFilter.java b/src/main/java/com/ajouchong/jwt/JwtAuthenticationFilter.java index 2c3995a..6f4bb70 100644 --- a/src/main/java/com/ajouchong/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/com/ajouchong/jwt/JwtAuthenticationFilter.java @@ -19,16 +19,29 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.Collections; +import java.util.Set; @Slf4j @RequiredArgsConstructor public class JwtAuthenticationFilter extends OncePerRequestFilter { + private static final Set PUBLIC_AUTH_PATHS = Set.of( + "/api/login/auth/oauth", + "/api/login/auth/logout" + ); private final JwtTokenProvider jwtTokenProvider; private final MemberRepository memberRepository; + @Override + protected boolean shouldNotFilter(@NonNull HttpServletRequest request) { + if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { + return true; + } + return PUBLIC_AUTH_PATHS.contains(request.getRequestURI()); + } + @Override protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @@ -90,6 +103,7 @@ private String extractTokenFromCookies(HttpServletRequest request) { private void setAuthentication(String token) { String email = jwtTokenProvider.getEmailFromToken(token); String role = jwtTokenProvider.getRoleFromToken(token); + String authorityRole = role != null && role.startsWith("ROLE_") ? role : "ROLE_" + role; Member member = memberRepository.findByEmail(email) .orElseThrow(() -> { @@ -100,7 +114,7 @@ private void setAuthentication(String token) { Authentication authentication = new UsernamePasswordAuthenticationToken( member, null, - Collections.singletonList(new SimpleGrantedAuthority(role)) + Collections.singletonList(new SimpleGrantedAuthority(authorityRole)) ); SecurityContextHolder.getContext().setAuthentication(authentication); diff --git a/src/main/java/com/ajouchong/oauth/OAuthController.java b/src/main/java/com/ajouchong/oauth/OAuthController.java index 6ce777a..43ecb25 100644 --- a/src/main/java/com/ajouchong/oauth/OAuthController.java +++ b/src/main/java/com/ajouchong/oauth/OAuthController.java @@ -13,12 +13,17 @@ import org.springframework.web.bind.annotation.*; import java.util.Map; +import java.util.Set; @RestController @RequestMapping(value = "/api/login/auth", produces = "application/json") @RequiredArgsConstructor @Slf4j public class OAuthController { + private static final Set ADMIN_EMAIL_ALLOWLIST = Set.of( + "toadsam@ajou.ac.kr", + "yeun787@ajou.ac.kr" + ); private final OAuthService oAuthService; private final JwtTokenProvider jwtTokenProvider; @@ -116,12 +121,24 @@ private String validateAndExtractAccessToken(Map requestBody) { } private Member findOrCreateMember(GoogleUserDto googleUser) { - return memberRepository.findByEmail(googleUser.getEmail()) + final String email = googleUser.getEmail(); + final MemberRole targetRole = ADMIN_EMAIL_ALLOWLIST.contains(email) ? MemberRole.ADMIN : MemberRole.STUDENT; + + return memberRepository.findByEmail(email) + .map(existingMember -> { + if (existingMember.getRole() != targetRole) { + existingMember.setRole(targetRole); + Member updated = memberRepository.save(existingMember); + log.info("회원 권한 동기화: {} -> {}", email, targetRole.name()); + return updated; + } + return existingMember; + }) .orElseGet(() -> { Member newMember = new Member(googleUser); - newMember.setRole(MemberRole.STUDENT); + newMember.setRole(targetRole); Member savedMember = memberRepository.save(newMember); - log.info("새로운 회원 생성: {}", savedMember.getEmail()); + log.info("새로운 회원 생성: {} ({})", savedMember.getEmail(), targetRole.name()); return savedMember; }); } diff --git a/src/main/java/com/ajouchong/repository/PromotionPartnerRepository.java b/src/main/java/com/ajouchong/repository/PromotionPartnerRepository.java new file mode 100644 index 0000000..31fd343 --- /dev/null +++ b/src/main/java/com/ajouchong/repository/PromotionPartnerRepository.java @@ -0,0 +1,14 @@ +package com.ajouchong.repository; + +import com.ajouchong.entity.PromotionPartner; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface PromotionPartnerRepository extends JpaRepository { + List findAllByOrderByDisplayOrderAscIdAsc(); + List findByActiveTrueOrderByDisplayOrderAscIdAsc(); +} + diff --git a/src/main/java/com/ajouchong/repository/RentalItemRepository.java b/src/main/java/com/ajouchong/repository/RentalItemRepository.java new file mode 100644 index 0000000..f916247 --- /dev/null +++ b/src/main/java/com/ajouchong/repository/RentalItemRepository.java @@ -0,0 +1,14 @@ +package com.ajouchong.repository; + +import com.ajouchong.entity.RentalItem; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface RentalItemRepository extends JpaRepository { + List findAllByOrderByDisplayOrderAscIdAsc(); + List findByActiveTrueOrderByDisplayOrderAscIdAsc(); +} + diff --git a/src/main/java/com/ajouchong/repository/RentalRecordRepository.java b/src/main/java/com/ajouchong/repository/RentalRecordRepository.java new file mode 100644 index 0000000..07459a7 --- /dev/null +++ b/src/main/java/com/ajouchong/repository/RentalRecordRepository.java @@ -0,0 +1,13 @@ +package com.ajouchong.repository; + +import com.ajouchong.entity.RentalRecord; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface RentalRecordRepository extends JpaRepository { + List findAllByOrderByRentalDateDescCreatedAtDesc(); +} + diff --git a/src/main/java/com/ajouchong/service/PromotionPartnerService.java b/src/main/java/com/ajouchong/service/PromotionPartnerService.java new file mode 100644 index 0000000..ddc7cef --- /dev/null +++ b/src/main/java/com/ajouchong/service/PromotionPartnerService.java @@ -0,0 +1,91 @@ +package com.ajouchong.service; + +import com.ajouchong.dto.request.PromotionPartnerRequestDto; +import com.ajouchong.dto.response.PromotionPartnerResponseDto; +import com.ajouchong.entity.PromotionPartner; +import com.ajouchong.repository.PromotionPartnerRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PromotionPartnerService { + + private final PromotionPartnerRepository promotionPartnerRepository; + + public List getActivePartners() { + return promotionPartnerRepository.findByActiveTrueOrderByDisplayOrderAscIdAsc() + .stream() + .map(this::toDto) + .collect(Collectors.toList()); + } + + public List getAllPartnersForAdmin() { + return promotionPartnerRepository.findAllByOrderByDisplayOrderAscIdAsc() + .stream() + .map(this::toDto) + .collect(Collectors.toList()); + } + + @Transactional + public PromotionPartnerResponseDto createPartner(PromotionPartnerRequestDto requestDto) { + PromotionPartner partner = PromotionPartner.builder() + .name(requestDto.getName()) + .category(requestDto.getCategory()) + .benefit(requestDto.getBenefit()) + .location(requestDto.getLocation()) + .homepageUrl(requestDto.getHomepageUrl()) + .note(requestDto.getNote()) + .displayOrder(requestDto.getDisplayOrder()) + .active(requestDto.getActive()) + .build(); + + return toDto(promotionPartnerRepository.save(partner)); + } + + @Transactional + public PromotionPartnerResponseDto updatePartner(Long id, PromotionPartnerRequestDto requestDto) { + PromotionPartner partner = promotionPartnerRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("제휴 항목을 찾을 수 없습니다. ID: " + id)); + + partner.setName(requestDto.getName()); + partner.setCategory(requestDto.getCategory()); + partner.setBenefit(requestDto.getBenefit()); + partner.setLocation(requestDto.getLocation()); + partner.setHomepageUrl(requestDto.getHomepageUrl()); + partner.setNote(requestDto.getNote()); + partner.setDisplayOrder(requestDto.getDisplayOrder()); + partner.setActive(requestDto.getActive()); + + return toDto(promotionPartnerRepository.save(partner)); + } + + @Transactional + public void deletePartner(Long id) { + PromotionPartner partner = promotionPartnerRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("제휴 항목을 찾을 수 없습니다. ID: " + id)); + promotionPartnerRepository.delete(partner); + } + + private PromotionPartnerResponseDto toDto(PromotionPartner partner) { + return PromotionPartnerResponseDto.builder() + .id(partner.getId()) + .name(partner.getName()) + .category(partner.getCategory()) + .benefit(partner.getBenefit()) + .location(partner.getLocation()) + .homepageUrl(partner.getHomepageUrl()) + .note(partner.getNote()) + .displayOrder(partner.getDisplayOrder()) + .active(partner.getActive()) + .createdAt(partner.getCreatedAt()) + .updatedAt(partner.getUpdatedAt()) + .build(); + } +} + diff --git a/src/main/java/com/ajouchong/service/RentalService.java b/src/main/java/com/ajouchong/service/RentalService.java new file mode 100644 index 0000000..c59f19c --- /dev/null +++ b/src/main/java/com/ajouchong/service/RentalService.java @@ -0,0 +1,215 @@ +package com.ajouchong.service; + +import com.ajouchong.dto.request.RentalItemRequestDto; +import com.ajouchong.dto.request.RentalRecordCreateRequestDto; +import com.ajouchong.dto.request.RentalRecordReturnRequestDto; +import com.ajouchong.dto.response.RentalItemResponseDto; +import com.ajouchong.dto.response.RentalRecordResponseDto; +import com.ajouchong.entity.RentalItem; +import com.ajouchong.entity.RentalRecord; +import com.ajouchong.repository.RentalItemRepository; +import com.ajouchong.repository.RentalRecordRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class RentalService { + + private final RentalItemRepository rentalItemRepository; + private final RentalRecordRepository rentalRecordRepository; + private final S3UploadService s3UploadService; + + public List getActiveItems() { + return rentalItemRepository.findByActiveTrueOrderByDisplayOrderAscIdAsc() + .stream() + .map(this::toItemDto) + .collect(Collectors.toList()); + } + + public List getAllItemsForAdmin() { + return rentalItemRepository.findAllByOrderByDisplayOrderAscIdAsc() + .stream() + .map(this::toItemDto) + .collect(Collectors.toList()); + } + + @Transactional + public RentalItemResponseDto createItem(RentalItemRequestDto requestDto) { + validateItemQuantities(requestDto.getCurrentQuantity(), requestDto.getTotalQuantity()); + + RentalItem item = RentalItem.builder() + .name(requestDto.getName()) + .category(requestDto.getCategory()) + .totalQuantity(requestDto.getTotalQuantity()) + .currentQuantity(requestDto.getCurrentQuantity()) + .imageUrl(requestDto.getImageUrl()) + .note(requestDto.getNote()) + .displayOrder(requestDto.getDisplayOrder()) + .active(requestDto.getActive()) + .build(); + + return toItemDto(rentalItemRepository.save(item)); + } + + @Transactional + public RentalItemResponseDto updateItem(Long id, RentalItemRequestDto requestDto) { + validateItemQuantities(requestDto.getCurrentQuantity(), requestDto.getTotalQuantity()); + RentalItem item = rentalItemRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("대여 품목을 찾을 수 없습니다. ID: " + id)); + + item.setName(requestDto.getName()); + item.setCategory(requestDto.getCategory()); + item.setTotalQuantity(requestDto.getTotalQuantity()); + item.setCurrentQuantity(requestDto.getCurrentQuantity()); + item.setImageUrl(requestDto.getImageUrl()); + item.setNote(requestDto.getNote()); + item.setDisplayOrder(requestDto.getDisplayOrder()); + item.setActive(requestDto.getActive()); + + return toItemDto(rentalItemRepository.save(item)); + } + + @Transactional + public void deleteItem(Long id) { + RentalItem item = rentalItemRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("대여 품목을 찾을 수 없습니다. ID: " + id)); + rentalItemRepository.delete(item); + } + + @Transactional + public RentalItemResponseDto adjustCurrentQuantity(Long id, Integer delta) { + if (delta == null || delta == 0) { + throw new IllegalArgumentException("수량 변경값(delta)은 0이 아니어야 합니다."); + } + + RentalItem item = rentalItemRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("대여 품목을 찾을 수 없습니다. ID: " + id)); + + int next = item.getCurrentQuantity() + delta; + if (next < 0) { + throw new IllegalArgumentException("현재 수량은 0보다 작아질 수 없습니다."); + } + if (next > item.getTotalQuantity()) { + throw new IllegalArgumentException("현재 수량은 총 수량을 초과할 수 없습니다."); + } + + item.setCurrentQuantity(next); + return toItemDto(rentalItemRepository.save(item)); + } + + public String uploadRentalItemImage(MultipartFile file) { + try { + return s3UploadService.saveFile(file); + } catch (Exception e) { + throw new IllegalArgumentException("이미지 업로드 실패: " + e.getMessage(), e); + } + } + + public List getAllRecordsForAdmin() { + return rentalRecordRepository.findAllByOrderByRentalDateDescCreatedAtDesc() + .stream() + .map(this::toRecordDto) + .collect(Collectors.toList()); + } + + @Transactional + public RentalRecordResponseDto createRecord(RentalRecordCreateRequestDto requestDto) { + RentalItem item = rentalItemRepository.findById(requestDto.getRentalItemId()) + .orElseThrow(() -> new IllegalArgumentException("대여 품목을 찾을 수 없습니다. ID: " + requestDto.getRentalItemId())); + + if (Boolean.FALSE.equals(item.getActive())) { + throw new IllegalArgumentException("비활성 품목은 대여할 수 없습니다."); + } + if (requestDto.getQuantity() > item.getCurrentQuantity()) { + throw new IllegalArgumentException("현재 수량보다 많이 대여할 수 없습니다."); + } + + item.setCurrentQuantity(item.getCurrentQuantity() - requestDto.getQuantity()); + + RentalRecord record = RentalRecord.builder() + .rentalItem(item) + .quantity(requestDto.getQuantity()) + .managerName(requestDto.getManagerName()) + .rentalDate(requestDto.getRentalDate()) + .borrowerName(requestDto.getBorrowerName()) + .borrowerDepartment(requestDto.getBorrowerDepartment()) + .borrowerStudentId(requestDto.getBorrowerStudentId()) + .borrowerPhone(requestDto.getBorrowerPhone()) + .borrowerSignature(requestDto.getBorrowerSignature()) + .note(requestDto.getNote()) + .build(); + + rentalItemRepository.save(item); + return toRecordDto(rentalRecordRepository.save(record)); + } + + @Transactional + public RentalRecordResponseDto markAsReturned(Long recordId, RentalRecordReturnRequestDto requestDto) { + RentalRecord record = rentalRecordRepository.findById(recordId) + .orElseThrow(() -> new IllegalArgumentException("대여 기록을 찾을 수 없습니다. ID: " + recordId)); + + if (record.getReturnedAt() != null) { + throw new IllegalArgumentException("이미 반납 처리된 기록입니다."); + } + + RentalItem item = record.getRentalItem(); + int restoredQuantity = Math.min(item.getTotalQuantity(), item.getCurrentQuantity() + record.getQuantity()); + item.setCurrentQuantity(restoredQuantity); + + record.setReturnedAt(LocalDateTime.now()); + record.setManagerConfirmation(requestDto.getManagerConfirmation()); + + rentalItemRepository.save(item); + return toRecordDto(rentalRecordRepository.save(record)); + } + + private void validateItemQuantities(Integer current, Integer total) { + if (current > total) { + throw new IllegalArgumentException("현재 수량은 총 수량을 초과할 수 없습니다."); + } + } + + private RentalItemResponseDto toItemDto(RentalItem item) { + return RentalItemResponseDto.builder() + .id(item.getId()) + .name(item.getName()) + .category(item.getCategory()) + .totalQuantity(item.getTotalQuantity()) + .currentQuantity(item.getCurrentQuantity()) + .imageUrl(item.getImageUrl()) + .note(item.getNote()) + .displayOrder(item.getDisplayOrder()) + .active(item.getActive()) + .createdAt(item.getCreatedAt()) + .updatedAt(item.getUpdatedAt()) + .build(); + } + + private RentalRecordResponseDto toRecordDto(RentalRecord record) { + return RentalRecordResponseDto.builder() + .id(record.getId()) + .rentalItemId(record.getRentalItem().getId()) + .rentalItemName(record.getRentalItem().getName()) + .quantity(record.getQuantity()) + .managerName(record.getManagerName()) + .rentalDate(record.getRentalDate()) + .borrowerName(record.getBorrowerName()) + .borrowerDepartment(record.getBorrowerDepartment()) + .borrowerStudentId(record.getBorrowerStudentId()) + .borrowerPhone(record.getBorrowerPhone()) + .borrowerSignature(record.getBorrowerSignature()) + .returnedAt(record.getReturnedAt()) + .managerConfirmation(record.getManagerConfirmation()) + .note(record.getNote()) + .createdAt(record.getCreatedAt()) + .build(); + } +}