diff --git a/common-service/src/main/java/com/checkit/common/entity/CategoryType.java b/common-service/src/main/java/com/checkit/common/entity/CategoryType.java new file mode 100644 index 0000000..f4003d2 --- /dev/null +++ b/common-service/src/main/java/com/checkit/common/entity/CategoryType.java @@ -0,0 +1,17 @@ +package com.checkit.common.entity; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum CategoryType { + WAKE("기상"), + SEATED("공부"), + COTE("코테"), + LANG("언어"), + CERT("자격증"), + ETC("기타"); + + private final String displayName; +} diff --git a/common-service/src/main/resources/db/checkmate_postgres_ddl.sql b/common-service/src/main/resources/db/checkmate_postgres_ddl.sql index 36cb086..deb97b6 100644 --- a/common-service/src/main/resources/db/checkmate_postgres_ddl.sql +++ b/common-service/src/main/resources/db/checkmate_postgres_ddl.sql @@ -125,7 +125,7 @@ CREATE TABLE social_account ( CREATE TABLE user_item ( product_item_id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, - user_id2 UUID, + user_id UUID, product_id BIGINT NOT NULL, quantity INTEGER NOT NULL DEFAULT 1, CONSTRAINT pk_user_item PRIMARY KEY (product_item_id) @@ -137,7 +137,7 @@ CREATE TABLE badge_user ( badge_id BIGINT NOT NULL, name VARCHAR(20) NOT NULL, earned_at TIMESTAMP NOT NULL DEFAULT now(), - is_equipped VARCHAR(255) NOT NULL DEFAULT false, + is_equipped BOOLEAN NOT NULL DEFAULT false, CONSTRAINT pk_badge_user PRIMARY KEY (badge_user_id) ); diff --git a/docker-compose.yml b/docker-compose.yml index 9ffbfaa..b03361f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,79 @@ version: '3.8' services: + eureka-service: + build: + context: . + dockerfile: eureka-service/Dockerfile + container_name: eureka-service + ports: + - "8761:8761" + networks: + - checkmate-network + + user-service: + build: + context: . + dockerfile: user-service/Dockerfile + container_name: user-service + ports: + - "8081:8081" + environment: + - SPRING_DATASOURCE_URL=jdbc:postgresql://sample-postgres:5432/${DB_NAME} + - SPRING_DATASOURCE_USERNAME=${DB_USERNAME} + - SPRING_DATASOURCE_PASSWORD=${DB_PASSWORD} + - SPRING_REDIS_HOST=redis + - JWT_SECRET=${JWT_SECRET} + - JWT_ACCESS_EXPIRATION=${JWT_ACCESS_EXPIRATION} + - JWT_REFRESH_EXPIRATION=${JWT_REFRESH_EXPIRATION} + - EUREKA_CLIENT_SERVICE_URL_DEFAULTZONE=http://eureka-service:8761/eureka/ + depends_on: + - postgres + - redis + - eureka-service + networks: + - checkmate-network + + gateway-service: + build: + context: . + dockerfile: gateway-service/Dockerfile + container_name: gateway-service + ports: + - "8080:8080" + environment: + - EUREKA_CLIENT_SERVICE_URL_DEFAULTZONE=http://eureka-service:8761/eureka/ + - JWT_SECRET=${JWT_SECRET} + - JWT_ACCESS_EXPIRATION=${JWT_ACCESS_EXPIRATION} + - JWT_REFRESH_EXPIRATION=${JWT_REFRESH_EXPIRATION} + depends_on: + - eureka-service + networks: + - checkmate-network + + store-service: + build: + context: . + dockerfile: store-service/Dockerfile + container_name: store-service + ports: + - "8084:8084" + environment: + - EUREKA_CLIENT_SERVICE_URL_DEFAULTZONE=http://eureka-service:8761/eureka/ + - SPRING_DATASOURCE_URL=jdbc:postgresql://sample-postgres:5432/${DB_NAME} + - SPRING_DATASOURCE_USERNAME=${DB_USERNAME} + - SPRING_DATASOURCE_PASSWORD=${DB_PASSWORD} + - SPRING_REDIS_HOST=redis + - JWT_SECRET=${JWT_SECRET} + - JWT_ACCESS_EXPIRATION=${JWT_ACCESS_EXPIRATION} + - JWT_REFRESH_EXPIRATION=${JWT_REFRESH_EXPIRATION} + depends_on: + - postgres + - redis + - eureka-service + networks: + - checkmate-network + redis: image: redis:7-alpine container_name: checkmate-redis @@ -8,7 +81,9 @@ services: - "${REDIS_LOCAL_PORT}:${REDIS_PORT}" volumes: - redis_data:/data - restart: always + restart: always + networks: + - checkmate-network postgres: image: postgres:latest @@ -21,6 +96,12 @@ services: - POSTGRES_PASSWORD=${DB_PASSWORD} volumes: - ./postgres-data:/var/lib/postgresql + networks: + - checkmate-network volumes: redis_data: + +networks: + checkmate-network: + driver: bridge \ No newline at end of file diff --git a/eureka-service/Dockerfile b/eureka-service/Dockerfile new file mode 100644 index 0000000..2a997eb --- /dev/null +++ b/eureka-service/Dockerfile @@ -0,0 +1,28 @@ +# 1. 빌드 스테이지 +FROM eclipse-temurin:17-jdk AS build +WORKDIR /app + +# 루트의 모든 파일을 복사 +COPY . . + +# 만약 루트에만 gradle 폴더가 있다면 서비스 안으로 복사 +RUN cp -r /app/gradle /app/eureka-service/ 2>/dev/null || : + +# 작업 디렉토리 이동 +WORKDIR /app/eureka-service + +# 권한 부여 및 빌드 +RUN chmod +x ./gradlew +RUN ./gradlew clean bootJar -x test + +# 2. 실행 스테이지 +FROM eclipse-temurin:17-jre +WORKDIR /app + +# 빌드된 jar 파일 복사 +COPY --from=build /app/eureka-service/build/libs/*.jar app.jar + +ENV TZ=Asia/Seoul +EXPOSE 8761 + +ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/gateway-service/Dockerfile b/gateway-service/Dockerfile new file mode 100644 index 0000000..439f63f --- /dev/null +++ b/gateway-service/Dockerfile @@ -0,0 +1,23 @@ +# 1. 빌드 스테이지 +FROM eclipse-temurin:17-jdk AS build +WORKDIR /app + +COPY . . + +RUN cp -r /app/gradle /app/gateway-service/ 2>/dev/null || : + +WORKDIR /app/gateway-service + +RUN chmod +x ./gradlew +RUN ./gradlew clean bootJar -x test + +# 2. 실행 스테이지 +FROM eclipse-temurin:17-jre +WORKDIR /app + +COPY --from=build /app/gateway-service/build/libs/*.jar app.jar + +ENV TZ=Asia/Seoul +EXPOSE 8000 + +ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/gateway-service/settings.gradle b/gateway-service/settings.gradle deleted file mode 100644 index 2e9fc2a..0000000 --- a/gateway-service/settings.gradle +++ /dev/null @@ -1 +0,0 @@ -rootProject.name = 'checkmate' diff --git a/gateway-service/src/main/resources/application.yml b/gateway-service/src/main/resources/application.yml index 5e86a93..3e4ff0e 100644 --- a/gateway-service/src/main/resources/application.yml +++ b/gateway-service/src/main/resources/application.yml @@ -26,10 +26,22 @@ spring: - id: user-service uri: lb://USER-SERVICE predicates: - - Path=/users/** + - Path=/users/**, /badges/** filters: - name: JwtAuthenticationFilter + - id: store-service-internal + uri: lb://STORE-SERVICE + predicates: + - Path=/internal/** + + + - id: store-service-public + uri: lb://STORE-SERVICE + predicates: + - Path=/points/**, /products/** + filters: + - name: JwtAuthenticationFilter - id: community-service uri: lb://COMMUNITY-SERVICE diff --git a/store-service/Dockerfile b/store-service/Dockerfile new file mode 100644 index 0000000..52e5eb9 --- /dev/null +++ b/store-service/Dockerfile @@ -0,0 +1,29 @@ +# 1. 빌드 스테이지 +FROM eclipse-temurin:17-jdk AS build +WORKDIR /app + +# 루트의 모든 파일을 복사 (gradle 폴더가 포함되도록) +COPY . . + +# [핵심] 루트에 있는 gradle 폴더를 store-service 안으로 복사 +RUN cp -r /app/gradle /app/store-service/ 2>/dev/null || : + +# 작업 디렉토리 이동 +WORKDIR /app/store-service + +# 권한 부여 및 빌드 +RUN chmod +x ./gradlew +RUN ./gradlew clean bootJar -x test + +# 2. 실행 스테이지 +FROM eclipse-temurin:17-jre +WORKDIR /app + +# 빌드된 jar 파일 복사 +COPY --from=build /app/store-service/build/libs/*.jar app.jar + +ENV TZ=Asia/Seoul +# docker-compose 설정에 맞춘 포트 (8084) +EXPOSE 8084 + +ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/store-service/build.gradle b/store-service/build.gradle index d7e6742..c48e37b 100644 --- a/store-service/build.gradle +++ b/store-service/build.gradle @@ -19,6 +19,13 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' runtimeOnly 'org.postgresql:postgresql' + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + implementation 'io.jsonwebtoken:jjwt-api:0.12.3' + implementation 'io.jsonwebtoken:jjwt-impl:0.12.3' + implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3' + testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' diff --git a/store-service/settings.gradle b/store-service/settings.gradle deleted file mode 100644 index 2e9fc2a..0000000 --- a/store-service/settings.gradle +++ /dev/null @@ -1 +0,0 @@ -rootProject.name = 'checkmate' diff --git a/store-service/src/main/java/com/checkit/storeservice/StoreApplication.java b/store-service/src/main/java/com/checkit/storeservice/StoreApplication.java index b3871be..4c9758f 100644 --- a/store-service/src/main/java/com/checkit/storeservice/StoreApplication.java +++ b/store-service/src/main/java/com/checkit/storeservice/StoreApplication.java @@ -2,7 +2,9 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +@EnableJpaAuditing @SpringBootApplication public class StoreApplication { diff --git a/store-service/src/main/java/com/checkit/storeservice/config/RestTemplateConfig.java b/store-service/src/main/java/com/checkit/storeservice/config/RestTemplateConfig.java new file mode 100644 index 0000000..ec294c5 --- /dev/null +++ b/store-service/src/main/java/com/checkit/storeservice/config/RestTemplateConfig.java @@ -0,0 +1,64 @@ +package com.checkit.storeservice.config; + +import org.apache.hc.client5.http.config.RequestConfig; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; +import org.apache.hc.core5.util.Timeout; +import org.springframework.cloud.client.loadbalancer.LoadBalanced; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class RestTemplateConfig { + + @Bean + @LoadBalanced + public RestTemplate restTemplate() { + var connManager = PoolingHttpClientConnectionManagerBuilder.create().build(); + connManager.setMaxTotal(200); + connManager.setDefaultMaxPerRoute(50); + + var requestConfig = RequestConfig.custom() + .setConnectTimeout(Timeout.ofMilliseconds(500)) + .setResponseTimeout(Timeout.ofMilliseconds(3000)) + .setConnectionRequestTimeout(Timeout.ofMilliseconds(1000)) + .build(); + + CloseableHttpClient httpClient = HttpClients.custom() + .setConnectionManager(connManager) + .setDefaultRequestConfig(requestConfig) + .evictExpiredConnections() + .build(); + + var factory = new HttpComponentsClientHttpRequestFactory(httpClient); + + return new RestTemplate(factory); + } + + @Bean(name = "externalRestTemplate") + public RestTemplate externalRestTemplate() { + var connManager = PoolingHttpClientConnectionManagerBuilder.create().build(); + connManager.setMaxTotal(200); + connManager.setDefaultMaxPerRoute(50); + + var requestConfig = RequestConfig.custom() + .setConnectTimeout(Timeout.ofMilliseconds(500)) + .setResponseTimeout(Timeout.ofMilliseconds(3000)) + .setConnectionRequestTimeout(Timeout.ofMilliseconds(1000)) + .build(); + + CloseableHttpClient httpClient = HttpClients.custom() + .setConnectionManager(connManager) + .setDefaultRequestConfig(requestConfig) + .evictExpiredConnections() + .build(); + + var factory = new HttpComponentsClientHttpRequestFactory(httpClient); + + + return new RestTemplate(factory); + } +} diff --git a/store-service/src/main/java/com/checkit/storeservice/config/SecurityConfig.java b/store-service/src/main/java/com/checkit/storeservice/config/SecurityConfig.java new file mode 100644 index 0000000..ebbab30 --- /dev/null +++ b/store-service/src/main/java/com/checkit/storeservice/config/SecurityConfig.java @@ -0,0 +1,71 @@ +package com.checkit.storeservice.config; + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; +import org.springframework.security.oauth2.jwt.*; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + .authorizeHttpRequests(auth -> auth + + .anyRequest().authenticated() + ) + .oauth2ResourceServer(oauth2 -> oauth2 + .jwt(Customizer.withDefaults()) + ); + + return http.build(); + } + + @Value("${jwt.secret}") + private String jwtSecret; + + @Bean + public JwtDecoder jwtDecoder() { + return token -> { + try { + SecretKey key = Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8)); + + var claims = Jwts.parser() + .verifyWith(key) + .build() + .parseSignedClaims(token) + .getPayload(); + + return new Jwt( + token, + claims.getIssuedAt().toInstant(), + claims.getExpiration().toInstant(), + Map.of("alg", "HS256"), + claims + ); + } catch (Exception e) { + throw new JwtException("검증 실패: " + e.getMessage()); + } + }; + } +} diff --git a/store-service/src/main/java/com/checkit/storeservice/controller/InternalPointController.java b/store-service/src/main/java/com/checkit/storeservice/controller/InternalPointController.java new file mode 100644 index 0000000..b41df54 --- /dev/null +++ b/store-service/src/main/java/com/checkit/storeservice/controller/InternalPointController.java @@ -0,0 +1,28 @@ +package com.checkit.storeservice.controller; + +import com.checkit.common.dto.ApiResponse; +import com.checkit.storeservice.service.PointService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.UUID; + +@RestController +@RequestMapping("/internal/points") // 내부 서비스 호출용 +@RequiredArgsConstructor +public class InternalPointController { + + private final PointService pointService; + + @PostMapping("/earn") + public ApiResponse earnPoint( + @RequestParam("userId") UUID userId, + @RequestParam("amount") int amount, + @RequestParam("description") String description) { + pointService.earnPoint(userId, amount, description); + return ApiResponse.success(null); + } +} diff --git a/store-service/src/main/java/com/checkit/storeservice/controller/PointController.java b/store-service/src/main/java/com/checkit/storeservice/controller/PointController.java new file mode 100644 index 0000000..5d15ae8 --- /dev/null +++ b/store-service/src/main/java/com/checkit/storeservice/controller/PointController.java @@ -0,0 +1,47 @@ +package com.checkit.storeservice.controller; + +import com.checkit.common.dto.ApiResponse; +import com.checkit.storeservice.dto.PointTransactionRes; +import com.checkit.storeservice.service.PointService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.web.bind.annotation.*; + +import java.util.UUID; + +@RestController +@RequestMapping("/points") +@RequiredArgsConstructor +public class PointController { + + private final PointService pointService; + + @GetMapping("/balance") + public ApiResponse getMyBalance(@RequestHeader("X-User-Id") String userId) { + int balance = pointService.getCurrentBalance(UUID.fromString(userId)); + return ApiResponse.success(balance); + } + + @GetMapping("/history") + public ApiResponse> getMyHistory( + @RequestHeader("X-User-Id") String userId, + @RequestParam("type") String type, + @PageableDefault(size = 10) Pageable pageable) { + + Page history = pointService.getPointHistory( + UUID.fromString(userId), type, pageable); + return ApiResponse.success(history); + } + + @PostMapping("/test-spend") + public ApiResponse testSpend( + @RequestHeader("X-User-Id") String userId, + @RequestParam("amount") int amount, + @RequestParam("description") String description) { + + pointService.spendPoint(UUID.fromString(userId), amount, description); + return ApiResponse.success("포인트 사용 완료. 잔액을 확인하세요!"); + } +} diff --git a/store-service/src/main/java/com/checkit/storeservice/controller/ProductController.java b/store-service/src/main/java/com/checkit/storeservice/controller/ProductController.java new file mode 100644 index 0000000..6088781 --- /dev/null +++ b/store-service/src/main/java/com/checkit/storeservice/controller/ProductController.java @@ -0,0 +1,119 @@ +package com.checkit.storeservice.controller; + +import com.checkit.common.dto.ApiResponse; +import com.checkit.storeservice.dto.*; +import com.checkit.storeservice.service.ProductService; +import com.checkit.storeservice.service.UserItemService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/products") +@RequiredArgsConstructor +@Slf4j +public class ProductController { + + private final ProductService productService; + private final UserItemService userItemService; + + @PostMapping + public ApiResponse createProduct( + @RequestBody ProductCreateReq request, + @RequestHeader("X-User-Id") String userId, + @RequestHeader("X-User-Role") String role) { + + checkAdminRole(role); + + log.info("Admin {} is creating a new product: {}", userId, request.getName()); + + ProductCreateRes response = productService.createProduct(request, UUID.fromString(userId)); + + return ApiResponse.success(response); + } + + @PatchMapping("/{productId}") + public ApiResponse updateProduct( + @PathVariable("productId") Long productId, + @RequestBody ProductUpdateReq request, + @RequestHeader("X-User-Id") String userId, + @RequestHeader("X-User-Role") String role) { + + checkAdminRole(role); + log.info("Admin {} is updating product ID: {}", userId, productId); + + ProductUpdateRes response = productService.updateProduct(productId, request, UUID.fromString(userId)); + + return ApiResponse.success(response); + } + + @PatchMapping("/{productId}/delete") + public ApiResponse deleteProduct( + @PathVariable("productId") Long productId, + @RequestHeader("X-User-Id") String userId, + @RequestHeader("X-User-Role") String role) { + + checkAdminRole(role); + log.info("Admin {} is soft-deleting product ID: {}", userId, productId); + + ProductDeleteRes response = productService.deleteProduct(productId, UUID.fromString(userId)); + + return ApiResponse.success(response); + } + + @GetMapping + public ApiResponse> getAvailableProducts() { + return ApiResponse.success(productService.getAvailableProducts()); + } + + @GetMapping("/admin") + public ApiResponse> getAllProducts( + @RequestHeader("X-User-Role") String role) { + + checkAdminRole(role); + return ApiResponse.success(productService.getAllProducts()); + } + + @PostMapping("/{productId}/purchase") + public ApiResponse purchaseProduct( + @PathVariable("productId") Long productId, + @RequestHeader("X-User-Id") UUID userId) { + return ApiResponse.success(userItemService.purchaseProduct(productId, userId)); + } + + @GetMapping("/items") + public ApiResponse getMyInventory( + @RequestHeader("X-User-Id") UUID userId) { + + List inventory = userItemService.getMyInventory(userId); + return ApiResponse.success(UserInventoryRes.from(inventory)); + } + + @PatchMapping("/items/{productItemId}/delete") + public ApiResponse deleteUserItem( + @PathVariable("productItemId") Long productItemId, + @RequestHeader("X-User-Id") String userId) { + + userItemService.deleteUserItem(UUID.fromString(userId), productItemId); + + return ApiResponse.success("아이템이 인벤토리에서 제거되었습니다."); + } + + @PostMapping("/items/use-auto") + public ApiResponse useItemAuto( + @RequestParam("userId") UUID userId, + @RequestParam("failureType") String failureType) { + + userItemService.useItemAuto(userId, failureType); + return ApiResponse.success("면제권이 자동으로 사용되었습니다."); + } + + private void checkAdminRole(String role) { + if (!"ADMIN".equals(role)) { + throw new RuntimeException("관리자 권한이 필요합니다."); + } + } +} diff --git a/store-service/src/main/java/com/checkit/storeservice/dto/PointTransactionRes.java b/store-service/src/main/java/com/checkit/storeservice/dto/PointTransactionRes.java new file mode 100644 index 0000000..17df6f4 --- /dev/null +++ b/store-service/src/main/java/com/checkit/storeservice/dto/PointTransactionRes.java @@ -0,0 +1,30 @@ +package com.checkit.storeservice.dto; + +import com.checkit.storeservice.entity.PointTransactionEntity; +import lombok.Builder; +import lombok.Getter; + +import java.time.OffsetDateTime; +import java.util.UUID; + +@Getter +@Builder +public class PointTransactionRes { + private UUID transactionId; + private String type; // 적립, 사용, 환전 등 + private Integer amount; // 거래 금액 + private Integer balanceAfter; // 거래 후 잔액 + private String description; // 거래 내용 + private OffsetDateTime createdAt; // 거래 일시 + + public static PointTransactionRes from(PointTransactionEntity entity) { + return PointTransactionRes.builder() + .transactionId(entity.getTransactionId()) + .type(entity.getType()) + .amount(entity.getAmount()) + .balanceAfter(entity.getBalanceAfter()) + .description(entity.getDescription()) + .createdAt(entity.getCreatedAt()) + .build(); + } +} diff --git a/store-service/src/main/java/com/checkit/storeservice/dto/ProductCreateReq.java b/store-service/src/main/java/com/checkit/storeservice/dto/ProductCreateReq.java new file mode 100644 index 0000000..6300aab --- /dev/null +++ b/store-service/src/main/java/com/checkit/storeservice/dto/ProductCreateReq.java @@ -0,0 +1,30 @@ +package com.checkit.storeservice.dto; + +import com.checkit.common.entity.CategoryType; +import com.checkit.storeservice.entity.ProductEntity; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.*; + + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class ProductCreateReq { + + private String name; + private CategoryType category; + private int price; + + @JsonProperty("isAvailable") + private boolean isAvailable; + + public ProductEntity toEntity() { + return ProductEntity.builder() + .name(this.name) + .category(this.category.name()) + .price(this.price) + .isAvailable(this.isAvailable) + .build(); + } +} diff --git a/store-service/src/main/java/com/checkit/storeservice/dto/ProductCreateRes.java b/store-service/src/main/java/com/checkit/storeservice/dto/ProductCreateRes.java new file mode 100644 index 0000000..c67cf06 --- /dev/null +++ b/store-service/src/main/java/com/checkit/storeservice/dto/ProductCreateRes.java @@ -0,0 +1,32 @@ +package com.checkit.storeservice.dto; + +import com.checkit.common.entity.CategoryType; +import com.checkit.storeservice.entity.ProductEntity; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.time.OffsetDateTime; + + +@Getter +@AllArgsConstructor +@Builder +public class ProductCreateRes { + + private Long productId; + private String name; + private CategoryType category; + private int price; + private OffsetDateTime createdAt; + + public static ProductCreateRes from(ProductEntity entity) { + return ProductCreateRes.builder() + .productId(entity.getProductId()) + .name(entity.getName()) + .category(CategoryType.valueOf(entity.getCategory())) + .price(entity.getPrice()) + .createdAt(entity.getCreatedAt()) + .build(); + } +} diff --git a/store-service/src/main/java/com/checkit/storeservice/dto/ProductDeleteRes.java b/store-service/src/main/java/com/checkit/storeservice/dto/ProductDeleteRes.java new file mode 100644 index 0000000..b725a96 --- /dev/null +++ b/store-service/src/main/java/com/checkit/storeservice/dto/ProductDeleteRes.java @@ -0,0 +1,16 @@ +package com.checkit.storeservice.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.time.OffsetDateTime; + +@Getter +@AllArgsConstructor +@Builder +public class ProductDeleteRes { + private Long productId; + private String name; + private OffsetDateTime deletedAt; +} diff --git a/store-service/src/main/java/com/checkit/storeservice/dto/ProductPurchaseRes.java b/store-service/src/main/java/com/checkit/storeservice/dto/ProductPurchaseRes.java new file mode 100644 index 0000000..e6c4b4f --- /dev/null +++ b/store-service/src/main/java/com/checkit/storeservice/dto/ProductPurchaseRes.java @@ -0,0 +1,20 @@ +package com.checkit.storeservice.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; +import lombok.Getter; + +import java.time.OffsetDateTime; +import java.util.UUID; + +@Getter +@Builder +public class ProductPurchaseRes { + private UUID transactionId; + private String productName; + private int spentAmount; + private int balanceAfter; + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + private OffsetDateTime purchasedAt; +} diff --git a/store-service/src/main/java/com/checkit/storeservice/dto/ProductRes.java b/store-service/src/main/java/com/checkit/storeservice/dto/ProductRes.java new file mode 100644 index 0000000..33bbff2 --- /dev/null +++ b/store-service/src/main/java/com/checkit/storeservice/dto/ProductRes.java @@ -0,0 +1,31 @@ +package com.checkit.storeservice.dto; + +import com.checkit.common.entity.CategoryType; +import com.checkit.storeservice.entity.ProductEntity; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@Builder +public class ProductRes { + private Long productId; + private String name; + private CategoryType category; + private String categoryName; + private int price; + private boolean isAvailable; + + public static ProductRes from(ProductEntity entity) { + CategoryType type = CategoryType.valueOf(entity.getCategory()); + return ProductRes.builder() + .productId(entity.getProductId()) + .name(entity.getName()) + .category(type) + .categoryName(type.getDisplayName()) + .price(entity.getPrice()) + .isAvailable(entity.isAvailable()) + .build(); + } +} diff --git a/store-service/src/main/java/com/checkit/storeservice/dto/ProductUpdateReq.java b/store-service/src/main/java/com/checkit/storeservice/dto/ProductUpdateReq.java new file mode 100644 index 0000000..1558196 --- /dev/null +++ b/store-service/src/main/java/com/checkit/storeservice/dto/ProductUpdateReq.java @@ -0,0 +1,18 @@ +package com.checkit.storeservice.dto; + +import com.checkit.common.entity.CategoryType; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.*; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class ProductUpdateReq { + private String name; + private CategoryType category; + private int price; + + @JsonProperty("isAvailable") + private boolean isAvailable; +} diff --git a/store-service/src/main/java/com/checkit/storeservice/dto/ProductUpdateRes.java b/store-service/src/main/java/com/checkit/storeservice/dto/ProductUpdateRes.java new file mode 100644 index 0000000..e551178 --- /dev/null +++ b/store-service/src/main/java/com/checkit/storeservice/dto/ProductUpdateRes.java @@ -0,0 +1,32 @@ +package com.checkit.storeservice.dto; + +import com.checkit.common.entity.CategoryType; +import com.checkit.storeservice.entity.ProductEntity; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.time.OffsetDateTime; + +@Getter +@AllArgsConstructor +@Builder +public class ProductUpdateRes { + private Long productId; + private String name; + private CategoryType category; + private int price; + private boolean isAvailable; + private OffsetDateTime updatedAt; + + public static ProductUpdateRes from(ProductEntity entity) { + return ProductUpdateRes.builder() + .productId(entity.getProductId()) + .name(entity.getName()) + .category(CategoryType.valueOf(entity.getCategory())) + .price(entity.getPrice()) + .isAvailable(entity.isAvailable()) + .updatedAt(entity.getUpdatedAt()) + .build(); + } +} diff --git a/store-service/src/main/java/com/checkit/storeservice/dto/UserInventoryRes.java b/store-service/src/main/java/com/checkit/storeservice/dto/UserInventoryRes.java new file mode 100644 index 0000000..d440302 --- /dev/null +++ b/store-service/src/main/java/com/checkit/storeservice/dto/UserInventoryRes.java @@ -0,0 +1,18 @@ +package com.checkit.storeservice.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +@Getter +@AllArgsConstructor +@Builder +public class UserInventoryRes { + private List items; + + public static UserInventoryRes from(List items) { + return new UserInventoryRes(items); + } +} diff --git a/store-service/src/main/java/com/checkit/storeservice/dto/UserItemRes.java b/store-service/src/main/java/com/checkit/storeservice/dto/UserItemRes.java new file mode 100644 index 0000000..daf6d58 --- /dev/null +++ b/store-service/src/main/java/com/checkit/storeservice/dto/UserItemRes.java @@ -0,0 +1,26 @@ +package com.checkit.storeservice.dto; + +import com.checkit.storeservice.entity.ProductEntity; +import com.checkit.storeservice.entity.UserItemEntity; +import lombok.Builder; +import lombok.Getter; + +import java.time.OffsetDateTime; + +@Getter +@Builder +public class UserItemRes { + private Long productItemId; + private Long productId; + private String name; + private int quantity; + + public static UserItemRes of(UserItemEntity item, ProductEntity product) { + return UserItemRes.builder() + .productItemId(item.getProductItemId()) + .productId(product.getProductId()) + .name(product.getName()) + .quantity(item.getQuantity()) + .build(); + } +} diff --git a/store-service/src/main/java/com/checkit/storeservice/entity/PointTransactionEntity.java b/store-service/src/main/java/com/checkit/storeservice/entity/PointTransactionEntity.java new file mode 100644 index 0000000..0900b66 --- /dev/null +++ b/store-service/src/main/java/com/checkit/storeservice/entity/PointTransactionEntity.java @@ -0,0 +1,40 @@ +package com.checkit.storeservice.entity; + +import com.checkit.common.entity.AuditBaseEntity; +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.util.UUID; + +@Entity +@Table(name = "point_transaction") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@SuperBuilder +public class PointTransactionEntity extends AuditBaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID transactionId; + + @Column(nullable = false) + private UUID userId; + + @Column(nullable = false, length = 10) + private String type; // 전체, 적립, 환전, 사용 + + @Column(nullable = false) + private Integer amount; + + @Column(nullable = false) + private Integer balanceAfter; + + @Column(nullable = false) + private String description; +} diff --git a/store-service/src/main/java/com/checkit/storeservice/entity/ProductEntity.java b/store-service/src/main/java/com/checkit/storeservice/entity/ProductEntity.java new file mode 100644 index 0000000..ee99215 --- /dev/null +++ b/store-service/src/main/java/com/checkit/storeservice/entity/ProductEntity.java @@ -0,0 +1,52 @@ +package com.checkit.storeservice.entity; + +import com.checkit.common.entity.AuditBaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "point_product") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProductEntity extends AuditBaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long productId; + + @Column(nullable = false) + private String name; + + @Column(nullable = false, length = 50) + private String category; + + @Column(nullable = false) + private int price; + + @Column(nullable = false) + private boolean isAvailable = true; + + @Builder + public ProductEntity(String name, String category, int price, boolean isAvailable) { + this.name = name; + this.category = category; + this.price = price; + this.isAvailable = isAvailable; + } + + public void update(String name, String category, Integer price, Boolean isAvailable) { + if (name != null) this.name = name; + if (category != null) this.category = category; + if (price != null) this.price = price; + if (isAvailable != null) this.isAvailable = isAvailable; + } + + @Override + public void softDelete(java.util.UUID actorId) { + super.softDelete(actorId); + this.isAvailable = false; + } +} diff --git a/store-service/src/main/java/com/checkit/storeservice/entity/UserItemEntity.java b/store-service/src/main/java/com/checkit/storeservice/entity/UserItemEntity.java new file mode 100644 index 0000000..70f9188 --- /dev/null +++ b/store-service/src/main/java/com/checkit/storeservice/entity/UserItemEntity.java @@ -0,0 +1,52 @@ +package com.checkit.storeservice.entity; + +import com.checkit.common.entity.AuditBaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.util.UUID; + +@Entity +@Table(name = "user_item") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@SuperBuilder +public class UserItemEntity extends AuditBaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long productItemId; + + @Column(nullable = false) + private UUID userId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "product_id") + private ProductEntity productId; + + @Column(nullable = false) + private int quantity; + + + public void addQuantity(int count) { + this.quantity += count; + } + + public void setQuantity(int quantity) { + if (quantity < 0) { + throw new IllegalArgumentException("수량은 0보다 작을 수 없습니다."); + } + this.quantity = quantity; + } + + public ProductEntity getProduct() { + return this.productId; + } +} diff --git a/store-service/src/main/java/com/checkit/storeservice/repository/PointTransactionRepository.java b/store-service/src/main/java/com/checkit/storeservice/repository/PointTransactionRepository.java new file mode 100644 index 0000000..410773b --- /dev/null +++ b/store-service/src/main/java/com/checkit/storeservice/repository/PointTransactionRepository.java @@ -0,0 +1,20 @@ +package com.checkit.storeservice.repository; + +import com.checkit.storeservice.entity.PointTransactionEntity; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; +import java.util.UUID; + +public interface PointTransactionRepository extends JpaRepository { + + Optional findFirstByUserIdOrderByCreatedAtDesc(UUID userId); + + Page findAllByUserIdOrderByCreatedAtDesc(UUID userId, Pageable pageable); + + Page findAllByUserIdAndTypeOrderByCreatedAtDesc(UUID userId, String type, Pageable pageable); +} diff --git a/store-service/src/main/java/com/checkit/storeservice/repository/ProductRepository.java b/store-service/src/main/java/com/checkit/storeservice/repository/ProductRepository.java new file mode 100644 index 0000000..a1a75ee --- /dev/null +++ b/store-service/src/main/java/com/checkit/storeservice/repository/ProductRepository.java @@ -0,0 +1,13 @@ +package com.checkit.storeservice.repository; + +import com.checkit.storeservice.entity.ProductEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface ProductRepository extends JpaRepository { + + List findAllByDeletedAtIsNullAndIsAvailableTrue(); + + List findAllByDeletedAtIsNull(); +} diff --git a/store-service/src/main/java/com/checkit/storeservice/repository/UserItemRepository.java b/store-service/src/main/java/com/checkit/storeservice/repository/UserItemRepository.java new file mode 100644 index 0000000..0d02cd1 --- /dev/null +++ b/store-service/src/main/java/com/checkit/storeservice/repository/UserItemRepository.java @@ -0,0 +1,26 @@ +package com.checkit.storeservice.repository; + +import com.checkit.storeservice.entity.ProductEntity; +import com.checkit.storeservice.entity.UserItemEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface UserItemRepository extends JpaRepository { + + Optional findByUserIdAndProductId(UUID userId, ProductEntity productId); + + List findAllByUserIdAndDeletedAtIsNull(UUID userId); + + @Query("SELECT ui FROM UserItemEntity ui " + + "JOIN FETCH ui.productId p " + + "WHERE ui.userId = :userId " + + "AND p.category IN :categories " + + "AND ui.deletedAt IS NULL " + + "ORDER BY p.category DESC") + List findAvailablePasses(@Param("userId") UUID userId, @Param("categories") List categories); +} diff --git a/store-service/src/main/java/com/checkit/storeservice/service/PointService.java b/store-service/src/main/java/com/checkit/storeservice/service/PointService.java new file mode 100644 index 0000000..658cdff --- /dev/null +++ b/store-service/src/main/java/com/checkit/storeservice/service/PointService.java @@ -0,0 +1,82 @@ +package com.checkit.storeservice.service; + +import com.checkit.storeservice.dto.PointTransactionRes; +import com.checkit.storeservice.entity.PointTransactionEntity; +import com.checkit.storeservice.repository.PointTransactionRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class PointService { + + private final PointTransactionRepository pointTransactionRepository; + + @Transactional + public void recordTransaction(UUID userId, String type, int amount, String description) { + int currentBalance = pointTransactionRepository.findFirstByUserIdOrderByCreatedAtDesc(userId) + .map(PointTransactionEntity::getBalanceAfter) + .orElse(0); + + int newBalance = currentBalance + amount; + + if (newBalance < 0) { + throw new RuntimeException("포인트가 부족합니다. (현재 잔액: " + currentBalance + ")"); + } + + PointTransactionEntity transaction = PointTransactionEntity.builder() + .userId(userId) + .type(type) + .amount(amount) + .balanceAfter(newBalance) + .description(description) + .build(); + + transaction.setCreator(userId); + + pointTransactionRepository.save(transaction); + } + + @Transactional(readOnly = true) + public Page getPointHistory(UUID userId, String type, Pageable pageable) { + Page transactions; + + if (type == null || type.equals("전체")) { + transactions = pointTransactionRepository.findAllByUserIdOrderByCreatedAtDesc(userId, pageable); + } else { + transactions = pointTransactionRepository.findAllByUserIdAndTypeOrderByCreatedAtDesc(userId, type, pageable); + } + + return transactions.map(PointTransactionRes::from); + } + + @Transactional(readOnly = true) + public int getCurrentBalance(UUID userId) { + return pointTransactionRepository.findFirstByUserIdOrderByCreatedAtDesc(userId) + .map(PointTransactionEntity::getBalanceAfter) + .orElse(0); + } + + @Transactional + public void earnPoint(UUID userId, int amount, String description) { + if (amount <= 0) { + throw new IllegalArgumentException("적립 금액은 0보다 커야 합니다."); + } + + recordTransaction(userId, "적립", amount, description); + } + + @Transactional + public void spendPoint(UUID userId, int amount, String description) { + if (amount <= 0) { + throw new IllegalArgumentException("사용 금액은 0보다 커야 합니다."); + } + + recordTransaction(userId, "사용", -amount, description); + } +} diff --git a/store-service/src/main/java/com/checkit/storeservice/service/ProductService.java b/store-service/src/main/java/com/checkit/storeservice/service/ProductService.java new file mode 100644 index 0000000..531f553 --- /dev/null +++ b/store-service/src/main/java/com/checkit/storeservice/service/ProductService.java @@ -0,0 +1,79 @@ +package com.checkit.storeservice.service; + +import com.checkit.storeservice.dto.*; +import com.checkit.storeservice.entity.ProductEntity; +import com.checkit.storeservice.repository.ProductRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class ProductService { + + private final ProductRepository productRepository; + + @Transactional + public ProductCreateRes createProduct(ProductCreateReq request, UUID adminId) { + + ProductEntity product = request.toEntity(); + + product.setCreator(adminId); + + ProductEntity savedProduct = productRepository.save(product); + + return ProductCreateRes.from(savedProduct); + } + + @Transactional + public ProductUpdateRes updateProduct(Long productId, ProductUpdateReq request, UUID adminId) { + + ProductEntity product = productRepository.findById(productId) + .orElseThrow(() -> new RuntimeException("해당 상품을 찾을 수 없습니다. ID: " + productId)); + + product.update( + request.getName(), + request.getCategory().name(), + request.getPrice(), + request.isAvailable() + ); + + product.setUpdater(adminId); + + return ProductUpdateRes.from(product); + } + + @Transactional + public ProductDeleteRes deleteProduct(Long productId, UUID adminId) { + ProductEntity product = productRepository.findById(productId) + .orElseThrow(() -> new RuntimeException("해당 상품을 찾을 수 없거나 이미 삭제되었습니다. ID: " + productId)); + + product.softDelete(adminId); + + + + return ProductDeleteRes.builder() + .productId(product.getProductId()) + .name(product.getName()) + .deletedAt(product.getDeletedAt()) + .build(); + } + + @Transactional(readOnly = true) + public List getAllProducts() { + return productRepository.findAllByDeletedAtIsNull().stream() + .map(ProductRes::from) + .collect(Collectors.toList()); + } + + @Transactional(readOnly = true) + public List getAvailableProducts() { + return productRepository.findAllByDeletedAtIsNullAndIsAvailableTrue().stream() + .map(ProductRes::from) + .collect(Collectors.toList()); + } +} diff --git a/store-service/src/main/java/com/checkit/storeservice/service/UserItemService.java b/store-service/src/main/java/com/checkit/storeservice/service/UserItemService.java new file mode 100644 index 0000000..ba3b616 --- /dev/null +++ b/store-service/src/main/java/com/checkit/storeservice/service/UserItemService.java @@ -0,0 +1,115 @@ +package com.checkit.storeservice.service; + +import com.checkit.storeservice.dto.ProductPurchaseRes; +import com.checkit.storeservice.dto.UserItemRes; +import com.checkit.storeservice.entity.PointTransactionEntity; +import com.checkit.storeservice.entity.ProductEntity; +import com.checkit.storeservice.entity.UserItemEntity; +import com.checkit.storeservice.repository.PointTransactionRepository; +import com.checkit.storeservice.repository.ProductRepository; +import com.checkit.storeservice.repository.UserItemRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Slf4j +public class UserItemService { + + private final ProductRepository productRepository; + private final UserItemRepository userItemRepository; + private final PointService pointService; + private final PointTransactionRepository pointTransactionRepository; + + @Transactional + public ProductPurchaseRes purchaseProduct(Long productId, UUID userId) { + + ProductEntity product = productRepository.findById(productId) + .filter(p -> p.getDeletedAt() == null && p.isAvailable()) + .orElseThrow(() -> new RuntimeException("구매 가능한 상품이 아닙니다.")); + + pointService.spendPoint(userId, product.getPrice(), product.getName() + " 구매"); + + PointTransactionEntity transaction = pointTransactionRepository + .findFirstByUserIdOrderByCreatedAtDesc(userId) + .orElseThrow(() -> new RuntimeException("결제 내역을 확인할 수 없습니다.")); + + UserItemEntity userItem = userItemRepository.findByUserIdAndProductId(userId, product) + .map(item -> { + item.addQuantity(1); + item.setUpdater(userId); + return item; + }) + .orElseGet(() -> UserItemEntity.builder() + .userId(userId) + .productId(product) + .quantity(1) + .createdBy(userId) + .build()); + + userItemRepository.save(userItem); + + return ProductPurchaseRes.builder() + .transactionId(transaction.getTransactionId()) + .productName(product.getName()) + .spentAmount(product.getPrice()) + .balanceAfter(transaction.getBalanceAfter()) + .purchasedAt(userItem.getCreatedAt()) + .build(); + } + + @Transactional(readOnly = true) + public List getMyInventory(UUID userId) { + return userItemRepository.findAllByUserIdAndDeletedAtIsNull(userId).stream() + .map(item -> UserItemRes.of(item, item.getProduct())) + .collect(Collectors.toList()); + } + + @Transactional + public void useItemAuto(UUID userId, String failureType) { + List inventory = userItemRepository.findAllByUserIdAndDeletedAtIsNull(userId); + + String specificCategory = failureType.equals("WAKEUP") ? "TODO" : "코테"; + + UserItemEntity selectedItem = inventory.stream() + .filter(ui -> { + String cat = ui.getProduct().getCategory(); + return cat.equals(specificCategory) || cat.equals("all"); + }) + .min(Comparator.comparingInt(ui -> + ui.getProduct().getCategory().equals("all") ? 1 : 0 // 전용권(0)이 전체권(1)보다 우선순위 높음 + )) + .orElseThrow(() -> new RuntimeException("사용 가능한 면제권이 없습니다.")); + + deductItemQuantity(selectedItem); + } + + @Transactional + public void deleteUserItem(UUID userId, Long productItemId) { + UserItemEntity userItem = userItemRepository.findById(productItemId) + .filter(ui -> ui.getUserId().equals(userId)) + .orElseThrow(() -> new RuntimeException("삭제할 아이템을 찾을 수 없습니다.")); + + userItem.setQuantity(0); + userItem.setDeletedAt(OffsetDateTime.now()); + + log.info("User {} deleted item {}", userId, productItemId); + } + + private void deductItemQuantity(UserItemEntity userItem) { + if (userItem.getQuantity() > 1) { + userItem.setQuantity(userItem.getQuantity() - 1); + } else { + userItem.setDeletedAt(OffsetDateTime.now()); + } + } +} diff --git a/store-service/src/main/resources/application.yml b/store-service/src/main/resources/application.yml index 267f7fe..9adb4b8 100644 --- a/store-service/src/main/resources/application.yml +++ b/store-service/src/main/resources/application.yml @@ -1 +1,34 @@ -spring.application.name=checkmate +server: + port: 8084 + +spring: + application: + name: store-service + datasource: + driver-class-name: org.postgresql.Driver + url: ${DB_URL} + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + + + jpa: + hibernate: + ddl-auto: update + show-sql: true + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + +eureka: + client: + register-with-eureka: true + fetch-registry: true + service-url: + defaultZone: http://localhost:8761/eureka/ + + +logging: + level: + org.springframework.security: debug + org.springframework.web: debug \ No newline at end of file diff --git a/user-service/Dockerfile b/user-service/Dockerfile new file mode 100644 index 0000000..912945c --- /dev/null +++ b/user-service/Dockerfile @@ -0,0 +1,29 @@ +# 1. 빌드 스테이지 +FROM eclipse-temurin:17-jdk AS build +WORKDIR /app + +# 루트의 모든 파일을 복사 (gradle 폴더가 포함되도록) +COPY . . + +# [핵심] 만약 루트에만 gradle 폴더가 있다면 user-service 안으로 복사해줍니다. +# 이미 있다면 덮어쓰기 되므로 안전합니다. +RUN cp -r /app/gradle /app/user-service/ 2>/dev/null || : + +# 작업 디렉토리 이동 +WORKDIR /app/user-service + +# 권한 부여 및 빌드 +RUN chmod +x ./gradlew +RUN ./gradlew clean bootJar -x test + +# 2. 실행 스테이지 +FROM eclipse-temurin:17-jre +WORKDIR /app + +# 빌드된 jar 파일 복사 +COPY --from=build /app/user-service/build/libs/*.jar app.jar + +ENV TZ=Asia/Seoul +EXPOSE 8081 + +ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/user-service/src/main/java/com/checkit/userservice/controller/BadgeController.java b/user-service/src/main/java/com/checkit/userservice/controller/BadgeController.java new file mode 100644 index 0000000..758526b --- /dev/null +++ b/user-service/src/main/java/com/checkit/userservice/controller/BadgeController.java @@ -0,0 +1,96 @@ +package com.checkit.userservice.controller; + +import com.checkit.common.dto.ApiResponse; +import com.checkit.userservice.dto.*; +import com.checkit.userservice.service.BadgeService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/badges") +@RequiredArgsConstructor +@Slf4j +public class BadgeController { + + private final BadgeService badgeService; + + @PostMapping + public ApiResponse createBadge( + @RequestHeader("X-User-Id") String userId, + @RequestHeader("X-User-Role") String role, + @Valid @RequestBody BadgeAdminReq request) { + + checkAdminRole(role); + log.info("Admin {} is creating badge: {}", userId, request.getName()); + + return ApiResponse.success(badgeService.createBadge(UUID.fromString(userId), request)); + } + + @GetMapping + public ApiResponse> getAllBadges() { + return ApiResponse.success(badgeService.getAllBadges()); + } + + @PatchMapping("/{badgeId}") + public ApiResponse updateBadge( + @PathVariable("badgeId") Long badgeId, + @RequestHeader("X-User-Id") String userId, + @RequestHeader("X-User-Role") String role, + @Valid @RequestBody BadgeAdminReq request) { + + checkAdminRole(role); + log.info("Admin {} is updating badge ID: {}", userId, badgeId); + + return ApiResponse.success(badgeService.updateBadge(badgeId, UUID.fromString(userId), request)); + } + + @PatchMapping("/{badgeId}/delete") + public ApiResponse deleteBadge( + @PathVariable("badgeId") Long badgeId, + @RequestHeader("X-User-Id") String userId, + @RequestHeader("X-User-Role") String role) { + + checkAdminRole(role); + log.info("Admin {} is soft-deleting badge ID: {}", userId, badgeId); + + BadgeDeleteRes response = badgeService.deleteBadge(badgeId, UUID.fromString(userId)); + + return ApiResponse.success(response); + } + + private void checkAdminRole(String role) { + if (!"ADMIN".equals(role)) { + throw new RuntimeException("관리자 권한이 필요합니다."); + } + } + + @PostMapping("/check") + public ApiResponse checkMyBadge( + @RequestHeader("X-User-Id") String userId) { + + UserBadgeRes response = badgeService.checkAndGrantBadge(UUID.fromString(userId)); + return ApiResponse.success(response); + } + + @GetMapping("/my-badges") + public ApiResponse getMyBadges( + @RequestHeader("X-User-Id") String userId) { + + MyBadgeListRes response = badgeService.getMyBadges(UUID.fromString(userId)); + return ApiResponse.success(response); + } + + @PatchMapping("/my-badges/{badgeUserId}/equip") + public ApiResponse equipBadge( + @PathVariable("badgeUserId") Long badgeUserId, + @RequestHeader("X-User-Id") String userId) { + + MyBadgeListRes.MyBadgeItemRes response = badgeService.equipBadge(UUID.fromString(userId), badgeUserId); + return ApiResponse.success(response); + } +} diff --git a/user-service/src/main/java/com/checkit/userservice/controller/UserController.java b/user-service/src/main/java/com/checkit/userservice/controller/UserController.java index ff5254c..54ab381 100644 --- a/user-service/src/main/java/com/checkit/userservice/controller/UserController.java +++ b/user-service/src/main/java/com/checkit/userservice/controller/UserController.java @@ -5,8 +5,11 @@ import com.checkit.userservice.service.UserService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import java.util.List; import java.util.UUID; @Slf4j @@ -43,7 +46,28 @@ public ApiResponse updateMyInfo( return ApiResponse.success(response); } + @GetMapping("/me/social") + public ApiResponse> getMySocialAccounts( + @RequestHeader("X-User-Id") String userId) { + log.info("Get social accounts request for User: {}", userId); + + UUID userUuid = UUID.fromString(userId); + List response = userService.getSocialAccounts(userUuid); + + return ApiResponse.success(response); + } + + @PatchMapping("/me/social/{provider}/unlink") + public ApiResponse unlinkSocial( + @RequestHeader("X-User-Id") String userId, + @PathVariable String provider) { + + log.info("Unlink social account request for User: {}, Provider: {}", userId, provider); + userService.unlinkSocial(UUID.fromString(userId), provider); + + return ApiResponse.success(null); + } @PatchMapping("/me/deactivate") public ApiResponse deactivate(@RequestHeader("X-User-Id") String userId) { userService.deactivateUser(UUID.fromString(userId)); @@ -66,4 +90,33 @@ public ApiResponse checkNickname( return ApiResponse.success(response); } + + @PatchMapping("/me/withdraw") + public ApiResponse withdraw(@RequestHeader("X-User-Id") String userId) { + log.info("Withdraw request for User: {}", userId); + userService.withdrawUser(UUID.fromString(userId)); + return ApiResponse.success(null); + } + + @PatchMapping("/me/favorite-categories") + public ApiResponse updateFavoriteCategories( + @RequestHeader("X-User-Id") String userId, + @RequestBody FavoriteCategoryReq request) { + + log.info("Update favorite categories for User: {}", userId); + + FavoriteCategoryRes response = userService.updateFavorites(UUID.fromString(userId), request); + + return ApiResponse.success(response); + } + + @GetMapping("/me/favorite-categories") + public ApiResponse getFavoriteCategories( + @RequestHeader("X-User-Id") String userId) { + + log.info("Get favorite categories for User: {}", userId); + FavoriteCategoryRes response = userService.getFavoriteCategories(UUID.fromString(userId)); + + return ApiResponse.success(response); + } } diff --git a/user-service/src/main/java/com/checkit/userservice/dto/BadgeAdminReq.java b/user-service/src/main/java/com/checkit/userservice/dto/BadgeAdminReq.java new file mode 100644 index 0000000..dad23e4 --- /dev/null +++ b/user-service/src/main/java/com/checkit/userservice/dto/BadgeAdminReq.java @@ -0,0 +1,22 @@ +package com.checkit.userservice.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class BadgeAdminReq { + + @NotBlank(message = "뱃지 이름은 필수입니다.") + private String name; + + @NotBlank(message = "뱃지 설명은 필수입니다.") + private String description; + + private String imageUrl; +} diff --git a/user-service/src/main/java/com/checkit/userservice/dto/BadgeAdminRes.java b/user-service/src/main/java/com/checkit/userservice/dto/BadgeAdminRes.java new file mode 100644 index 0000000..d7a89d6 --- /dev/null +++ b/user-service/src/main/java/com/checkit/userservice/dto/BadgeAdminRes.java @@ -0,0 +1,37 @@ +package com.checkit.userservice.dto; + +import com.checkit.userservice.entity.BadgeEntity; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.util.UUID; + +@Getter +@Builder +@AllArgsConstructor +public class BadgeAdminRes { + private Long badgeId; + private String name; + private String description; + private String imageUrl; + private UUID createdBy; + private OffsetDateTime createdAt; + private UUID updatedBy; + private OffsetDateTime updatedAt; + + public static BadgeAdminRes from(BadgeEntity badge) { + return BadgeAdminRes.builder() + .badgeId(badge.getBadgeId()) + .name(badge.getName()) + .description(badge.getDescription()) + .imageUrl(badge.getImageUrl()) + .createdBy(badge.getCreatedBy()) + .createdAt(badge.getCreatedAt()) + .updatedBy(badge.getUpdatedBy()) + .updatedAt(badge.getUpdatedAt()) + .build(); + } +} diff --git a/user-service/src/main/java/com/checkit/userservice/dto/BadgeDeleteRes.java b/user-service/src/main/java/com/checkit/userservice/dto/BadgeDeleteRes.java new file mode 100644 index 0000000..ff0cb64 --- /dev/null +++ b/user-service/src/main/java/com/checkit/userservice/dto/BadgeDeleteRes.java @@ -0,0 +1,26 @@ +package com.checkit.userservice.dto; + +import com.checkit.userservice.entity.BadgeEntity; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.time.OffsetDateTime; +import java.util.UUID; + +@Getter +@Builder +@AllArgsConstructor +public class BadgeDeleteRes { + private Long badgeId; + private UUID deletedBy; + private OffsetDateTime deletedAt; + + public static BadgeDeleteRes from(BadgeEntity badge) { + return BadgeDeleteRes.builder() + .badgeId(badge.getBadgeId()) + .deletedBy(badge.getDeletedBy()) + .deletedAt(badge.getDeletedAt()) + .build(); + } +} diff --git a/user-service/src/main/java/com/checkit/userservice/dto/FavoriteCategoryReq.java b/user-service/src/main/java/com/checkit/userservice/dto/FavoriteCategoryReq.java new file mode 100644 index 0000000..5a3c0e9 --- /dev/null +++ b/user-service/src/main/java/com/checkit/userservice/dto/FavoriteCategoryReq.java @@ -0,0 +1,17 @@ +package com.checkit.userservice.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@NoArgsConstructor +public class FavoriteCategoryReq { + + @JsonProperty("category_ids") + @Size(min = 1, max = 3, message = "관심 카테고리는 1개에서 3개 사이로 선택해주세요.") + private List categoryIds; +} diff --git a/user-service/src/main/java/com/checkit/userservice/dto/FavoriteCategoryRes.java b/user-service/src/main/java/com/checkit/userservice/dto/FavoriteCategoryRes.java new file mode 100644 index 0000000..2a76244 --- /dev/null +++ b/user-service/src/main/java/com/checkit/userservice/dto/FavoriteCategoryRes.java @@ -0,0 +1,52 @@ +package com.checkit.userservice.dto; + +import com.checkit.userservice.entity.UserEntity; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Getter +@Builder +@AllArgsConstructor +public class FavoriteCategoryRes { + private UUID userId; + private List favorites; + private int count; + private OffsetDateTime updatedAt; + + @Getter + @Builder + @AllArgsConstructor + public static class CategoryInfo { + private String id; + private String name; + } + + public static FavoriteCategoryRes from(UserEntity user) { + List favorites = new ArrayList<>(); + + + if (user.getFavCategory1() != null) { + favorites.add(new CategoryInfo(user.getFavCategory1().name(), user.getFavCategory1().getDisplayName())); + } + if (user.getFavCategory2() != null) { + favorites.add(new CategoryInfo(user.getFavCategory2().name(), user.getFavCategory2().getDisplayName())); + } + if (user.getFavCategory3() != null) { + favorites.add(new CategoryInfo(user.getFavCategory3().name(), user.getFavCategory3().getDisplayName())); + } + + return FavoriteCategoryRes.builder() + .userId(user.getUserId()) + .favorites(favorites) + .count(favorites.size()) + .updatedAt(user.getUpdatedAt()) + .build(); + } +} diff --git a/user-service/src/main/java/com/checkit/userservice/dto/MyBadgeListRes.java b/user-service/src/main/java/com/checkit/userservice/dto/MyBadgeListRes.java new file mode 100644 index 0000000..7c745cd --- /dev/null +++ b/user-service/src/main/java/com/checkit/userservice/dto/MyBadgeListRes.java @@ -0,0 +1,49 @@ +package com.checkit.userservice.dto; + +import com.checkit.userservice.entity.UserBadgeEntity; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.OffsetDateTime; +import java.util.List; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MyBadgeListRes { + + private List badges; + + public static MyBadgeListRes from(List badges) { + return MyBadgeListRes.builder() + .badges(badges) + .build(); + } + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class MyBadgeItemRes { + private Long badgeId; + private String name; + private String description; + private String imageUrl; + private boolean isEquipped; + private OffsetDateTime acquiredAt; + + public static MyBadgeItemRes from(UserBadgeEntity userBadge, String description, String imageUrl) { + return MyBadgeItemRes.builder() + .badgeId(userBadge.getBadgeId()) + .name(userBadge.getName()) + .description(description) + .imageUrl(imageUrl) + .isEquipped(userBadge.isEquipped()) + .acquiredAt(userBadge.getCreatedAt()) + .build(); + } + } +} \ No newline at end of file diff --git a/user-service/src/main/java/com/checkit/userservice/dto/OAuthAttributes.java b/user-service/src/main/java/com/checkit/userservice/dto/OAuthAttributes.java index 948d24e..dfa7663 100644 --- a/user-service/src/main/java/com/checkit/userservice/dto/OAuthAttributes.java +++ b/user-service/src/main/java/com/checkit/userservice/dto/OAuthAttributes.java @@ -5,6 +5,7 @@ import lombok.Getter; import java.util.Map; +import java.util.UUID; @Getter public class OAuthAttributes { @@ -26,8 +27,15 @@ public OAuthAttributes(Map attributes, String nameAttributeKey, this.providerUserId = providerUserId; } - //구글에서 오는 데이터 변환 메스드 + // 서비스(google, github)에 따라 분기 처리 public static OAuthAttributes of(String registrationId, String userNameAttribute, Map attributes) { + if ("github".equals(registrationId)) { + return ofGithub("id", attributes); // 깃허브는 고유키가 id + } + return ofGoogle(userNameAttribute, attributes); + } + + private static OAuthAttributes ofGoogle(String userNameAttribute, Map attributes) { return OAuthAttributes.builder() .name((String) attributes.get("name")) .email((String) attributes.get("email")) @@ -38,13 +46,26 @@ public static OAuthAttributes of(String registrationId, String userNameAttribute .build(); } + private static OAuthAttributes ofGithub(String userNameAttribute, Map attributes) { + return OAuthAttributes.builder() + .name((String) attributes.get("login")) // 깃허브는 login이 닉네임 + .email((String) attributes.get("email")) + .picture((String) attributes.get("avatar_url")) // 깃허브는 avatar_url이 프사 + .providerUserId(String.valueOf(attributes.get("id"))) // id가 숫자형이므로 String 변환 + .attributes(attributes) + .nameAttributeKey(userNameAttribute) + .build(); + } + //처음 가입할 때 UserEntity 생성 메서드 - public UserEntity toEntity() { + public UserEntity toEntity(UUID userId) { return UserEntity.builder() + .userId(userId) .name(name) .email(email) .profileImageUrl(picture) .nickname(name + "-" + providerUserId.substring(0, 5)) + .createdBy(userId) .build(); } } diff --git a/user-service/src/main/java/com/checkit/userservice/dto/SocialAccountRes.java b/user-service/src/main/java/com/checkit/userservice/dto/SocialAccountRes.java new file mode 100644 index 0000000..7cf489b --- /dev/null +++ b/user-service/src/main/java/com/checkit/userservice/dto/SocialAccountRes.java @@ -0,0 +1,11 @@ +package com.checkit.userservice.dto; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class SocialAccountRes { + private String provider; + private String email; +} diff --git a/user-service/src/main/java/com/checkit/userservice/dto/UserBadgeRes.java b/user-service/src/main/java/com/checkit/userservice/dto/UserBadgeRes.java new file mode 100644 index 0000000..0fe7f7c --- /dev/null +++ b/user-service/src/main/java/com/checkit/userservice/dto/UserBadgeRes.java @@ -0,0 +1,25 @@ +package com.checkit.userservice.dto; + +import com.checkit.userservice.entity.UserBadgeEntity; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.time.OffsetDateTime; + +@Getter +@Builder +@AllArgsConstructor +public class UserBadgeRes { + private Long badgeId; + private String name; + private OffsetDateTime createdAt; + + public static UserBadgeRes from(UserBadgeEntity userBadge) { + return UserBadgeRes.builder() + .badgeId(userBadge.getBadgeId()) + .name(userBadge.getName()) + .createdAt(userBadge.getCreatedAt()) + .build(); + } +} diff --git a/user-service/src/main/java/com/checkit/userservice/entity/BadgeEntity.java b/user-service/src/main/java/com/checkit/userservice/entity/BadgeEntity.java new file mode 100644 index 0000000..7a69bcf --- /dev/null +++ b/user-service/src/main/java/com/checkit/userservice/entity/BadgeEntity.java @@ -0,0 +1,43 @@ +package com.checkit.userservice.entity; + +import com.checkit.common.entity.AuditBaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; +import org.hibernate.annotations.SQLRestriction; + +import java.util.UUID; + +@Entity +@Table(name = "badge_all") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@SuperBuilder +@SQLRestriction("deleted_at IS NULL") +public class BadgeEntity extends AuditBaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long badgeId; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private String description; + + @Column(columnDefinition = "TEXT") + private String imageUrl; + + public void updateInfo(String name, String description, String imageUrl, UUID adminId) { + this.name = name; + this.description = description; + this.imageUrl = imageUrl; + this.setUpdater(adminId); // Audit 기록 + } + +} diff --git a/user-service/src/main/java/com/checkit/userservice/entity/SocialEntity.java b/user-service/src/main/java/com/checkit/userservice/entity/SocialEntity.java index 6228e23..397438f 100644 --- a/user-service/src/main/java/com/checkit/userservice/entity/SocialEntity.java +++ b/user-service/src/main/java/com/checkit/userservice/entity/SocialEntity.java @@ -1,7 +1,9 @@ package com.checkit.userservice.entity; +import com.checkit.common.entity.AuditBaseEntity; import jakarta.persistence.*; import lombok.*; +import lombok.experimental.SuperBuilder; import java.util.UUID; import java.time.LocalDateTime; @@ -9,11 +11,13 @@ @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@SuperBuilder @Table(name = "social_account", uniqueConstraints = { @UniqueConstraint(columnNames = {"provider", "provider_user_id"}) }) -public class SocialEntity { +public class SocialEntity extends AuditBaseEntity { @Id @GeneratedValue(strategy = GenerationType.AUTO) @@ -32,15 +36,7 @@ public class SocialEntity { private String email; + @Builder.Default @Column(name = "connected_at", nullable = false) - private LocalDateTime connectedAt; - - @Builder - public SocialEntity(UserEntity user, String provider, String providerUserId, String email) { - this.user = user; - this.provider = provider; - this.providerUserId = providerUserId; - this.email = email; - this.connectedAt = LocalDateTime.now(); - } + private LocalDateTime connectedAt = LocalDateTime.now(); } diff --git a/user-service/src/main/java/com/checkit/userservice/entity/UserBadgeEntity.java b/user-service/src/main/java/com/checkit/userservice/entity/UserBadgeEntity.java new file mode 100644 index 0000000..9aefb0c --- /dev/null +++ b/user-service/src/main/java/com/checkit/userservice/entity/UserBadgeEntity.java @@ -0,0 +1,41 @@ +package com.checkit.userservice.entity; + +import com.checkit.common.entity.AuditBaseEntity; +import jakarta.persistence.*; +import lombok.*; +import lombok.experimental.SuperBuilder; + +import java.util.UUID; + +@Entity +@Table(name = "badge_user", uniqueConstraints = { + @UniqueConstraint(columnNames = {"user_id", "badge_id"}) +}) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@SuperBuilder +public class UserBadgeEntity extends AuditBaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long badgeUserId; + + @Column(nullable = false) + private UUID userId; + + @Column(nullable = false) + private Long badgeId; + + @Column(nullable = false, length = 20) + private String name; + + @Column(nullable = false) + @Builder.Default + private boolean isEquipped = false; + + public void updateEquipped(boolean status, UUID userId) { + this.isEquipped = status; + this.setUpdater(userId); + } +} diff --git a/user-service/src/main/java/com/checkit/userservice/entity/UserEntity.java b/user-service/src/main/java/com/checkit/userservice/entity/UserEntity.java index 63e836a..fff4c68 100644 --- a/user-service/src/main/java/com/checkit/userservice/entity/UserEntity.java +++ b/user-service/src/main/java/com/checkit/userservice/entity/UserEntity.java @@ -1,17 +1,23 @@ package com.checkit.userservice.entity; +import com.checkit.common.entity.AuditBaseEntity; +import com.checkit.common.entity.CategoryType; import com.checkit.common.entity.UserRole; import jakarta.persistence.*; import lombok.*; +import lombok.experimental.SuperBuilder; import java.time.LocalDate; +import java.util.List; import java.util.UUID; @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@SuperBuilder @Table(name = "users") -public class UserEntity { +public class UserEntity extends AuditBaseEntity { @Id @GeneratedValue(strategy = GenerationType.UUID) @@ -36,27 +42,31 @@ public class UserEntity { @Column(name = "phone_number") private String phoneNumber; + @Builder.Default @Column(name = "is_active", nullable = false) private boolean isActive = true; @Column(name = "profile_image_url") private String profileImageUrl; + @Builder.Default @Enumerated(EnumType.STRING) @Column(nullable = false) private UserRole role = UserRole.USER; - @Builder - public UserEntity(String email, String name, String nickname, String profileImageUrl) { - this.email = email; - this.name = name; - this.nickname = nickname; - this.profileImageUrl = profileImageUrl; - this.role = (role != null) ? role : UserRole.USER; - this.isActive = true; - } + @Enumerated(EnumType.STRING) + @Column(name = "fav_category_1", length = 10) + private CategoryType favCategory1; + + @Enumerated(EnumType.STRING) + @Column(name = "fav_category_2", length = 10) + private CategoryType favCategory2; + + @Enumerated(EnumType.STRING) + @Column(name = "fav_category_3", length = 10) + private CategoryType favCategory3; - public void updateProfile(String nickname, LocalDate birthdate, String gender, String phoneNumber) { + public void updateProfile(String nickname, LocalDate birthdate, String gender, String phoneNumber, UUID actorId) { if (nickname != null && !nickname.isBlank()) { this.nickname = nickname; } @@ -69,14 +79,31 @@ public void updateProfile(String nickname, LocalDate birthdate, String gender, S if (phoneNumber != null && !phoneNumber.isBlank()) { this.phoneNumber = phoneNumber; } + + this.setUpdater(actorId); } - public void deactivate() { + public void deactivate(UUID actorId) { this.isActive = false; + this.setUpdater(actorId); } - public void activate() { + public void activate(UUID actorId) { this.isActive = true; + this.setUpdater(actorId); + } + + public void withdraw(UUID actorId) { + this.isActive = false; + this.softDelete(actorId); + } + + public void updateFavoriteCategories(List categories, UUID actorId) { + this.favCategory1 = (categories.size() >= 1) ? categories.get(0) : null; + this.favCategory2 = (categories.size() >= 2) ? categories.get(1) : null; + this.favCategory3 = (categories.size() >= 3) ? categories.get(2) : null; + + this.setUpdater(actorId); } } diff --git a/user-service/src/main/java/com/checkit/userservice/repository/BadgeRepository.java b/user-service/src/main/java/com/checkit/userservice/repository/BadgeRepository.java new file mode 100644 index 0000000..1808982 --- /dev/null +++ b/user-service/src/main/java/com/checkit/userservice/repository/BadgeRepository.java @@ -0,0 +1,16 @@ +package com.checkit.userservice.repository; + +import com.checkit.userservice.entity.BadgeEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface BadgeRepository extends JpaRepository { + + boolean existsByName(String name); + + boolean existsByNameAndBadgeIdNot(String name, Long badgeId); + + boolean existsByNameAndBadgeIdNotAndDeletedAtIsNull(String name, Long badgeId); + +} diff --git a/user-service/src/main/java/com/checkit/userservice/repository/SocialRepository.java b/user-service/src/main/java/com/checkit/userservice/repository/SocialRepository.java index f7282df..e118817 100644 --- a/user-service/src/main/java/com/checkit/userservice/repository/SocialRepository.java +++ b/user-service/src/main/java/com/checkit/userservice/repository/SocialRepository.java @@ -4,6 +4,7 @@ import com.checkit.userservice.entity.UserEntity; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; import java.util.Optional; import java.util.UUID; @@ -11,4 +12,8 @@ public interface SocialRepository extends JpaRepository { Optional findByProviderAndProviderUserId(String provider, String providerUserId); Optional findByUser(UserEntity user); + + Optional findByUserAndProvider(UserEntity user, String provider); + + List findAllByUser(UserEntity user); } diff --git a/user-service/src/main/java/com/checkit/userservice/repository/UserBadgeRepository.java b/user-service/src/main/java/com/checkit/userservice/repository/UserBadgeRepository.java new file mode 100644 index 0000000..3e7b32e --- /dev/null +++ b/user-service/src/main/java/com/checkit/userservice/repository/UserBadgeRepository.java @@ -0,0 +1,19 @@ +package com.checkit.userservice.repository; + +import com.checkit.userservice.entity.UserBadgeEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface UserBadgeRepository extends JpaRepository { + + boolean existsByUserIdAndBadgeId(UUID userId, Long badgeId); + + Optional findByUserIdAndIsEquippedTrue(UUID userId); + + Optional findByUserIdAndBadgeId(UUID userId, Long badgeId); + + List findAllByUserId(UUID userId); +} diff --git a/user-service/src/main/java/com/checkit/userservice/service/BadgeService.java b/user-service/src/main/java/com/checkit/userservice/service/BadgeService.java new file mode 100644 index 0000000..5676824 --- /dev/null +++ b/user-service/src/main/java/com/checkit/userservice/service/BadgeService.java @@ -0,0 +1,165 @@ +package com.checkit.userservice.service; + +import com.checkit.userservice.dto.*; +import com.checkit.userservice.entity.BadgeEntity; +import com.checkit.userservice.entity.UserBadgeEntity; +import com.checkit.userservice.repository.BadgeRepository; +import com.checkit.userservice.repository.UserBadgeRepository; +import org.springframework.transaction.annotation.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class BadgeService { + + private final BadgeRepository badgeRepository; + private final UserBadgeRepository userBadgeRepository; + + @Transactional + public BadgeAdminRes createBadge(UUID userId, BadgeAdminReq request) { + if (badgeRepository.existsByName(request.getName())) { + throw new RuntimeException("이미 존재하는 뱃지 이름입니다: " + request.getName()); + } + + BadgeEntity badge = BadgeEntity.builder() + .name(request.getName()) + .description(request.getDescription()) + .imageUrl(request.getImageUrl()) + .build(); + + badge.setCreator(userId); + + BadgeEntity savedBadge = badgeRepository.save(badge); + return BadgeAdminRes.from(savedBadge); + } + + + public List getAllBadges() { + return badgeRepository.findAll().stream() + .map(BadgeAdminRes::from) + .collect(Collectors.toList()); + } + + @Transactional + public BadgeAdminRes updateBadge(Long badgeId, UUID userId, BadgeAdminReq request) { + + BadgeEntity badge = badgeRepository.findById(badgeId) + .orElseThrow(() -> new RuntimeException("해당 뱃지를 찾을 수 없습니다. ID: " + badgeId)); + + if (badgeRepository.existsByNameAndBadgeIdNot(request.getName(), badgeId)) { + throw new RuntimeException("이미 존재하는 뱃지 이름입니다: " + request.getName()); + } + + badge.updateInfo( + request.getName(), + request.getDescription(), + request.getImageUrl(), + userId + ); + + return BadgeAdminRes.from(badge); + } + + @Transactional + public BadgeDeleteRes deleteBadge(Long badgeId, UUID userId) { + BadgeEntity badge = badgeRepository.findById(badgeId) + .orElseThrow(() -> new RuntimeException("해당 뱃지를 찾을 수 없습니다.")); + + badge.softDelete(userId); + + return BadgeDeleteRes.from(badge); + } + + @Transactional + public UserBadgeRes checkAndGrantBadge(UUID userId) { + // 임시 하드코딩 카운트 + int certCount = 30; + + Long targetBadgeId = determineBadgeIdByCount(certCount); + if (targetBadgeId == null) return null; + + Optional existingBadge = userBadgeRepository.findByUserIdAndBadgeId(userId, targetBadgeId); + if (existingBadge.isPresent()) { + return UserBadgeRes.from(existingBadge.get()); + } + + BadgeEntity badge = badgeRepository.findById(targetBadgeId) + .orElseThrow(() -> new RuntimeException("뱃지를 찾을 수 없습니다.")); + + UserBadgeEntity userBadge = UserBadgeEntity.builder() + .userId(userId) + .badgeId(badge.getBadgeId()) + .name(badge.getName()) + .isEquipped(false) + .build(); + + userBadge.setCreator(userId); + UserBadgeEntity saved = userBadgeRepository.save(userBadge); + + return UserBadgeRes.from(saved); + } + + private Long determineBadgeIdByCount(int count) { + return switch (count) { + case 7 -> 1L; + case 14 -> 2L; + case 21 -> 3L; + case 30 -> 4L; + default -> null; + }; + } + + @Transactional(readOnly = true) + public MyBadgeListRes getMyBadges(UUID userId) { + + List userBadges = userBadgeRepository.findAllByUserId(userId); + + List badgeIds = userBadges.stream() + .map(UserBadgeEntity::getBadgeId) + .toList(); + + Map badgeInfoMap = badgeRepository.findAllById(badgeIds).stream() + .collect(Collectors.toMap(BadgeEntity::getBadgeId, b -> b)); + + List badgeItems = userBadges.stream() + .map(ub -> { + BadgeEntity original = badgeInfoMap.get(ub.getBadgeId()); + return MyBadgeListRes.MyBadgeItemRes.from( + ub, + original != null ? original.getDescription() : "", + original != null ? original.getImageUrl() : "" + ); + }) + .toList(); + + return MyBadgeListRes.from(badgeItems); + } + + @Transactional + public MyBadgeListRes.MyBadgeItemRes equipBadge(UUID userId, Long badgeUserId) { + + userBadgeRepository.findByUserIdAndIsEquippedTrue(userId) + .ifPresent(badge -> badge.updateEquipped(false, userId)); + + UserBadgeEntity targetBadge = userBadgeRepository.findById(badgeUserId) + .orElseThrow(() -> new RuntimeException("보유하지 않은 뱃지입니다.")); + + if (!targetBadge.getUserId().equals(userId)) { + throw new RuntimeException("본인의 뱃지만 장착할 수 있습니다."); + } + + targetBadge.updateEquipped(true, userId); + + BadgeEntity original = badgeRepository.findById(targetBadge.getBadgeId()) + .orElseThrow(() -> new RuntimeException("원본 뱃지 정보를 찾을 수 없습니다.")); + + return MyBadgeListRes.MyBadgeItemRes.from(targetBadge, original.getDescription(), original.getImageUrl()); + } +} diff --git a/user-service/src/main/java/com/checkit/userservice/service/OAuth2UserService.java b/user-service/src/main/java/com/checkit/userservice/service/OAuth2UserService.java index 9a38408..f1d6480 100644 --- a/user-service/src/main/java/com/checkit/userservice/service/OAuth2UserService.java +++ b/user-service/src/main/java/com/checkit/userservice/service/OAuth2UserService.java @@ -13,11 +13,16 @@ import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.oauth2.core.user.DefaultOAuth2User; import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.authentication.AnonymousAuthenticationToken; + import org.springframework.stereotype.Service; import java.util.Collections; import java.util.HashMap; import java.util.Map; +import java.util.UUID; @Service @RequiredArgsConstructor @@ -56,23 +61,51 @@ private UserEntity saveOrUpdate(OAuthAttributes attributes, String provider) { return socialRepository.findByProviderAndProviderUserId(provider, attributes.getProviderUserId()) .map(social -> { UserEntity user = social.getUser(); - - if (!user.isActive()) { - user.activate(); - } - + if (user.isDeleted()) throw new OAuth2AuthenticationException("탈퇴 처리된 계정입니다."); + if (!user.isActive()) user.activate(user.getUserId()); return user; }) .orElseGet(() -> { - UserEntity newUser = userRepository.save(attributes.toEntity()); + UserEntity existingUser = userRepository.findByEmail(attributes.getEmail()) + .orElse(null); + + if (existingUser != null) { + socialRepository.save(SocialEntity.builder() + .user(existingUser) + .provider(provider) + .providerUserId(attributes.getProviderUserId()) + .email(attributes.getEmail()) + .createdBy(existingUser.getUserId()) + .build()); + return existingUser; + } + + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth != null && auth.isAuthenticated() && !(auth instanceof AnonymousAuthenticationToken)) { + + OAuth2User loginUser = (OAuth2User) auth.getPrincipal(); + UUID currentUserId = (UUID) loginUser.getAttributes().get("userId"); + UserEntity currentUser = userRepository.findById(currentUserId).orElseThrow(); + + socialRepository.save(SocialEntity.builder() + .user(currentUser) + .provider(provider) + .providerUserId(attributes.getProviderUserId()) + .email(attributes.getEmail()) + .createdBy(currentUser.getUserId()) + .build()); + return currentUser; + } + UUID newUserId = UUID.randomUUID(); + UserEntity newUser = userRepository.save(attributes.toEntity(newUserId)); socialRepository.save(SocialEntity.builder() .user(newUser) .provider(provider) .providerUserId(attributes.getProviderUserId()) .email(attributes.getEmail()) + .createdBy(newUser.getUserId()) .build()); - return newUser; }); } diff --git a/user-service/src/main/java/com/checkit/userservice/service/UserService.java b/user-service/src/main/java/com/checkit/userservice/service/UserService.java index d89fd36..0aa6820 100644 --- a/user-service/src/main/java/com/checkit/userservice/service/UserService.java +++ b/user-service/src/main/java/com/checkit/userservice/service/UserService.java @@ -1,5 +1,6 @@ package com.checkit.userservice.service; +import com.checkit.common.entity.CategoryType; import com.checkit.common.entity.UserRole; import com.checkit.userservice.dto.*; import com.checkit.userservice.entity.SocialEntity; @@ -14,6 +15,7 @@ import org.springframework.data.redis.core.StringRedisTemplate; import java.time.Duration; +import java.util.List; import java.util.UUID; @@ -78,11 +80,16 @@ public UserUpdateRes updateUserInfo(UUID userId, UserUpdateReq request) { UserEntity user = userRepository.findById(userId) .orElseThrow(() -> new EntityNotFoundException("사용자를 찾을 수 없습니다.")); + if (user.isDeleted()) { + throw new EntityNotFoundException("삭제되거나 존재하지 않는 사용자입니다."); + } + user.updateProfile( request.getNickname(), request.getBirthdate(), request.getGender(), - request.getPhoneNumber() + request.getPhoneNumber(), + userId ); return UserUpdateRes.from(user); @@ -92,7 +99,25 @@ public UserUpdateRes updateUserInfo(UUID userId, UserUpdateReq request) { public void deactivateUser(UUID userId) { UserEntity user = userRepository.findById(userId) .orElseThrow(() -> new EntityNotFoundException("User not found")); - user.deactivate(); // isActive = false + user.deactivate(userId); // isActive = false + } + + @Transactional + public void withdrawUser(UUID userId) { + UserEntity user = userRepository.findById(userId) + .orElseThrow(() -> new EntityNotFoundException("사용자를 찾을 수 없습니다.")); + + if (user.isDeleted()) { + throw new IllegalStateException("이미 탈퇴 처리된 사용자입니다."); + } + + user.withdraw(userId); + + socialRepository.findByUser(user).ifPresent(social -> { + social.softDelete(userId); + }); + + redisTemplate.delete("RT:" + userId.toString()); } @Transactional(readOnly = true) @@ -104,4 +129,66 @@ public NickNameCheckRes checkNicknameAvailability(String nickName) { .nickName(nickName) .build(); } + + @Transactional + public FavoriteCategoryRes updateFavorites(UUID userId, FavoriteCategoryReq request) { + UserEntity user = userRepository.findById(userId) + .orElseThrow(() -> new EntityNotFoundException("사용자를 찾을 수 없습니다.")); + + List categories = request.getCategoryIds().stream() + .map(id -> { + try { + return CategoryType.valueOf(id.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new RuntimeException("유효하지 않은 카테고리 ID입니다: " + id); + } + }) + .toList(); + + user.updateFavoriteCategories(categories, userId); + + return FavoriteCategoryRes.from(user); + } + + @Transactional(readOnly = true) + public List getSocialAccounts(UUID userId) { + UserEntity user = userRepository.findById(userId) + .orElseThrow(() -> new EntityNotFoundException("사용자를 찾을 수 없습니다.")); + + return socialRepository.findAllByUser(user).stream() + .map(social -> SocialAccountRes.builder() + .provider(social.getProvider()) + .email(social.getEmail()) + .build()) + .toList(); + } + + @Transactional + public void unlinkSocial(UUID userId, String provider) { + UserEntity user = userRepository.findById(userId) + .orElseThrow(() -> new EntityNotFoundException("사용자를 찾을 수 없습니다.")); + + List socialAccounts = socialRepository.findAllByUser(user).stream() + .filter(s -> !s.isDeleted()) + .toList(); + + if (socialAccounts.size() <= 1) { + throw new IllegalStateException("최소 하나 이상의 소셜 계정이 연동되어 있어야 합니다."); + } + + SocialEntity targetSocial = socialAccounts.stream() + .filter(s -> s.getProvider().equalsIgnoreCase(provider)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("연동되지 않은 소셜 계정입니다.")); + + targetSocial.softDelete(userId); + } + + @Transactional(readOnly = true) + public FavoriteCategoryRes getFavoriteCategories(UUID userId) { + UserEntity user = userRepository.findById(userId) + .orElseThrow(() -> new EntityNotFoundException("사용자를 찾을 수 없습니다.")); + + return FavoriteCategoryRes.from(user); + } } diff --git a/user-service/src/main/resources/application.yml b/user-service/src/main/resources/application.yml index 510c3eb..3c7e085 100644 --- a/user-service/src/main/resources/application.yml +++ b/user-service/src/main/resources/application.yml @@ -26,9 +26,21 @@ spring: google: client-id: ${GOOGLE_CLIENT_ID} client-secret: ${GOOGLE_CLIENT_SECRET} - scope: - - profile - - email + scope: + - profile + - email + github: + client-id: ${GITHUB_CLIENT_ID} + client-secret: ${GITHUB_CLIENT_SECRET} + scope: + - user:email + - read:user + provider: + github: + authorization-uri: https://github.com/login/oauth/authorize + token-uri: https://github.com/login/oauth/access_token + user-info-uri: https://api.github.com/user + user-name-attribute: id eureka: