diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index e821a7a..512cef4 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -1,3 +1,14 @@ +<<<<<<< HEAD +name: Deploy to EC2 (Self-contained) + +on: + push: + branches: + - main + +jobs: + build-and-deploy: +======= name: Deploy to EKS on: @@ -14,6 +25,7 @@ env: jobs: deploy: name: Build and Deploy +>>>>>>> 12d0498008b5251ba390633e46e4cbf9f1235fd7 runs-on: ubuntu-latest steps: - name: Checkout source code @@ -31,6 +43,34 @@ jobs: - name: Build with Gradle run: ./gradlew build -x test +<<<<<<< HEAD + - name: Transfer JAR to EC2 + uses: appleboy/scp-action@v0.1.4 + with: + host: ${{ secrets.SSH_HOST }} + username: ${{ secrets.SSH_USER }} + key: ${{ secrets.SSH_KEY }} + source: "build/libs/*.jar" + target: "/home/${{ secrets.SSH_USER }}/app" + strip_components: 2 + + - name: Deploy on EC2 + uses: appleboy/ssh-action@v0.1.10 + with: + host: ${{ secrets.SSH_HOST }} + username: ${{ secrets.SSH_USER }} + key: ${{ secrets.SSH_KEY }} + script: | + APP_DIR="/home/${{ secrets.SSH_USER }}/app" + LOG_DIR="${APP_DIR}/logs" + DEPLOY_SCRIPT="$APP_DIR/deploy.sh" + + mkdir -p $LOG_DIR + + echo "> 배포 스크립트 실행" + chmod +x $DEPLOY_SCRIPT + $DEPLOY_SCRIPT +======= - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v2 with: @@ -74,4 +114,5 @@ jobs: kubectl apply -f . # 배포 확인 - kubectl rollout status deployment/joycrew-backend-deployment \ No newline at end of file + kubectl rollout status deployment/joycrew-backend-deployment +>>>>>>> 12d0498008b5251ba390633e46e4cbf9f1235fd7 diff --git a/build.gradle b/build.gradle index 591dfb5..14317f0 100644 --- a/build.gradle +++ b/build.gradle @@ -38,6 +38,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-mail' + // Added from merged branch implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'io.micrometer:micrometer-registry-prometheus' @@ -52,14 +53,14 @@ dependencies { // AWS SDK for S3 and Secrets Manager Integration implementation platform('io.awspring.cloud:spring-cloud-aws-dependencies:3.1.1') implementation 'io.awspring.cloud:spring-cloud-aws-starter-secrets-manager' - implementation(platform("software.amazon.awssdk:bom:2.21.46")) + implementation platform("software.amazon.awssdk:bom:2.21.46") implementation 'software.amazon.awssdk:s3' implementation 'org.springframework.cloud:spring-cloud-starter-bootstrap' // Database Drivers - runtimeOnly 'com.mysql:mysql-connector-j' // For production - runtimeOnly 'com.h2database:h2' // For development + runtimeOnly 'com.mysql:mysql-connector-j' + runtimeOnly 'com.h2database:h2' // Developer Tools compileOnly 'org.projectlombok:lombok' @@ -76,7 +77,7 @@ bootJar { enabled = true } -// Disables the plain JAR generation as the bootJar is sufficient. +// Disables the plain JAR generation. jar { enabled = false } diff --git a/infra/eks/cluster.yml b/infra/eks/cluster.yml index cb293ec..d2cb858 100644 --- a/infra/eks/cluster.yml +++ b/infra/eks/cluster.yml @@ -1,3 +1,10 @@ +<<<<<<< HEAD +# =================================================================== +# EKS Cluster Configuration for JoyCrew Backend +# This version is configured to use your EXISTING VPC and subnets. +# =================================================================== +======= +>>>>>>> 12d0498008b5251ba390633e46e4cbf9f1235fd7 apiVersion: eksctl.io/v1alpha5 kind: ClusterConfig @@ -6,6 +13,24 @@ metadata: region: ap-northeast-2 version: "1.29" +<<<<<<< HEAD +# Enable IAM Roles for Service Accounts (IRSA) +iam: + withOIDC: true + +vpc: + id: "vpc-067968d8c7f0de694" + subnets: + # The worker nodes will be placed in the private subnets. + private: + ap-northeast-2b: { id: "subnet-0b1c4ed704fe8809c" } + ap-northeast-2d: { id: "subnet-00394d1c845cc1187" } + public: + ap-northeast-2a: { id: "subnet-03e9e38db1457d819" } + ap-northeast-2c: { id: "subnet-0283caa043409b846" } + +# Configuration for managed node groups +======= vpc: id: "vpc-028bab1adc7ebda69" subnets: @@ -29,15 +54,59 @@ iam: wellKnownPolicies: awsLoadBalancerController: true +>>>>>>> 12d0498008b5251ba390633e46e4cbf9f1235fd7 managedNodeGroups: - name: app-workers instanceType: t3.small desiredCapacity: 2 minSize: 1 +<<<<<<< HEAD + maxSize: 4 + # 'privateNetworking' is not needed when subnets are specified above. + # eksctl will automatically use the private subnets for the nodes. +======= maxSize: 3 privateNetworking: false # Public 서브넷 사용 +>>>>>>> 12d0498008b5251ba390633e46e4cbf9f1235fd7 ssh: allow: true publicKeyName: joycrew-deploy-key labels: role: application +<<<<<<< HEAD + environment: production + volumeSize: 20 + volumeType: gp3 + iam: + attachPolicyARNs: + - arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy + - arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy + - arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly + - arn:aws:iam::aws:policy/CloudWatchAgentServerPolicy + +# Manage essential EKS add-ons +addons: + - name: vpc-cni + version: latest + configurationValues: |- + enableNetworkPolicy: "true" + - name: coredns + version: latest + - name: kube-proxy + version: latest + - name: aws-ebs-csi-driver + version: latest + wellKnownPolicies: + ebsCSIController: true + +# CloudWatch logging configuration +cloudWatch: + clusterLogging: + enableTypes: + - api + - audit + - authenticator + - controllerManager + - scheduler +======= +>>>>>>> 12d0498008b5251ba390633e46e4cbf9f1235fd7 diff --git a/k8s/deployment.yml b/k8s/deployment.yml index f432337..db0298c 100644 --- a/k8s/deployment.yml +++ b/k8s/deployment.yml @@ -1,3 +1,7 @@ +<<<<<<< HEAD +# Defines the desired state for the application pods. +======= +>>>>>>> 12d0498008b5251ba390633e46e4cbf9f1235fd7 apiVersion: apps/v1 kind: Deployment metadata: @@ -5,6 +9,10 @@ metadata: labels: app: joycrew-backend spec: +<<<<<<< HEAD + # Runs 2 replicas of the pod for high availability. +======= +>>>>>>> 12d0498008b5251ba390633e46e4cbf9f1235fd7 replicas: 2 selector: matchLabels: @@ -14,6 +22,30 @@ spec: labels: app: joycrew-backend spec: +<<<<<<< HEAD + # Use a dedicated service account for the application pods. + # This is crucial for securely granting AWS permissions via IRSA. + serviceAccountName: joycrew-service-account + containers: + - name: backend-container + image: "566105751077.dkr.ecr.ap-northeast-2.amazonaws.com/joycrew-backend:a761bbb-1754808557" + ports: + - containerPort: 8082 + env: + - name: SPRING_DATASOURCE_HIKARI_CONNECTION_TIMEOUT + value: "20000" # 20 seconds + - name: SPRING_DATASOURCE_HIKARI_MAXIMUM_POOL_SIZE + value: "5" + # Define resource requests and limits for better scheduling and stability. + resources: + requests: + memory: "128Mi" + cpu: "150m" + limits: + memory: "1Gi" + cpu: "500m" + # Readiness probe checks if the container is ready to accept traffic. +======= # cluster.yml에서 생성한 서비스 계정 이름과 일치해야 함 serviceAccountName: joycrew-service-account containers: @@ -43,15 +75,27 @@ spec: memory: "1Gi" cpu: "1000m" +>>>>>>> 12d0498008b5251ba390633e46e4cbf9f1235fd7 readinessProbe: httpGet: path: / port: 8082 +<<<<<<< HEAD + initialDelaySeconds: 30 + periodSeconds: 10 + # Liveness probe checks if the container is still running correctly. + # If it fails, Kubernetes will restart the container. +======= initialDelaySeconds: 40 periodSeconds: 10 +>>>>>>> 12d0498008b5251ba390633e46e4cbf9f1235fd7 livenessProbe: httpGet: path: / port: 8082 +<<<<<<< HEAD + initialDelaySeconds: 60 +======= initialDelaySeconds: 90 +>>>>>>> 12d0498008b5251ba390633e46e4cbf9f1235fd7 periodSeconds: 20 \ No newline at end of file diff --git a/k8s/ingress.yml b/k8s/ingress.yml index c730c27..cd693f5 100644 --- a/k8s/ingress.yml +++ b/k8s/ingress.yml @@ -1,14 +1,35 @@ +<<<<<<< HEAD +# /k8s/ingress.yml +# Defines how external traffic reaches the service inside the cluster. +# This configuration uses the AWS Load Balancer Controller to provision an ALB. +======= +>>>>>>> 12d0498008b5251ba390633e46e4cbf9f1235fd7 apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: joycrew-backend-ingress annotations: +<<<<<<< HEAD + # Creates an internet-facing load balancer. alb.ingress.kubernetes.io/scheme: internet-facing + # Routes traffic directly to Pod IPs for better performance. alb.ingress.kubernetes.io/target-type: ip + # Specifies the ARN of the ACM certificate for HTTPS. alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:ap-northeast-2:566105751077:certificate/d5930928-10dc-4f4d-9cc4-67770a94522b + # Configures the ALB to listen on port 443 (HTTPS). alb.ingress.kubernetes.io/listen-ports: '[{"HTTPS":443}]' + # Redirects HTTP traffic to HTTPS. alb.ingress.kubernetes.io/ssl-redirect: '443' spec: + # Specifies that the 'alb' ingress controller should handle this Ingress. (Modern way) +======= + alb.ingress.kubernetes.io/scheme: internet-facing + alb.ingress.kubernetes.io/target-type: ip + alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:ap-northeast-2:566105751077:certificate/d5930928-10dc-4f4d-9cc4-67770a94522b + alb.ingress.kubernetes.io/listen-ports: '[{"HTTPS":443}]' + alb.ingress.kubernetes.io/ssl-redirect: '443' +spec: +>>>>>>> 12d0498008b5251ba390633e46e4cbf9f1235fd7 ingressClassName: alb rules: - host: api.joycrew.co.kr @@ -18,6 +39,13 @@ spec: pathType: Prefix backend: service: +<<<<<<< HEAD + # Forwards traffic to the 'joycrew-backend-service'. + name: joycrew-backend-service + port: + # Forwards to the service's port 80. +======= name: joycrew-backend-service port: +>>>>>>> 12d0498008b5251ba390633e46e4cbf9f1235fd7 number: 80 \ No newline at end of file diff --git a/k8s/serviceaccount.yml b/k8s/serviceaccount.yml new file mode 100644 index 0000000..3866247 --- /dev/null +++ b/k8s/serviceaccount.yml @@ -0,0 +1,9 @@ +# Defines a dedicated identity for the application pods within the cluster. +apiVersion: v1 +kind: ServiceAccount +metadata: + name: joycrew-service-account + # Annotations for IAM Roles for Service Accounts (IRSA). + # This links the Kubernetes service account to an AWS IAM role. + annotations: + eks.amazonaws.com/role-arn: "arn:aws:iam::566105751077:role/eksctl-joycrew-cluster-addon-iamserviceaccoun-Role1-65tzCzsgly5T" \ No newline at end of file diff --git a/src/main/java/com/joycrew/backend/controller/AccountLookupController.java b/src/main/java/com/joycrew/backend/controller/AccountLookupController.java index 6e728cf..f2d4311 100644 --- a/src/main/java/com/joycrew/backend/controller/AccountLookupController.java +++ b/src/main/java/com/joycrew/backend/controller/AccountLookupController.java @@ -5,7 +5,7 @@ import com.joycrew.backend.service.KycTokenService; import com.joycrew.backend.util.EmailMasker; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; // 로그 확인용 추가 +import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -13,7 +13,7 @@ import java.util.Objects; import java.util.stream.Stream; -@Slf4j // 로그를 찍어보시려면 추가하세요 +@Slf4j @RestController @RequestMapping("/accounts/emails") @RequiredArgsConstructor @@ -29,12 +29,13 @@ public ResponseEntity emailsByPhone( // 1. 토큰에서 폰번호 추출 (하이픈이 있을 수도 있음) String rawPhone = kycTokenService.validateAndExtractPhone(kycToken); - // 🚨 [수정 핵심] 숫자 이외의 문자(하이픈 등) 제거 -> "01044907174" + // 숫자 이외의 문자(하이픈 등) 제거 -> "01044907174" 형태로 정규화 String cleanPhone = rawPhone.replaceAll("\\D", ""); log.info("Email Lookup Request - Raw: {}, Clean: {}", rawPhone, cleanPhone); // 2. 정제된 번호(cleanPhone)로 DB 조회 + // EmployeeRepository.findByPhoneNumber(cleanPhone) 가 List 라고 가정 List emails = employeeRepo.findByPhoneNumber(cleanPhone).stream() .flatMap(e -> Stream.of(e.getEmail(), e.getPersonalEmail())) .filter(Objects::nonNull) @@ -52,4 +53,4 @@ public ResponseEntity emailsByPhone( message )); } -} \ No newline at end of file +} diff --git a/src/main/java/com/joycrew/backend/controller/AuthController.java b/src/main/java/com/joycrew/backend/controller/AuthController.java index 1825dd6..c956771 100644 --- a/src/main/java/com/joycrew/backend/controller/AuthController.java +++ b/src/main/java/com/joycrew/backend/controller/AuthController.java @@ -2,14 +2,13 @@ import com.joycrew.backend.dto.*; import com.joycrew.backend.service.AuthService; +import com.joycrew.backend.web.CookieUtil; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpHeaders; -import org.springframework.http.ResponseCookie; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -25,24 +24,21 @@ public class AuthController { @ApiResponse(responseCode = "200", description = "Login successful") @ApiResponse(responseCode = "401", description = "Authentication failed") @PostMapping("/login") - public ResponseEntity login(@RequestBody @Valid LoginRequest request) { - // 1. 서비스에서 인증 처리 및 토큰 발급 - LoginResponse loginResponse = authService.login(request); + public ResponseEntity login(@RequestBody @Valid LoginRequest request, + HttpServletRequest httpReq) { + LoginResponse body = authService.login(request); - // 2. deploy/eks 브랜치의 코드 (httpOnly=false 명시) - ResponseCookie cookie = ResponseCookie.from("accessToken", loginResponse.accessToken()) - .path("/") - .sameSite("None") - .secure(true) - .httpOnly(false) - .domain(".joycrew.co.kr") - .maxAge(60 * 60) - .build(); + // 운영/개발에 맞게 설정 (지금은 prod 기준) + boolean secure = true; // HTTPS 환경에서는 true 고정 권장 + long maxAgeSec = 24 * 60 * 60; // access 토큰 유효시간과 동일하게 (1일) + String cookieDomain = ".joycrew.co.kr"; // 공통 도메인 + + // 쿠키에 JWT 심기 (JC_AUTH 라는 이름으로 CookieUtil에서 생성) + var cookie = CookieUtil.authCookie(body.accessToken(), cookieDomain, maxAgeSec, secure); - // 3. 헤더에 쿠키를 포함하여 응답 반환 return ResponseEntity.ok() - .header(HttpHeaders.SET_COOKIE, cookie.toString()) - .body(loginResponse); + .header("Set-Cookie", cookie.toString()) + .body(body); } @Operation(summary = "Logout") @@ -50,35 +46,45 @@ public ResponseEntity login(@RequestBody @Valid LoginRequest requ public ResponseEntity logout(HttpServletRequest request) { authService.logout(request); - // 로그아웃 시 쿠키 삭제 - ResponseCookie deleteCookie = ResponseCookie.from("accessToken", "") - .path("/") - .sameSite("None") - .secure(true) - .httpOnly(false) - .domain(".joycrew.co.kr") - .maxAge(0) // 시간을 0으로 설정하여 즉시 삭제 - .build(); + boolean secure = true; + String cookieDomain = ".joycrew.co.kr"; + // JC_AUTH 쿠키 제거 + var clear = CookieUtil.clearAuth(cookieDomain, secure); return ResponseEntity.ok() - .header(HttpHeaders.SET_COOKIE, deleteCookie.toString()) + .header("Set-Cookie", clear.toString()) .body(new SuccessResponse("You have been logged out.")); } - @Operation(summary = "Request password reset (sends email)", description = "Sends a magic link to the user's email to reset the password.") - @ApiResponse(responseCode = "200", description = "The request was processed successfully (the response is the same regardless of whether the email exists).") + @Operation( + summary = "Request password reset (sends email)", + description = "Sends a magic link to the user's email to reset the password." + ) + @ApiResponse( + responseCode = "200", + description = "The request was processed successfully (the response is the same regardless of whether the email exists)." + ) @PostMapping("/password-reset/request") - public ResponseEntity requestPasswordReset(@RequestBody @Valid PasswordResetRequest request) { + public ResponseEntity requestPasswordReset( + @RequestBody @Valid PasswordResetRequest request + ) { authService.requestPasswordReset(request.email()); - return ResponseEntity.ok(new SuccessResponse("A password reset email has been requested. Please check your email.")); + return ResponseEntity.ok( + new SuccessResponse("A password reset email has been requested. Please check your email.") + ); } - @Operation(summary = "Confirm password reset", description = "Finalizes the password change using the token from the email and the new password.") + @Operation( + summary = "Confirm password reset", + description = "Finalizes the password change using the token from the email and the new password." + ) @ApiResponse(responseCode = "200", description = "Password changed successfully.") @ApiResponse(responseCode = "400", description = "The token is invalid or has expired.") @PostMapping("/password-reset/confirm") - public ResponseEntity confirmPasswordReset(@RequestBody @Valid PasswordResetConfirmRequest request) { + public ResponseEntity confirmPasswordReset( + @RequestBody @Valid PasswordResetConfirmRequest request + ) { authService.confirmPasswordReset(request.token(), request.newPassword()); return ResponseEntity.ok(new SuccessResponse("Password changed successfully.")); } -} \ No newline at end of file +} diff --git a/src/main/java/com/joycrew/backend/controller/SubscriptionController.java b/src/main/java/com/joycrew/backend/controller/SubscriptionController.java new file mode 100644 index 0000000..5c86cb7 --- /dev/null +++ b/src/main/java/com/joycrew/backend/controller/SubscriptionController.java @@ -0,0 +1,59 @@ +package com.joycrew.backend.controller; + +import com.joycrew.backend.dto.IssueBillingKeyRequest; +import com.joycrew.backend.dto.SubscriptionPaymentHistoryResponse; +import com.joycrew.backend.dto.SubscriptionSummaryResponse; +import com.joycrew.backend.dto.SuccessResponse; +import com.joycrew.backend.entity.enums.PaymentStatus; +import com.joycrew.backend.service.SubscriptionBillingKeyAppService; +import com.joycrew.backend.service.SubscriptionPaymentQueryService; +import com.joycrew.backend.service.SubscriptionQueryService; +import com.joycrew.backend.tenant.Tenant; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/admin/subscription") +@RequiredArgsConstructor +public class SubscriptionController { + + private final SubscriptionBillingKeyAppService billingKeyAppService; + private final SubscriptionPaymentQueryService paymentQueryService; + private final SubscriptionQueryService subscriptionQueryService; + + /** ✅ authKey만 받음 */ + @PostMapping("/billing-key/issue") + public ResponseEntity issueBillingKey(@RequestBody IssueBillingKeyRequest req) { + Long companyId = Tenant.id(); + billingKeyAppService.issueAndSaveBillingKey(companyId, req.authKey()); + return ResponseEntity.ok(new SuccessResponse("BillingKey issued and auto-renew enabled")); + } + + /** 구독 해지(자동결제 OFF) */ + @PostMapping("/auto/disable") + public ResponseEntity disableAutoRenew() { + Long companyId = Tenant.id(); + billingKeyAppService.disableAutoRenew(companyId); + return ResponseEntity.ok(new SuccessResponse("Auto-renew disabled")); + } + + /** ✅ 결제 이력 조회(관리자 페이지) */ + @GetMapping("/payments") + public ResponseEntity getPayments( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(required = false) PaymentStatus status + ) { + Long companyId = Tenant.id(); + return ResponseEntity.ok(paymentQueryService.getHistory(companyId, page, size, status)); + } + + @GetMapping("/summary") + public ResponseEntity getSubscriptionSummary() { + Long companyId = Tenant.id(); + return ResponseEntity.ok( + subscriptionQueryService.getSubscriptionSummary(companyId) + ); + } +} diff --git a/src/main/java/com/joycrew/backend/dto/IssueBillingKeyRequest.java b/src/main/java/com/joycrew/backend/dto/IssueBillingKeyRequest.java new file mode 100644 index 0000000..90628c3 --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/IssueBillingKeyRequest.java @@ -0,0 +1,5 @@ +package com.joycrew.backend.dto; + +public record IssueBillingKeyRequest( + String authKey +) {} diff --git a/src/main/java/com/joycrew/backend/dto/LoginResponse.java b/src/main/java/com/joycrew/backend/dto/LoginResponse.java index a943b53..7e605ef 100644 --- a/src/main/java/com/joycrew/backend/dto/LoginResponse.java +++ b/src/main/java/com/joycrew/backend/dto/LoginResponse.java @@ -22,9 +22,22 @@ public record LoginResponse( @Schema(description = "URL of the profile image") String profileImageUrl, @Schema(description = "Tenant subdomain (e.g., 'alko', 'BDL')") - String subdomain + String subdomain, + @Schema(description = "Whether billing method registration is required after login") + boolean billingRequired ) { - public static LoginResponse fail(String message) { - return new LoginResponse(null, message, null, null, null, null, null, null, null); - } + public static LoginResponse fail(String message) { + return new LoginResponse( + null, // accessToken + message, // message + null, // userId + null, // name + null, // email + null, // role + null, // totalPoint + null, // profileImageUrl + null, // subdomain + false // billingRequired (로그인 실패면 의미 없으니 false) + ); + } } diff --git a/src/main/java/com/joycrew/backend/dto/PagedResponse.java b/src/main/java/com/joycrew/backend/dto/PagedResponse.java new file mode 100644 index 0000000..94799a0 --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/PagedResponse.java @@ -0,0 +1,12 @@ +package com.joycrew.backend.dto; + +import java.util.List; + +public record PagedResponse( + List content, + int page, + int size, + long totalElements, + int totalPages, + boolean last +) {} diff --git a/src/main/java/com/joycrew/backend/dto/SubscriptionPaymentHistoryItem.java b/src/main/java/com/joycrew/backend/dto/SubscriptionPaymentHistoryItem.java new file mode 100644 index 0000000..c1e06cb --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/SubscriptionPaymentHistoryItem.java @@ -0,0 +1,36 @@ +package com.joycrew.backend.dto; + +import com.joycrew.backend.entity.SubscriptionPayment; +import com.joycrew.backend.entity.enums.PaymentStatus; + +import java.time.LocalDateTime; + +public record SubscriptionPaymentHistoryItem( + Long id, + String orderId, + Long amount, + PaymentStatus status, + LocalDateTime requestedAt, + LocalDateTime approvedAt, + LocalDateTime periodStartAt, + LocalDateTime periodEndAt, + String tossPaymentKey, + String failCode, + String failMessage +) { + public static SubscriptionPaymentHistoryItem from(SubscriptionPayment p) { + return new SubscriptionPaymentHistoryItem( + p.getId(), + p.getOrderId(), + Long.valueOf(p.getAmount()), + p.getStatus(), + p.getRequestedAt(), + p.getApprovedAt(), + p.getPeriodStartAt(), + p.getPeriodEndAt(), + p.getTossPaymentKey(), + p.getFailCode(), + p.getFailMessage() + ); + } +} diff --git a/src/main/java/com/joycrew/backend/dto/SubscriptionPaymentHistoryResponse.java b/src/main/java/com/joycrew/backend/dto/SubscriptionPaymentHistoryResponse.java new file mode 100644 index 0000000..6dfca89 --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/SubscriptionPaymentHistoryResponse.java @@ -0,0 +1,11 @@ +package com.joycrew.backend.dto; + +import java.util.List; + +public record SubscriptionPaymentHistoryResponse( + List items, + int page, + int size, + long totalElements, + int totalPages +) {} diff --git a/src/main/java/com/joycrew/backend/dto/SubscriptionSummaryResponse.java b/src/main/java/com/joycrew/backend/dto/SubscriptionSummaryResponse.java new file mode 100644 index 0000000..1396ecf --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/SubscriptionSummaryResponse.java @@ -0,0 +1,10 @@ +package com.joycrew.backend.dto; + +import java.time.LocalDateTime; + +public record SubscriptionSummaryResponse( + LocalDateTime subscriptionStartAt, // 가입일 + LocalDateTime nextBillingAt, // 결제 예정일 + boolean autoRenew, + String status +) {} diff --git a/src/main/java/com/joycrew/backend/dto/toss/TossIssueBillingKeyResponse.java b/src/main/java/com/joycrew/backend/dto/toss/TossIssueBillingKeyResponse.java new file mode 100644 index 0000000..6f5b585 --- /dev/null +++ b/src/main/java/com/joycrew/backend/dto/toss/TossIssueBillingKeyResponse.java @@ -0,0 +1,11 @@ +package com.joycrew.backend.dto.toss; + +public record TossIssueBillingKeyResponse( + String mId, + String customerKey, + String billingKey, + String authenticatedAt, + String cardCompany, + String cardNumber, + String cardType +) {} diff --git a/src/main/java/com/joycrew/backend/entity/Company.java b/src/main/java/com/joycrew/backend/entity/Company.java index b70e563..59418d2 100644 --- a/src/main/java/com/joycrew/backend/entity/Company.java +++ b/src/main/java/com/joycrew/backend/entity/Company.java @@ -39,47 +39,107 @@ public class Company { @OneToMany(mappedBy = "company", cascade = CascadeType.ALL, orphanRemoval = true) private List adminAccessList = new ArrayList<>(); + // ========================= + // Subscription fields + // ========================= + + @Column(name = "subscription_end_at") + private LocalDateTime subscriptionEndAt; + + private boolean autoRenew; + + @Column(name = "toss_billing_key") + private String tossBillingKey; + + @Column(name = "toss_customer_key") + private String tossCustomerKey; + private LocalDateTime createdAt; private LocalDateTime updatedAt; - public void changeName(String newCompanyName) { - this.companyName = newCompanyName; - } - - public void changeStatus(String newStatus) { - this.status = newStatus; - } + // ----------------------- + // Budget Logic (기존) + // ----------------------- public void addBudget(double amount) { - if (amount < 0) { - throw new IllegalArgumentException("Budget amount cannot be negative."); - } + if (amount < 0) throw new IllegalArgumentException("Budget amount cannot be negative."); + if (this.totalCompanyBalance == null) this.totalCompanyBalance = 0.0; this.totalCompanyBalance += amount; } public void spendBudget(double amount) { - if (amount < 0) { - throw new IllegalArgumentException("Amount to spend cannot be negative."); - } + if (amount < 0) throw new IllegalArgumentException("Amount to spend cannot be negative."); + if (this.totalCompanyBalance == null) this.totalCompanyBalance = 0.0; if (this.totalCompanyBalance < amount) { throw new InsufficientPointsException("The company does not have enough budget to distribute the points."); } this.totalCompanyBalance -= amount; } + public Double getTotalCompanyBalance() { + return this.totalCompanyBalance == null ? 0.0 : this.totalCompanyBalance; + } + + // ----------------------- + // Subscription Logic + // ----------------------- + + /** billingKey 저장 + autoRenew ON */ + public void registerBillingKeyAndEnableAutoRenew(String billingKey, String customerKey) { + this.autoRenew = true; + this.tossBillingKey = billingKey; + this.tossCustomerKey = customerKey; + } + + /** ✅ 최초 카드등록 시점 기준으로 만료일을 now+1개월로 세팅 (이미 있으면 절대 덮어쓰지 않음) */ + public void initializeSubscriptionEndAtIfFirstTime() { + if (this.subscriptionEndAt == null) { + this.subscriptionEndAt = LocalDateTime.now().plusMonths(1); + this.status = "ACTIVE"; + } + } + + /** 자동갱신 해지 */ + public void disableAutoRenew() { + this.autoRenew = false; + // 보안상 권장: 해지하면 키도 제거 (원하면 아래 2줄 삭제) + this.tossBillingKey = null; + this.tossCustomerKey = null; + } + + public boolean canAutoBill() { + return autoRenew && tossBillingKey != null && subscriptionEndAt != null; + } + + /** 결제 성공 시 구독 연장 */ + public void extendSubscription(int months) { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime base = + (subscriptionEndAt != null && subscriptionEndAt.isAfter(now)) + ? subscriptionEndAt + : now; + subscriptionEndAt = base.plusMonths(months); + status = "ACTIVE"; + } + + public void markFailed() { + status = "PAYMENT_FAILED"; + } + @PrePersist protected void onCreate() { this.createdAt = this.updatedAt = LocalDateTime.now(); - if (this.totalCompanyBalance == null) { - this.totalCompanyBalance = 0.0; - } - if (this.status == null) { - this.status = "ACTIVE"; - } + if (this.totalCompanyBalance == null) this.totalCompanyBalance = 0.0; + if (this.status == null) this.status = "ACTIVE"; } @PreUpdate protected void onUpdate() { this.updatedAt = LocalDateTime.now(); } -} \ No newline at end of file + + public boolean isBillingReady() { + // 카드 등록이 끝났다는 의미: billingKey + customerKey 존재 + return this.tossBillingKey != null && this.tossCustomerKey != null && !this.tossBillingKey.isBlank(); + } +} diff --git a/src/main/java/com/joycrew/backend/entity/SubscriptionPayment.java b/src/main/java/com/joycrew/backend/entity/SubscriptionPayment.java new file mode 100644 index 0000000..f930437 --- /dev/null +++ b/src/main/java/com/joycrew/backend/entity/SubscriptionPayment.java @@ -0,0 +1,113 @@ +package com.joycrew.backend.entity; + +import com.joycrew.backend.entity.enums.PaymentStatus; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Table( + name = "subscription_payment", + indexes = { + @Index(name = "idx_subpay_company_requested", columnList = "company_id, requested_at"), + @Index(name = "idx_subpay_company_paid", columnList = "company_id, approved_at"), + @Index(name = "idx_subpay_order", columnList = "order_id", unique = true) + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class SubscriptionPayment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** 결제 대상 회사 */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "company_id", nullable = false) + private Company company; + + /** 우리쪽 주문 ID (멱등성/조회 기준) */ + @Column(name = "order_id", nullable = false, unique = true, length = 80) + private String orderId; + + /** 토스 paymentKey (성공 시 응답에 존재) */ + @Column(name = "toss_payment_key", length = 200) + private String tossPaymentKey; + + /** 금액 */ + @Column(nullable = false) + private long amount; + + /** 이번 결제가 커버하는 구독 기간 */ + @Column(name = "period_start_at", nullable = false) + private LocalDateTime periodStartAt; + + @Column(name = "period_end_at", nullable = false) + private LocalDateTime periodEndAt; + + /** 결제 요청 시각 */ + @Column(name = "requested_at", nullable = false) + private LocalDateTime requestedAt; + + /** 승인 시각 */ + @Column(name = "approved_at") + private LocalDateTime approvedAt; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private PaymentStatus status; + + /** 실패 사유 */ + @Column(name = "fail_reason", length = 500) + private String failReason; + + /** 토스 응답 raw */ + @Lob + @Column(name = "raw_response") + private String rawResponse; + + public static SubscriptionPayment pending(Company company, + String orderId, + long amount, + LocalDateTime periodStartAt, + LocalDateTime periodEndAt, + LocalDateTime requestedAt) { + SubscriptionPayment p = new SubscriptionPayment(); + p.company = company; + p.orderId = orderId; + p.amount = amount; + p.periodStartAt = periodStartAt; + p.periodEndAt = periodEndAt; + p.requestedAt = requestedAt; + p.status = PaymentStatus.PENDING; + return p; + } + + public void markSuccess(String tossPaymentKey, LocalDateTime approvedAt, String rawResponse) { + this.status = PaymentStatus.SUCCESS; + this.tossPaymentKey = tossPaymentKey; + this.approvedAt = approvedAt; + this.failReason = null; + this.rawResponse = rawResponse; + } + + public void markFailed(String reason, String rawResponse) { + this.status = PaymentStatus.FAILED; + this.failReason = reason; + this.rawResponse = rawResponse; + } + + // ====================== + // DTO/프론트 호환 getter + // ====================== + + public String getFailCode() { + return null; // 지금은 코드 저장 안 하므로 null (추후 확장) + } + + public String getFailMessage() { + return this.failReason; + } +} diff --git a/src/main/java/com/joycrew/backend/entity/Wallet.java b/src/main/java/com/joycrew/backend/entity/Wallet.java index 8aa4984..34ccfe1 100644 --- a/src/main/java/com/joycrew/backend/entity/Wallet.java +++ b/src/main/java/com/joycrew/backend/entity/Wallet.java @@ -3,6 +3,7 @@ import com.joycrew.backend.exception.InsufficientPointsException; import jakarta.persistence.*; import lombok.*; + import java.time.LocalDateTime; @Entity @@ -19,10 +20,10 @@ public class Wallet { private Employee employee; @Column(nullable = false) - private Integer balance; // 의미: 총 잔액 + private Integer balance; // 의미: 총 잔액 @Column(nullable = false) - private Integer giftablePoint; // 의미: 총 잔액 중 선물 가능한 한도 + private Integer giftablePoint; // 의미: 총 잔액 중 선물 가능한 한도 @Column(nullable = false) private LocalDateTime createdAt; @@ -49,21 +50,22 @@ public void addPoints(int amount) { } /** - * [NEW] 2. P2P 선물 받기 - * 동료에게 받은 선물은 '총 잔액(balance)'만 올리고 '선물 한도(giftablePoint)'는 건드리지 않습니다. - * (즉, '구매 전용' 포인트가 됨) + * [2. P2P 선물 받기] + * 동료에게 받은 선물은 '총 잔액(balance)'만 올리고 + * '선물 한도(giftablePoint)'는 건드리지 않습니다. + * -> 선물로 받은 포인트는 "구매 전용" 느낌. */ public void receiveGiftPoints(int amount) { if (amount < 0) { throw new IllegalArgumentException("Points to receive cannot be negative."); } this.balance += amount; - // giftablePoint는 올리지 않음 + // giftablePoint는 증가시키지 않음 } /** * [3. P2P 선물 하기] - * 선물을 하면 총 잔액과 선물 한도 둘 다 차감합니다. (기존 로직 유지) + * 선물을 하면 총 잔액과 선물 한도 둘 다 차감합니다. */ public void spendGiftablePoints(int amount) { if (amount < 0) { @@ -73,7 +75,7 @@ public void spendGiftablePoints(int amount) { throw new InsufficientPointsException("Insufficient giftable points."); } if (this.balance < amount) { - // (방어 코드) 총액이 선물 한도보다 적은 비정상적 상황 + // 방어 코드: 총 잔액이 선물 한도보다 적은 비정상 상황 throw new InsufficientPointsException("Insufficient balance."); } this.balance -= amount; @@ -81,8 +83,9 @@ public void spendGiftablePoints(int amount) { } /** - * [FIXED] 4. 기프티콘 구매 - * 구매 시 '총 잔액(balance)'만 차감합니다. '선물 한도(giftablePoint)'는 건드리지 않습니다. + * [4. 기프티콘/스토어 구매] + * 구매 시 '총 잔액(balance)'만 차감합니다. + * '선물 한도(giftablePoint)'는 건드리지 않습니다. */ public void purchaseWithPoints(int amount) { if (amount < 0) { @@ -92,14 +95,12 @@ public void purchaseWithPoints(int amount) { throw new InsufficientPointsException("Insufficient points for purchase."); } this.balance -= amount; - - // [수정됨] 선물 한도를 차감하는 로직 삭제 - // this.giftablePoint = Math.max(0, this.giftablePoint - amount); + // giftablePoint는 변화 없음 } /** * [5. 관리자 회수] - * 관리자가 회수할 때는 총 잔액과 선물 한도 둘 다 차감합니다. + * 관리자가 포인트를 회수할 때는 총 잔액과 선물 한도 둘 다 차감합니다. */ public void revokePoints(int amount) { if (amount < 0) { @@ -113,17 +114,18 @@ public void revokePoints(int amount) { } /** - * [FIXED] 6. 구매 환불 - * 구매가 실패하여 환불될 때는 '총 잔액(balance)'만 다시 채워줍니다. + * [6. 구매 환불] + * 구매 실패/취소로 인한 환불은 '총 잔액(balance)'만 다시 채워줍니다. + * 선물 한도는 변하지 않습니다. */ public void refundPoints(int amount) { if (amount < 0) { throw new IllegalArgumentException("Refund amount cannot be negative."); } this.balance += amount; + // giftablePoint는 변화 없음 } - // --- (PrePersist, PreUpdate는 기존과 동일) --- @PrePersist protected void onCreate() { this.createdAt = this.updatedAt = LocalDateTime.now(); @@ -135,4 +137,4 @@ protected void onCreate() { protected void onUpdate() { this.updatedAt = LocalDateTime.now(); } -} \ No newline at end of file +} diff --git a/src/main/java/com/joycrew/backend/entity/enums/PaymentStatus.java b/src/main/java/com/joycrew/backend/entity/enums/PaymentStatus.java new file mode 100644 index 0000000..2b96ab9 --- /dev/null +++ b/src/main/java/com/joycrew/backend/entity/enums/PaymentStatus.java @@ -0,0 +1,7 @@ +package com.joycrew.backend.entity.enums; + +public enum PaymentStatus { + PENDING, + SUCCESS, + FAILED +} diff --git a/src/main/java/com/joycrew/backend/exception/BillingRequiredException.java b/src/main/java/com/joycrew/backend/exception/BillingRequiredException.java new file mode 100644 index 0000000..f150604 --- /dev/null +++ b/src/main/java/com/joycrew/backend/exception/BillingRequiredException.java @@ -0,0 +1,17 @@ +package com.joycrew.backend.exception; + +import org.springframework.http.HttpStatus; + +public class BillingRequiredException extends RuntimeException { + public BillingRequiredException() { + super("Billing method registration required."); + } + + public HttpStatus status() { + return HttpStatus.FORBIDDEN; + } + + public String code() { + return "BILLING_REQUIRED"; + } +} diff --git a/src/main/java/com/joycrew/backend/exception/GlobalExceptionHandler.java b/src/main/java/com/joycrew/backend/exception/GlobalExceptionHandler.java index d8c7465..ea2c8d0 100644 --- a/src/main/java/com/joycrew/backend/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/joycrew/backend/exception/GlobalExceptionHandler.java @@ -1,58 +1,127 @@ -package com.joycrew.backend.exception; +package com.joycrew.backend.web; import com.joycrew.backend.dto.ErrorResponse; +import com.joycrew.backend.exception.BillingRequiredException; +import com.joycrew.backend.exception.InsufficientPointsException; +import com.joycrew.backend.exception.UserNotFoundException; import jakarta.servlet.http.HttpServletRequest; -import org.springframework.http.HttpStatus; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.security.authentication.BadCredentialsException; -import org.springframework.security.core.userdetails.UsernameNotFoundException; // import 추가 -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; import java.time.LocalDateTime; -import java.util.NoSuchElementException; +@Slf4j @RestControllerAdvice public class GlobalExceptionHandler { - @ExceptionHandler(InsufficientPointsException.class) - @ResponseStatus(HttpStatus.BAD_REQUEST) - public ErrorResponse handleInsufficientPoints(InsufficientPointsException ex, HttpServletRequest req) { - return new ErrorResponse("INSUFFICIENT_POINTS", ex.getMessage(), LocalDateTime.now(), req.getRequestURI()); + // ------------------------- + // 400 - Validation + // ------------------------- + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidation( + MethodArgumentNotValidException e, + HttpServletRequest req + ) { + String msg = e.getBindingResult().getAllErrors().isEmpty() + ? "Validation failed" + : e.getBindingResult().getAllErrors().get(0).getDefaultMessage(); + + return ResponseEntity.badRequest().body(error( + "VALIDATION_ERROR", + msg, + req + )); } - @ExceptionHandler(NoSuchElementException.class) - @ResponseStatus(HttpStatus.NOT_FOUND) - public ErrorResponse handleNoSuchElement(NoSuchElementException ex, HttpServletRequest req) { - return new ErrorResponse("NOT_FOUND", ex.getMessage(), LocalDateTime.now(), req.getRequestURI()); + // ------------------------- + // 401 - Auth + // ------------------------- + @ExceptionHandler(BadCredentialsException.class) + public ResponseEntity handleBadCredentials( + BadCredentialsException e, + HttpServletRequest req + ) { + return ResponseEntity.status(401).body(error( + "AUTH_FAILED", + "Invalid email or password.", + req + )); } - @ExceptionHandler(IllegalStateException.class) - @ResponseStatus(HttpStatus.BAD_REQUEST) - public ErrorResponse handleIllegalState(IllegalStateException ex, HttpServletRequest req) { - return new ErrorResponse("ORDER_CANNOT_CANCEL", ex.getMessage(), LocalDateTime.now(), req.getRequestURI()); + // ------------------------- + // 403 - Billing required + // ------------------------- + @ExceptionHandler(BillingRequiredException.class) + public ResponseEntity handleBillingRequired( + BillingRequiredException e, + HttpServletRequest req + ) { + return ResponseEntity.status(403).body(error( + "BILLING_REQUIRED", + e.getMessage(), + req + )); } - // '가입되지 않은 이메일' 처리 - @ExceptionHandler(UsernameNotFoundException.class) - public ResponseEntity handleUsernameNotFound(UsernameNotFoundException ex, HttpServletRequest req) { - ErrorResponse errorResponse = new ErrorResponse( - "AUTH_002", // 가입되지 않은 이메일 - "이메일 또는 비밀번호가 잘못되었습니다.", // 보안을 위해 메시지는 동일하게 유지 - LocalDateTime.now(), - req.getRequestURI() - ); - return new ResponseEntity<>(errorResponse, HttpStatus.UNAUTHORIZED); + // ------------------------- + // 404 - Not Found + // ------------------------- + @ExceptionHandler(UserNotFoundException.class) + public ResponseEntity handleUserNotFound( + UserNotFoundException e, + HttpServletRequest req + ) { + return ResponseEntity.status(404).body(error( + "USER_NOT_FOUND", + e.getMessage(), + req + )); } - // '비밀번호 불일치' 처리 - @ExceptionHandler(BadCredentialsException.class) - public ResponseEntity handleAuthenticationFailed(BadCredentialsException ex, HttpServletRequest req) { - ErrorResponse errorResponse = new ErrorResponse( - "AUTH_003", // 비밀번호 불일치 - "이메일 또는 비밀번호가 잘못되었습니다.", // 보안을 위해 메시지는 동일하게 유지 + // ------------------------- + // 409 / 400 - Business rule + // ------------------------- + @ExceptionHandler(InsufficientPointsException.class) + public ResponseEntity handleInsufficientPoints( + InsufficientPointsException e, + HttpServletRequest req + ) { + // 포인트 부족은 보통 409(충돌)로 주기도 하고 400으로 주기도 함. + // 정책 확정 전이면 409 추천. + return ResponseEntity.status(409).body(error( + "INSUFFICIENT_POINTS", + e.getMessage(), + req + )); + } + + // ------------------------- + // 500 - Fallback + // ------------------------- + @ExceptionHandler(Exception.class) + public ResponseEntity handleUnknown( + Exception e, + HttpServletRequest req + ) { + log.error("[UNHANDLED] path={}, msg={}", req.getRequestURI(), e.getMessage(), e); + + return ResponseEntity.status(500).body(error( + "INTERNAL_SERVER_ERROR", + "An unexpected error occurred.", + req + )); + } + + private ErrorResponse error(String code, String message, HttpServletRequest req) { + return new ErrorResponse( + code, + message, LocalDateTime.now(), req.getRequestURI() ); - return new ResponseEntity<>(errorResponse, HttpStatus.UNAUTHORIZED); } -} \ No newline at end of file +} diff --git a/src/main/java/com/joycrew/backend/repository/CompanyRepository.java b/src/main/java/com/joycrew/backend/repository/CompanyRepository.java index e00083e..fa2e088 100644 --- a/src/main/java/com/joycrew/backend/repository/CompanyRepository.java +++ b/src/main/java/com/joycrew/backend/repository/CompanyRepository.java @@ -2,9 +2,21 @@ import com.joycrew.backend.entity.Company; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import java.time.LocalDateTime; +import java.util.List; import java.util.Optional; public interface CompanyRepository extends JpaRepository { + Optional findByCompanyName(String name); -} \ No newline at end of file + @Query(""" + SELECT c FROM Company c + WHERE c.autoRenew = true + AND c.tossBillingKey IS NOT NULL + AND c.subscriptionEndAt IS NOT NULL + AND c.subscriptionEndAt <= :now + """) + List findAutoBillingTargets(LocalDateTime now); +} diff --git a/src/main/java/com/joycrew/backend/repository/DepartmentRepository.java b/src/main/java/com/joycrew/backend/repository/DepartmentRepository.java index 9196339..3e00fac 100644 --- a/src/main/java/com/joycrew/backend/repository/DepartmentRepository.java +++ b/src/main/java/com/joycrew/backend/repository/DepartmentRepository.java @@ -10,7 +10,9 @@ import java.util.Optional; public interface DepartmentRepository extends JpaRepository { + List findAllByCompanyCompanyId(Long companyId); + Optional findByCompanyAndName(Company company, String name); Page findByCompanyCompanyId(Long companyId, Pageable pageable); @@ -20,4 +22,4 @@ public interface DepartmentRepository extends JpaRepository { Optional findByCompanyCompanyIdAndName(Long companyId, String name); boolean existsByCompanyCompanyIdAndName(Long companyId, String name); -} \ No newline at end of file +} diff --git a/src/main/java/com/joycrew/backend/repository/EmployeeRepository.java b/src/main/java/com/joycrew/backend/repository/EmployeeRepository.java index 914a310..ba5694d 100644 --- a/src/main/java/com/joycrew/backend/repository/EmployeeRepository.java +++ b/src/main/java/com/joycrew/backend/repository/EmployeeRepository.java @@ -31,6 +31,19 @@ public interface EmployeeRepository extends JpaRepository { """) Optional findByIdWithCompany(@Param("id") Long id); + @Query(""" + SELECT e + FROM Employee e + JOIN FETCH e.company c + WHERE e.employeeId = :employeeId + AND c.companyId = :companyId + """) + Optional findByIdWithCompanyAndCompanyId(Long employeeId, Long companyId); + + List findAllByCompanyCompanyIdAndEmployeeIdIn(Long companyId, List employeeIds); + + List findAllByCompanyCompanyId(Long companyId); + List findByPhoneNumber(String phoneNumber); Page findByCompanyCompanyIdAndStatus(Long companyId, String status, Pageable pageable); @@ -40,4 +53,14 @@ public interface EmployeeRepository extends JpaRepository { Optional findByCompanyCompanyIdAndEmployeeId(Long companyId, Long employeeId); boolean existsByCompanyCompanyIdAndEmail(Long companyId, String email); + + @Query(""" + SELECT e + FROM Employee e + JOIN FETCH e.company c + WHERE c.companyId = :companyId + AND e.employeeId = :employeeId + """) + Optional findByCompanyCompanyIdAndEmployeeIdWithCompany(Long companyId, Long employeeId); + } diff --git a/src/main/java/com/joycrew/backend/repository/SubscriptionPaymentRepository.java b/src/main/java/com/joycrew/backend/repository/SubscriptionPaymentRepository.java new file mode 100644 index 0000000..cf874f9 --- /dev/null +++ b/src/main/java/com/joycrew/backend/repository/SubscriptionPaymentRepository.java @@ -0,0 +1,20 @@ +package com.joycrew.backend.repository; + +import com.joycrew.backend.entity.SubscriptionPayment; +import com.joycrew.backend.entity.enums.PaymentStatus; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface SubscriptionPaymentRepository extends JpaRepository { + + Optional findByOrderId(String orderId); + + boolean existsByOrderId(String orderId); + + Page findByCompany_CompanyIdOrderByRequestedAtDesc(Long companyId, Pageable pageable); + + Page findByCompany_CompanyIdAndStatusOrderByRequestedAtDesc(Long companyId, PaymentStatus status, Pageable pageable); +} diff --git a/src/main/java/com/joycrew/backend/repository/WalletRepository.java b/src/main/java/com/joycrew/backend/repository/WalletRepository.java index 03d35eb..ae759f4 100644 --- a/src/main/java/com/joycrew/backend/repository/WalletRepository.java +++ b/src/main/java/com/joycrew/backend/repository/WalletRepository.java @@ -9,13 +9,14 @@ import java.util.Optional; public interface WalletRepository extends JpaRepository { - // ⭐️ 비관적 락 적용 (동시성 문제 해결) + + // ⭐ 비관적 락 적용 (동시성 문제 해결용) @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("select w from Wallet w where w.employee.employeeId = :employeeId") Optional findByEmployee_EmployeeIdForUpdate(Long employeeId); - // 일반 조회는 기존 메서드 유지 + // 일반 조회는 락 없이 사용 Optional findByEmployee_EmployeeId(Long employeeId); Optional findByEmployeeCompanyCompanyIdAndEmployeeEmployeeId(Long companyId, Long employeeId); -} \ No newline at end of file +} diff --git a/src/main/java/com/joycrew/backend/security/JwtAuthenticationFilter.java b/src/main/java/com/joycrew/backend/security/JwtAuthenticationFilter.java index 5a4e7e7..6aabc44 100644 --- a/src/main/java/com/joycrew/backend/security/JwtAuthenticationFilter.java +++ b/src/main/java/com/joycrew/backend/security/JwtAuthenticationFilter.java @@ -30,47 +30,80 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final UserDetailsService userDetailsService; private final AntPathMatcher pathMatcher = new AntPathMatcher(); + // JWT 토큰 검사를 건너뛸 경로 목록 (SecurityConfig와 일치하도록 유지) private static final List EXCLUDE_URLS = Arrays.asList( - "/", "/error", "/actuator/health", "/h2-console/**", - "/api/auth/**", "/api/kyc/phone/**", "/accounts/emails/by-phone", - "/api/catalog/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html" + "/", + "/error", + "/actuator/health", + "/actuator/prometheus", + "/h2-console/**", + "/api/auth/**", + "/api/kyc/phone/**", + "/accounts/emails/by-phone", + "/api/catalog/**", + "/v3/api-docs/**", + "/swagger-ui/**", + "/swagger-ui.html" ); + /** + * Authorization 헤더 또는 JC_AUTH 쿠키에서 토큰을 추출한다. + */ private String resolveToken(HttpServletRequest request) { + // 1순위: Authorization 헤더 String authHeader = request.getHeader("Authorization"); if (authHeader != null && authHeader.startsWith("Bearer ")) { return authHeader.substring(7); } + + // 2순위: 쿠키(JC_AUTH)에서 추출 Cookie[] cookies = request.getCookies(); if (cookies != null) { for (Cookie c : cookies) { - if ("accessToken".equals(c.getName())) { + if ("JC_AUTH".equals(c.getName())) { String value = c.getValue(); - if (value != null && !value.isBlank()) return value; + if (value != null && !value.isBlank()) { + return value; + } } } } + return null; } private boolean isExcluded(String path) { - return EXCLUDE_URLS.stream().anyMatch(excludeUrl -> pathMatcher.match(excludeUrl, path)); + return EXCLUDE_URLS.stream() + .anyMatch(excludeUrl -> pathMatcher.match(excludeUrl, path)); } @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { String path = request.getServletPath(); - if (request.getMethod().equalsIgnoreCase("OPTIONS") || isExcluded(path)) { + // CORS Preflight 요청(OPTIONS)은 항상 통과 + if (request.getMethod().equalsIgnoreCase("OPTIONS")) { filterChain.doFilter(request, response); return; } + // 화이트리스트 경로는 JWT 검사 스킵 + if (isExcluded(path)) { + log.debug("JWT Filter bypassed for path: {}", path); + filterChain.doFilter(request, response); + return; + } + + log.debug("===== JWT Filter Executed for path: {} =====", path); + + // 헤더 또는 쿠키에서 토큰 추출 String token = resolveToken(request); if (token == null || token.isBlank()) { - // log.warn("No JWT token found for protected path: {}", path); + log.warn("No JWT token found for protected path: {}", path); filterChain.doFilter(request, response); return; } @@ -91,26 +124,31 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse UserDetails userDetails = this.userDetailsService.loadUserByUsername(email); UsernamePasswordAuthenticationToken authentication = - new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); + new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities() + ); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); + log.info("User '{}' authenticated successfully.", email); - // 🚨 [수정됨] 메서드 이름 변경: getCurrentTenant -> get, setCurrentTenant -> set - if (TenantContext.get() == null && userDetails instanceof UserPrincipal) { - UserPrincipal principal = (UserPrincipal) userDetails; + // 🔁 도메인 필터가 테넌트를 못 설정한 경우, JWT에서 유저 회사 기준으로 fallback 설정 + if (TenantContext.get() == null && userDetails instanceof UserPrincipal principal) { Long userCompanyId = principal.getEmployee().getCompany().getCompanyId(); - - TenantContext.set(userCompanyId); // 👈 여기 수정됨 + TenantContext.set(userCompanyId); tenantSetByJwt = true; log.debug("Tenant fallback: Set to Company ID {} from JWT UserPrincipal", userCompanyId); } } + filterChain.doFilter(request, response); } finally { + // 이 필터에서 테넌트 컨텍스트를 세팅한 경우만 책임지고 정리 if (tenantSetByJwt) { TenantContext.clear(); } } } -} \ No newline at end of file +} diff --git a/src/main/java/com/joycrew/backend/service/AdminDashboardService.java b/src/main/java/com/joycrew/backend/service/AdminDashboardService.java index 0ce0a50..055c03d 100644 --- a/src/main/java/com/joycrew/backend/service/AdminDashboardService.java +++ b/src/main/java/com/joycrew/backend/service/AdminDashboardService.java @@ -7,6 +7,7 @@ import com.joycrew.backend.exception.UserNotFoundException; import com.joycrew.backend.repository.EmployeeRepository; import com.joycrew.backend.repository.WalletRepository; +import com.joycrew.backend.tenant.Tenant; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -19,30 +20,27 @@ public class AdminDashboardService { private final EmployeeRepository employeeRepository; private final WalletRepository walletRepository; - /** - * Fetches both the company's total point budget and the admin's personal wallet balance. - * @param adminEmail The email of the currently logged-in administrator. - * @return A DTO containing both company and personal point balances. - */ public AdminPointBudgetResponse getAdminAndCompanyBalance(String adminEmail) { - // 1. Fetch the admin employee and their associated company - Employee admin = employeeRepository.findByEmail(adminEmail) - .orElseThrow(() -> new UserNotFoundException("Admin user not found.")); + + Long companyId = Tenant.id(); + + // tenant 범위에서 admin 조회 + Employee admin = employeeRepository.findByCompanyCompanyIdAndEmail(companyId, adminEmail) + .orElseThrow(() -> new UserNotFoundException("Admin user not found.")); Company company = admin.getCompany(); if (company == null) { throw new IllegalStateException("Admin is not associated with any company."); } - // 2. Fetch the admin's personal wallet Wallet adminWallet = walletRepository.findByEmployee_EmployeeId(admin.getEmployeeId()) - .orElse(new Wallet(admin)); // If no wallet, create a new one with 0 points + .orElse(new Wallet(admin)); - // 3. Create and return the combined response DTO + // Double 그대로 반환 return new AdminPointBudgetResponse( - company.getTotalCompanyBalance(), - adminWallet.getBalance(), - adminWallet.getGiftablePoint() + company.getTotalCompanyBalance(), + adminWallet.getBalance(), + adminWallet.getGiftablePoint() ); } } diff --git a/src/main/java/com/joycrew/backend/service/AdminPointService.java b/src/main/java/com/joycrew/backend/service/AdminPointService.java index 66ad44c..7d8245c 100644 --- a/src/main/java/com/joycrew/backend/service/AdminPointService.java +++ b/src/main/java/com/joycrew/backend/service/AdminPointService.java @@ -7,17 +7,18 @@ import com.joycrew.backend.entity.RewardPointTransaction; import com.joycrew.backend.entity.Wallet; import com.joycrew.backend.entity.enums.TransactionType; +import com.joycrew.backend.exception.BillingRequiredException; import com.joycrew.backend.exception.UserNotFoundException; import com.joycrew.backend.repository.CompanyRepository; import com.joycrew.backend.repository.EmployeeRepository; import com.joycrew.backend.repository.RewardPointTransactionRepository; import com.joycrew.backend.repository.WalletRepository; +import com.joycrew.backend.tenant.Tenant; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.List; -import java.util.Map; +import java.util.*; import java.util.function.Function; import java.util.stream.Collectors; @@ -32,23 +33,32 @@ public class AdminPointService { private final CompanyRepository companyRepository; public void distributePoints(AdminPointDistributionRequest request, Employee admin) { - // Admin 다시 조회 (Company까지 join fetch) - Employee managedAdmin = employeeRepository.findByIdWithCompany(admin.getEmployeeId()) + + Long companyId = Tenant.id(); + + // ✅ Admin 다시 조회 (tenant + company join fetch) + Employee managedAdmin = employeeRepository.findByIdWithCompanyAndCompanyId(admin.getEmployeeId(), companyId) .orElseThrow(() -> new UserNotFoundException("Admin not found")); Company company = managedAdmin.getCompany(); + // ✅ 카드등록 필수 게이트 (포인트 지급/차감 전에) + if (!company.isBillingReady()) { + throw new BillingRequiredException(); + } + // 총 변화량 계산 int netPointsChange = request.distributions().stream() .mapToInt(PointDistributionDetail::points) .sum(); - // 회사 예산 반영 + // 회사 예산 반영 (Double 기반) if (netPointsChange > 0) { company.spendBudget(netPointsChange); } else if (netPointsChange < 0) { company.addBudget(Math.abs(netPointsChange)); } + // company는 영속 상태라 save 생략 가능하지만, 명시적으로 두려면 유지 companyRepository.save(company); // 지급 대상 직원 ID 목록 @@ -56,18 +66,19 @@ public void distributePoints(AdminPointDistributionRequest request, Employee adm .map(PointDistributionDetail::employeeId) .toList(); - // 직원 목록 조회 - Map employeeMap = employeeRepository.findAllById(employeeIds).stream() + // ✅ tenant(회사) 범위에서만 직원 조회 + Map employeeMap = employeeRepository + .findAllByCompanyCompanyIdAndEmployeeIdIn(companyId, employeeIds) + .stream() .collect(Collectors.toMap(Employee::getEmployeeId, Function.identity())); - // 일부 직원이 없으면 예외 if (employeeMap.size() != employeeIds.size()) { throw new UserNotFoundException("Could not find some of the requested employees. Please verify the IDs."); } - // 각 직원별 지급/차감 실행 for (PointDistributionDetail detail : request.distributions()) { Employee employee = employeeMap.get(detail.employeeId()); + Wallet wallet = walletRepository.findByEmployee_EmployeeId(employee.getEmployeeId()) .orElseThrow(() -> new IllegalStateException("Wallet not found for employee: " + employee.getEmployeeName())); @@ -79,7 +90,6 @@ public void distributePoints(AdminPointDistributionRequest request, Employee adm wallet.revokePoints(Math.abs(pointsToProcess)); } - // 트랜잭션 기록 저장 if (pointsToProcess != 0) { RewardPointTransaction transaction = RewardPointTransaction.builder() .sender(managedAdmin) diff --git a/src/main/java/com/joycrew/backend/service/AuthService.java b/src/main/java/com/joycrew/backend/service/AuthService.java index 87357a9..39641df 100644 --- a/src/main/java/com/joycrew/backend/service/AuthService.java +++ b/src/main/java/com/joycrew/backend/service/AuthService.java @@ -4,6 +4,7 @@ import com.joycrew.backend.dto.LoginResponse; import com.joycrew.backend.entity.Employee; import com.joycrew.backend.entity.Wallet; +import com.joycrew.backend.entity.enums.AdminLevel; import com.joycrew.backend.exception.UserNotFoundException; import com.joycrew.backend.repository.EmployeeRepository; import com.joycrew.backend.repository.WalletRepository; @@ -30,115 +31,117 @@ @RequiredArgsConstructor public class AuthService { - @Value("${jwt.password-reset-expiration-ms}") - private long passwordResetExpirationMs; - - private final JwtUtil jwtUtil; - private final AuthenticationManager authenticationManager; - private final WalletRepository walletRepository; - private final EmployeeRepository employeeRepository; - private final CompanyDomainRepository companyDomainRepository; - private final PasswordEncoder passwordEncoder; - private final EmailService emailService; - - /** - * 로그인: 인증 성공 시 JWT와 사용자 정보 + subdomain(예: alko.joycrew.co.kr)을 반환 - */ - @Transactional - public LoginResponse login(LoginRequest request) { - log.info("Attempting login for email: {}", request.email()); - - try { - // 1. 인증 진행 - Authentication authentication = authenticationManager.authenticate( - new UsernamePasswordAuthenticationToken(request.email(), request.password()) - ); - - UserPrincipal principal = (UserPrincipal) authentication.getPrincipal(); - Employee employee = principal.getEmployee(); - - // 2. 지갑 잔액 조회 - Integer totalPoint = walletRepository.findByEmployee_EmployeeId(employee.getEmployeeId()) - .map(Wallet::getBalance) - .orElse(0); - - // 3. 마지막 로그인 시간 업데이트 - employee.updateLastLogin(); - - // 4. 토큰 생성 - String accessToken = jwtUtil.generateToken(employee.getEmail()); - - // 🚨 [핵심 수정] - // 기존: Tenant.id() -> 현재 접속한 URL(joycrew.co.kr)을 기준으로 찾음 (실패 원인) - // 수정: employee.getCompany().getCompanyId() -> '로그인한 유저의 소속 회사'를 기준으로 찾음 (정답) - Long userCompanyId = employee.getCompany().getCompanyId(); - - String subdomain = companyDomainRepository - .findFirstByCompanyCompanyIdAndPrimaryDomainTrueOrderByIdDesc(userCompanyId) - .map(cd -> cd.getDomain().toLowerCase()) - .orElse(null); - - return new LoginResponse( - accessToken, - "Login successful", - employee.getEmployeeId(), - employee.getEmployeeName(), - employee.getEmail(), - employee.getRole(), - totalPoint, - employee.getProfileImageUrl(), - subdomain // 이제 BDL 유저는 'bdl.joycrew.co.kr'을 반환받음 - ); - - } catch (UsernameNotFoundException | BadCredentialsException e) { - log.warn("Login failed for email {}: {}", request.email(), e.getMessage()); - throw e; - } + @Value("${jwt.password-reset-expiration-ms}") + private long passwordResetExpirationMs; + + private final JwtUtil jwtUtil; + private final AuthenticationManager authenticationManager; + private final WalletRepository walletRepository; + private final EmployeeRepository employeeRepository; + private final CompanyDomainRepository companyDomainRepository; + private final PasswordEncoder passwordEncoder; + private final EmailService emailService; + + /** + * 로그인: 인증 성공 시 JWT와 사용자 정보 + subdomain(예: alko.joycrew.co.kr)을 반환 + */ + @Transactional + public LoginResponse login(LoginRequest request) { + log.info("Attempting login for email: {}", request.email()); + + try { + // 1. 인증 진행 + Authentication authentication = authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken(request.email(), request.password()) + ); + + UserPrincipal principal = (UserPrincipal) authentication.getPrincipal(); + Employee employee = principal.getEmployee(); + + // 2. 지갑 잔액 조회 + Integer totalPoint = walletRepository.findByEmployee_EmployeeId(employee.getEmployeeId()) + .map(Wallet::getBalance) + .orElse(0); + + // 3. 마지막 로그인 시간 업데이트 + employee.updateLastLogin(); + + // 4. 토큰 생성 + String accessToken = jwtUtil.generateToken(employee.getEmail()); + + // ✅ 핵심: 유저가 속한 회사 기준으로 primary domain 조회 + Long userCompanyId = employee.getCompany().getCompanyId(); + + String subdomain = companyDomainRepository + .findFirstByCompanyCompanyIdAndPrimaryDomainTrueOrderByIdDesc(userCompanyId) + .map(cd -> cd.getDomain().toLowerCase()) + .orElse(null); // 등록이 안 되어 있다면 null + + boolean isAdmin = employee.getRole() == AdminLevel.HR_ADMIN || employee.getRole() == AdminLevel.SUPER_ADMIN; + boolean billingRequired = isAdmin && !employee.getCompany().isBillingReady(); + + return new LoginResponse( + accessToken, + "Login successful", + employee.getEmployeeId(), + employee.getEmployeeName(), + employee.getEmail(), + employee.getRole(), + totalPoint, + employee.getProfileImageUrl(), + subdomain, + billingRequired + ); + + } catch (UsernameNotFoundException | BadCredentialsException e) { + log.warn("Login failed for email {}: {}", request.email(), e.getMessage()); + throw e; } - - /** - * 로그아웃 - */ - public void logout(HttpServletRequest request) { - final String authHeader = request.getHeader("Authorization"); - if (authHeader != null && authHeader.startsWith("Bearer ")) { - String jwt = authHeader.substring(7); - log.info("Logout requested (token: {}). Add blacklist handling if needed.", jwt); - } + } + + /** + * 로그아웃: 서버 사이드 블랙리스트를 쓴다면 여기에서 처리 (현재는 로그만) + */ + public void logout(HttpServletRequest request) { + final String authHeader = request.getHeader("Authorization"); + if (authHeader != null && authHeader.startsWith("Bearer ")) { + String jwt = authHeader.substring(7); + log.info("Logout requested (token: {}). Add blacklist handling if needed.", jwt); } - - /** - * 비밀번호 재설정 요청 - */ - @Transactional(readOnly = true) - public void requestPasswordReset(String email) { - // 비밀번호 변경은 현재 접속한 도메인 컨텍스트를 유지하는 것이 안전할 수 있음 - Long tenant = Tenant.id(); - employeeRepository.findByCompanyCompanyIdAndEmail(tenant, email).ifPresent(emp -> { - String token = jwtUtil.generateToken(email, passwordResetExpirationMs); - emailService.sendPasswordResetEmail(email, token); - log.info("Password reset requested for email: {} (tenant={})", email, tenant); - }); + } + + /** + * 비밀번호 재설정 요청: 도메인 기반 테넌트에서 이메일 검색 -> 토큰 발행 후 메일 발송 + * (응답은 존재 여부와 무관하게 동일) + */ + @Transactional(readOnly = true) + public void requestPasswordReset(String email) { + Long tenant = Tenant.id(); + employeeRepository.findByCompanyCompanyIdAndEmail(tenant, email).ifPresent(emp -> { + String token = jwtUtil.generateToken(email, passwordResetExpirationMs); + emailService.sendPasswordResetEmail(email, token); + log.info("Password reset requested for email: {} (tenant={})", email, tenant); + }); + } + + /** + * 비밀번호 재설정 확정: 토큰에서 이메일 추출 후 같은 테넌트 범위에서 사용자 조회 -> 비밀번호 변경 + */ + @Transactional + public void confirmPasswordReset(String token, String newPassword) { + String email; + try { + email = jwtUtil.getEmailFromToken(token); + } catch (JwtException e) { + throw new BadCredentialsException("Invalid or expired token.", e); } - /** - * 비밀번호 재설정 확정 - */ - @Transactional - public void confirmPasswordReset(String token, String newPassword) { - String email; - try { - email = jwtUtil.getEmailFromToken(token); - } catch (JwtException e) { - throw new BadCredentialsException("Invalid or expired token.", e); - } - - Long tenant = Tenant.id(); - Employee employee = employeeRepository - .findByCompanyCompanyIdAndEmail(tenant, email) - .orElseThrow(() -> new UserNotFoundException("User not found.")); - - employee.changePassword(newPassword, passwordEncoder); - log.info("Password has been reset for: {} (tenant={})", email, tenant); - } -} \ No newline at end of file + Long tenant = Tenant.id(); + Employee employee = employeeRepository + .findByCompanyCompanyIdAndEmail(tenant, email) + .orElseThrow(() -> new UserNotFoundException("User not found.")); + + employee.changePassword(newPassword, passwordEncoder); + log.info("Password has been reset for: {} (tenant={})", email, tenant); + } +} diff --git a/src/main/java/com/joycrew/backend/service/BillingGate.java b/src/main/java/com/joycrew/backend/service/BillingGate.java new file mode 100644 index 0000000..e94d284 --- /dev/null +++ b/src/main/java/com/joycrew/backend/service/BillingGate.java @@ -0,0 +1,23 @@ +package com.joycrew.backend.service; + +import com.joycrew.backend.entity.Company; +import com.joycrew.backend.exception.BillingRequiredException; +import com.joycrew.backend.repository.CompanyRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +public class BillingGate { + + private final CompanyRepository companyRepository; + + @Transactional(readOnly = true) + public void requireBillingReady(Long companyId) { + Company company = companyRepository.findById(companyId).orElseThrow(); + if (!company.isBillingReady()) { + throw new BillingRequiredException(); + } + } +} diff --git a/src/main/java/com/joycrew/backend/service/EmployeeManagementService.java b/src/main/java/com/joycrew/backend/service/EmployeeManagementService.java index 7d14502..f7a951f 100644 --- a/src/main/java/com/joycrew/backend/service/EmployeeManagementService.java +++ b/src/main/java/com/joycrew/backend/service/EmployeeManagementService.java @@ -3,12 +3,16 @@ import com.joycrew.backend.dto.AdminEmployeeQueryResponse; import com.joycrew.backend.dto.AdminEmployeeUpdateRequest; import com.joycrew.backend.dto.AdminPagedEmployeeResponse; +import com.joycrew.backend.entity.Company; import com.joycrew.backend.entity.Department; import com.joycrew.backend.entity.Employee; +import com.joycrew.backend.exception.BillingRequiredException; import com.joycrew.backend.exception.UserNotFoundException; +import com.joycrew.backend.repository.CompanyRepository; import com.joycrew.backend.repository.DepartmentRepository; import com.joycrew.backend.repository.EmployeeRepository; import com.joycrew.backend.service.mapper.EmployeeMapper; +import com.joycrew.backend.tenant.Tenant; import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; import jakarta.persistence.TypedQuery; @@ -25,41 +29,72 @@ public class EmployeeManagementService { private final EmployeeRepository employeeRepository; private final DepartmentRepository departmentRepository; + private final CompanyRepository companyRepository; private final EmployeeMapper employeeMapper; + @PersistenceContext private final EntityManager em; + /** 카드 등록 게이트(직원 관리/등록/조회 모두 차단) */ + private Company requireBillingReady() { + Long companyId = Tenant.id(); + Company company = companyRepository.findById(companyId) + .orElseThrow(() -> new IllegalStateException("Company not found (tenant=" + companyId + ")")); + + if (!company.isBillingReady()) { + throw new BillingRequiredException(); + } + return company; + } + @Transactional(readOnly = true) public AdminPagedEmployeeResponse searchEmployees(String keyword, int page, int size) { - StringBuilder whereClause = new StringBuilder("WHERE 1=1 "); + requireBillingReady(); + + Long companyId = Tenant.id(); + + StringBuilder whereClause = new StringBuilder("WHERE c.companyId = :companyId "); if (keyword != null && !keyword.isBlank()) { whereClause.append("AND (LOWER(e.employeeName) LIKE :keyword ") .append("OR LOWER(e.email) LIKE :keyword ") .append("OR LOWER(d.name) LIKE :keyword) "); } - String countJpql = "SELECT COUNT(e) FROM Employee e LEFT JOIN e.department d " + whereClause; + String countJpql = + "SELECT COUNT(e) " + + "FROM Employee e " + + "JOIN e.company c " + + "LEFT JOIN e.department d " + + whereClause; + TypedQuery countQuery = em.createQuery(countJpql, Long.class); + countQuery.setParameter("companyId", companyId); if (keyword != null && !keyword.isBlank()) { countQuery.setParameter("keyword", "%" + keyword.toLowerCase() + "%"); } + long total = countQuery.getSingleResult(); int totalPages = (int) Math.ceil((double) total / size); - String dataJpql = "SELECT e FROM Employee e " + - "LEFT JOIN FETCH e.department d " + - "LEFT JOIN FETCH e.company c " + - whereClause + - "ORDER BY e.employeeName ASC"; + String dataJpql = + "SELECT e " + + "FROM Employee e " + + "JOIN FETCH e.company c " + + "LEFT JOIN FETCH e.department d " + + whereClause + + "ORDER BY e.employeeName ASC"; + TypedQuery dataQuery = em.createQuery(dataJpql, Employee.class) .setFirstResult(page * size) .setMaxResults(size); + + dataQuery.setParameter("companyId", companyId); if (keyword != null && !keyword.isBlank()) { dataQuery.setParameter("keyword", "%" + keyword.toLowerCase() + "%"); } List employees = dataQuery.getResultList().stream() - .map(employeeMapper::toAdminEmployeeQueryResponse) // Use Mapper + .map(employeeMapper::toAdminEmployeeQueryResponse) .toList(); return new AdminPagedEmployeeResponse( @@ -71,17 +106,23 @@ public AdminPagedEmployeeResponse searchEmployees(String keyword, int page, int } public Employee updateEmployee(Long employeeId, AdminEmployeeUpdateRequest request) { - Employee employee = employeeRepository.findById(employeeId) + requireBillingReady(); + + Long companyId = Tenant.id(); + + Employee employee = employeeRepository.findByCompanyCompanyIdAndEmployeeId(companyId, employeeId) .orElseThrow(() -> new UserNotFoundException("Employee not found with ID: " + employeeId)); if (request.name() != null) { employee.updateName(request.name()); } + if (request.departmentId() != null) { - Department department = departmentRepository.findById(request.departmentId()) + Department department = departmentRepository.findByCompanyCompanyIdAndDepartmentId(companyId, request.departmentId()) .orElseThrow(() -> new IllegalArgumentException("Department not found with ID: " + request.departmentId())); employee.assignToDepartment(department); } + if (request.position() != null) { employee.updatePosition(request.position()); } @@ -91,19 +132,29 @@ public Employee updateEmployee(Long employeeId, AdminEmployeeUpdateRequest reque if (request.status() != null) { employee.updateStatus(request.status()); } - return employee; // @Transactional will handle the save + + return employee; // dirty checking } public void deactivateEmployee(Long employeeId) { - Employee employee = employeeRepository.findById(employeeId) + requireBillingReady(); + + Long companyId = Tenant.id(); + + Employee employee = employeeRepository.findByCompanyCompanyIdAndEmployeeId(companyId, employeeId) .orElseThrow(() -> new UserNotFoundException("Employee not found with ID: " + employeeId)); + employee.updateStatus("INACTIVE"); } @Transactional(readOnly = true) public List getAllEmployees() { - return employeeRepository.findAll().stream() - .map(employeeMapper::toAdminEmployeeQueryResponse) // Use Mapper + requireBillingReady(); + + Long companyId = Tenant.id(); + + return employeeRepository.findAllByCompanyCompanyId(companyId).stream() + .map(employeeMapper::toAdminEmployeeQueryResponse) .toList(); } -} \ No newline at end of file +} diff --git a/src/main/java/com/joycrew/backend/service/EmployeeQueryService.java b/src/main/java/com/joycrew/backend/service/EmployeeQueryService.java index a43e6a3..5295700 100644 --- a/src/main/java/com/joycrew/backend/service/EmployeeQueryService.java +++ b/src/main/java/com/joycrew/backend/service/EmployeeQueryService.java @@ -22,58 +22,58 @@ @Transactional(readOnly = true) public class EmployeeQueryService { - @PersistenceContext - private final EntityManager em; - private final EmployeeMapper employeeMapper; + @PersistenceContext + private final EntityManager em; + private final EmployeeMapper employeeMapper; - public PagedEmployeeResponse getEmployees(String keyword, int page, int size, Long currentUserId, AdminLevel requesterRole) { - Long tenant = Tenant.id(); // ✅ 테넌트 + public PagedEmployeeResponse getEmployees(String keyword, int page, int size, Long currentUserId, AdminLevel requesterRole) { + Long tenant = Tenant.id(); // 현재 테넌트(companyId) - StringBuilder where = new StringBuilder("WHERE c.companyId = :tenant AND e.employeeId != :currentUserId "); + StringBuilder where = new StringBuilder("WHERE c.companyId = :tenant AND e.employeeId != :currentUserId "); - boolean hasKeyword = StringUtils.hasText(keyword); - if (hasKeyword) { - where.append("AND (LOWER(e.employeeName) LIKE :keyword ") - .append("OR LOWER(e.email) LIKE :keyword ") - .append("OR LOWER(d.name) LIKE :keyword) "); - } + boolean hasKeyword = StringUtils.hasText(keyword); + if (hasKeyword) { + where.append("AND (LOWER(e.employeeName) LIKE :keyword ") + .append("OR LOWER(e.email) LIKE :keyword ") + .append("OR LOWER(d.name) LIKE :keyword) "); + } - boolean hideSuperAdmin = (requesterRole != AdminLevel.SUPER_ADMIN); - if (hideSuperAdmin) { - where.append("AND e.role <> :superAdmin "); - } + boolean hideSuperAdmin = (requesterRole != AdminLevel.SUPER_ADMIN); + if (hideSuperAdmin) { + where.append("AND e.role <> :superAdmin "); + } - String countJpql = "SELECT COUNT(e) FROM Employee e JOIN e.company c LEFT JOIN e.department d " + where; - TypedQuery countQuery = em.createQuery(countJpql, Long.class) - .setParameter("tenant", tenant) - .setParameter("currentUserId", currentUserId); - if (hasKeyword) countQuery.setParameter("keyword", "%" + keyword.toLowerCase() + "%"); - if (hideSuperAdmin) countQuery.setParameter("superAdmin", AdminLevel.SUPER_ADMIN); + String countJpql = "SELECT COUNT(e) FROM Employee e JOIN e.company c LEFT JOIN e.department d " + where; + TypedQuery countQuery = em.createQuery(countJpql, Long.class) + .setParameter("tenant", tenant) + .setParameter("currentUserId", currentUserId); + if (hasKeyword) countQuery.setParameter("keyword", "%" + keyword.toLowerCase() + "%"); + if (hideSuperAdmin) countQuery.setParameter("superAdmin", AdminLevel.SUPER_ADMIN); - long totalCount = countQuery.getSingleResult(); - int totalPages = (int) Math.ceil((double) totalCount / size); + long totalCount = countQuery.getSingleResult(); + int totalPages = (int) Math.ceil((double) totalCount / size); - String dataJpql = - "SELECT e FROM Employee e " + - "JOIN FETCH e.company c " + - "LEFT JOIN FETCH e.department d " + - where + - "ORDER BY e.employeeName ASC"; + String dataJpql = + "SELECT e FROM Employee e " + + "JOIN FETCH e.company c " + + "LEFT JOIN FETCH e.department d " + + where + + "ORDER BY e.employeeName ASC"; - TypedQuery dataQuery = em.createQuery(dataJpql, Employee.class) - .setParameter("tenant", tenant) - .setParameter("currentUserId", currentUserId); - if (hasKeyword) dataQuery.setParameter("keyword", "%" + keyword.toLowerCase() + "%"); - if (hideSuperAdmin) dataQuery.setParameter("superAdmin", AdminLevel.SUPER_ADMIN); + TypedQuery dataQuery = em.createQuery(dataJpql, Employee.class) + .setParameter("tenant", tenant) + .setParameter("currentUserId", currentUserId); + if (hasKeyword) dataQuery.setParameter("keyword", "%" + keyword.toLowerCase() + "%"); + if (hideSuperAdmin) dataQuery.setParameter("superAdmin", AdminLevel.SUPER_ADMIN); - dataQuery.setFirstResult(page * size); - dataQuery.setMaxResults(size); + dataQuery.setFirstResult(page * size); + dataQuery.setMaxResults(size); - List employees = dataQuery.getResultList().stream() - .filter(e -> !(hideSuperAdmin && e.getRole() == AdminLevel.SUPER_ADMIN)) // 최종 안전망 - .map(employeeMapper::toEmployeeQueryResponse) - .collect(Collectors.toList()); + List employees = dataQuery.getResultList().stream() + .filter(e -> !(hideSuperAdmin && e.getRole() == AdminLevel.SUPER_ADMIN)) // 최종 안전망 + .map(employeeMapper::toEmployeeQueryResponse) + .collect(Collectors.toList()); - return new PagedEmployeeResponse(employees, page, totalPages, page >= totalPages - 1); - } + return new PagedEmployeeResponse(employees, page, totalPages, page >= totalPages - 1); + } } diff --git a/src/main/java/com/joycrew/backend/service/GiftPointService.java b/src/main/java/com/joycrew/backend/service/GiftPointService.java index cf06254..9a8d607 100644 --- a/src/main/java/com/joycrew/backend/service/GiftPointService.java +++ b/src/main/java/com/joycrew/backend/service/GiftPointService.java @@ -19,45 +19,45 @@ @RequiredArgsConstructor public class GiftPointService { - private final EmployeeRepository employeeRepository; - private final WalletRepository walletRepository; - private final RewardPointTransactionRepository transactionRepository; - private final ApplicationEventPublisher eventPublisher; - - @Transactional - public void giftPointsToColleague(String senderEmail, GiftPointRequest request) { - Employee sender = employeeRepository.findByEmail(senderEmail) - .orElseThrow(() -> new UserNotFoundException("Sender not found.")); - Employee receiver = employeeRepository.findById(request.receiverId()) - .orElseThrow(() -> new UserNotFoundException("Receiver not found.")); - - Wallet senderWallet = walletRepository.findByEmployee_EmployeeId(sender.getEmployeeId()) - .orElseThrow(() -> new IllegalStateException("Sender's wallet does not exist.")); - Wallet receiverWallet = walletRepository.findByEmployee_EmployeeId(receiver.getEmployeeId()) - .orElseThrow(() -> new IllegalStateException("Receiver's wallet does not exist.")); - - // Transfer points - // 1. 보내는 사람: '총 잔액(balance)'과 '선물 한도(giftablePoint)' 둘 다 차감 (기존 로직) - senderWallet.spendGiftablePoints(request.points()); - - // [FIXED] 2. 받는 사람: '총 잔액(balance)'만 증가시킴 - receiverWallet.receiveGiftPoints(request.points()); - // receiverWallet.addPoints(request.points()); // 👈 기존 코드 - - // Record the transaction - RewardPointTransaction transaction = RewardPointTransaction.builder() - .sender(sender) - .receiver(receiver) - .pointAmount(request.points()) - .message(request.message()) - .type(TransactionType.AWARD_P2P) - .tags(request.tags()) - .build(); - transactionRepository.save(transaction); - - // Publish an event for notifications or other async tasks - eventPublisher.publishEvent( - new RecognitionEvent(this, sender.getEmployeeId(), receiver.getEmployeeId(), request.points(), request.message()) - ); - } -} \ No newline at end of file + private final EmployeeRepository employeeRepository; + private final WalletRepository walletRepository; + private final RewardPointTransactionRepository transactionRepository; + private final ApplicationEventPublisher eventPublisher; + + @Transactional + public void giftPointsToColleague(String senderEmail, GiftPointRequest request) { + Employee sender = employeeRepository.findByEmail(senderEmail) + .orElseThrow(() -> new UserNotFoundException("Sender not found.")); + Employee receiver = employeeRepository.findById(request.receiverId()) + .orElseThrow(() -> new UserNotFoundException("Receiver not found.")); + + Wallet senderWallet = walletRepository.findByEmployee_EmployeeId(sender.getEmployeeId()) + .orElseThrow(() -> new IllegalStateException("Sender's wallet does not exist.")); + Wallet receiverWallet = walletRepository.findByEmployee_EmployeeId(receiver.getEmployeeId()) + .orElseThrow(() -> new IllegalStateException("Receiver's wallet does not exist.")); + + // Transfer points + // 1. 보내는 사람: '총 잔액(balance)'과 '선물 한도(giftablePoint)' 둘 다 차감 + senderWallet.spendGiftablePoints(request.points()); + + // 2. 받는 사람: '총 잔액(balance)'만 증가시킴 (선물로 받은 포인트) + receiverWallet.receiveGiftPoints(request.points()); + // 기존: receiverWallet.addPoints(request.points()); + + // Record the transaction + RewardPointTransaction transaction = RewardPointTransaction.builder() + .sender(sender) + .receiver(receiver) + .pointAmount(request.points()) + .message(request.message()) + .type(TransactionType.AWARD_P2P) + .tags(request.tags()) + .build(); + transactionRepository.save(transaction); + + // Publish an event for notifications or other async tasks + eventPublisher.publishEvent( + new RecognitionEvent(this, sender.getEmployeeId(), receiver.getEmployeeId(), request.points(), request.message()) + ); + } +} diff --git a/src/main/java/com/joycrew/backend/service/GiftPurchaseService.java b/src/main/java/com/joycrew/backend/service/GiftPurchaseService.java index 36657f1..065c2c3 100644 --- a/src/main/java/com/joycrew/backend/service/GiftPurchaseService.java +++ b/src/main/java/com/joycrew/backend/service/GiftPurchaseService.java @@ -1,9 +1,9 @@ -// src/main/java/com/joycrew/backend/service/GiftPurchaseService.java package com.joycrew.backend.service; import com.joycrew.backend.dto.CreateOrderRequest; import com.joycrew.backend.dto.OrderResponse; import com.joycrew.backend.dto.kakao.KakaoTemplateOrderRequest; +import com.joycrew.backend.entity.Company; import com.joycrew.backend.entity.Employee; import com.joycrew.backend.entity.Order; import com.joycrew.backend.entity.RewardPointTransaction; @@ -11,13 +11,18 @@ import com.joycrew.backend.entity.enums.OrderStatus; import com.joycrew.backend.entity.enums.TransactionType; import com.joycrew.backend.entity.kakao.KakaoTemplate; +import com.joycrew.backend.exception.BillingRequiredException; import com.joycrew.backend.exception.UserNotFoundException; import com.joycrew.backend.kakao.KakaoGiftBizClient; -import com.joycrew.backend.repository.*; +import com.joycrew.backend.repository.KakaoTemplateRepository; +import com.joycrew.backend.repository.OrderRepository; +import com.joycrew.backend.repository.RewardPointTransactionRepository; +import com.joycrew.backend.repository.WalletRepository; +import com.joycrew.backend.repository.EmployeeRepository; +import com.joycrew.backend.tenant.Tenant; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.server.ResponseStatusException; @@ -56,23 +61,33 @@ public class GiftPurchaseService { */ @Transactional public OrderResponse purchaseWithPoints(Long employeeId, CreateOrderRequest req) { - // 1) 사용자/지갑/템플릿 - Employee employee = employeeRepository.findById(employeeId) + + Long companyId = Tenant.id(); + + // ✅ tenant 범위에서 employee 로드 + 회사 join fetch 권장(없으면 repository 메서드로) + Employee employee = employeeRepository.findByCompanyCompanyIdAndEmployeeIdWithCompany(companyId, employeeId) .orElseThrow(() -> new UserNotFoundException("Employee not found")); + + Company company = employee.getCompany(); + + // ✅ 카드등록 전이면 주문 차단 + if (!company.isBillingReady()) { + throw new BillingRequiredException(); + } + Wallet wallet = walletRepository.findByEmployee_EmployeeId(employeeId) .orElseThrow(() -> new IllegalStateException("Wallet not found")); KakaoTemplate template = templateRepo.findById(req.externalProductId()) .orElseThrow(() -> new IllegalArgumentException("Template not found: " + req.externalProductId())); - // 2) 금액/포인트 계산 (옵션 제거 버전) int qty = (req.quantity() == null || req.quantity() <= 0) ? 1 : req.quantity(); int unitKrw = template.getBasePriceKrw(); long totalKrw = (long) unitKrw * qty; int totalPoint = (int) Math.ceil(totalKrw / (double) krwPerPoint); - // 3) 주문 레코드 PENDING 으로 선저장 (external_order_id 생성) String externalOrderId = buildExternalOrderId(employeeId, template.getTemplateId()); + Order order = Order.builder() .employee(employee) .productId(stableHashToLong(template.getTemplateId())) @@ -82,14 +97,12 @@ public OrderResponse purchaseWithPoints(Long employeeId, CreateOrderRequest req) .totalPrice(totalPoint) .status(OrderStatus.PENDING) .orderedAt(LocalDateTime.now()) - .externalOrderId(externalOrderId) // 필요시 Order 엔티티에 필드 추가 + .externalOrderId(externalOrderId) .build(); order = orderRepository.save(order); - // 4) 포인트 선차감 (구매는 balance만 차감) wallet.purchaseWithPoints(totalPoint); - // 5) Kakao 요청 바디 구성 (receiver_id = 직원 휴대폰 번호) String receiverPhone = Optional.ofNullable(employee.getPhoneNumber()) .map(this::normalizePhone) .filter(s -> s != null && !s.isBlank()) @@ -112,7 +125,6 @@ public OrderResponse purchaseWithPoints(Long employeeId, CreateOrderRequest req) externalOrderId ); - // 6) Kakao 호출 (dry-run 지원) try { if (!dryRun) { String kakaoBody = kakao.sendTemplateOrder(kakaoReq); @@ -121,7 +133,6 @@ public OrderResponse purchaseWithPoints(Long employeeId, CreateOrderRequest req) log.warn("[DRY-RUN] Skipping Kakao call. Would send: {}", kakaoReq); } - // 7) 거래 이력 저장(성공시에만) RewardPointTransaction tx = RewardPointTransaction.builder() .sender(employee) .receiver(null) @@ -131,21 +142,18 @@ public OrderResponse purchaseWithPoints(Long employeeId, CreateOrderRequest req) .build(); transactionRepository.save(tx); - // 8) 주문 상태 갱신 -> PLACED order.setStatus(OrderStatus.PLACED); order = orderRepository.save(order); return OrderResponse.from(order, template.getThumbnailUrl()); } catch (ResponseStatusException ex) { - // Kakao 4xx/네트워크 오류 등 → FAILED 처리 + 포인트 환불 refundWalletSilently(wallet, totalPoint); order.setStatus(OrderStatus.FAILED); orderRepository.save(order); - throw ex; // 400/502 그대로 클라이언트에게 + throw ex; } catch (RuntimeException ex) { - // 기타 예외 → FAILED 처리 + 포인트 환불 refundWalletSilently(wallet, totalPoint); order.setStatus(OrderStatus.FAILED); orderRepository.save(order); @@ -158,7 +166,6 @@ private void refundWalletSilently(Wallet wallet, int totalPoint) { wallet.refundPoints(totalPoint); } catch (Exception e) { log.error("Failed to refund points on error (amount={}): {}", totalPoint, e.getMessage(), e); - // 환불 실패시에도 본 예외를 삼키지 않는다. } } @@ -168,17 +175,16 @@ private String emptyToNull(String s) { private String normalizePhone(String raw) { if (raw == null) return null; - return raw.replaceAll("\\D", ""); // 숫자만 남김 (010-xxxx-xxxx → 010xxxxxxxx) + return raw.replaceAll("\\D", ""); } private String buildExternalOrderId(Long employeeId, String templateId) { - // 길이 ≤ 70: "JC-" + employeeId + "-" + 12자리 랜덤 String rand = UUID.randomUUID().toString().replace("-", "").substring(0, 12); return "JC-" + employeeId + "-" + rand; } private long stableHashToLong(String s) { - long h = 1469598103934665603L; // FNV-1a 64-bit + long h = 1469598103934665603L; for (byte b : s.getBytes()) { h ^= b; h *= 1099511628211L; } return h & Long.MAX_VALUE; } diff --git a/src/main/java/com/joycrew/backend/service/SubscriptionBillingKeyAppService.java b/src/main/java/com/joycrew/backend/service/SubscriptionBillingKeyAppService.java new file mode 100644 index 0000000..d6ea608 --- /dev/null +++ b/src/main/java/com/joycrew/backend/service/SubscriptionBillingKeyAppService.java @@ -0,0 +1,32 @@ +package com.joycrew.backend.service; + +import com.joycrew.backend.entity.Company; +import com.joycrew.backend.repository.CompanyRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class SubscriptionBillingKeyAppService { + + private final CompanyRepository companyRepository; + private final TossBillingKeyService tossBillingKeyService; + + @Transactional + public void issueAndSaveBillingKey(Long companyId, String authKey) { + Company company = companyRepository.findById(companyId).orElseThrow(); + + String customerKey = "company_" + companyId; + String billingKey = tossBillingKeyService.issueBillingKey(authKey, customerKey); + + company.registerBillingKeyAndEnableAutoRenew(billingKey, customerKey); + company.initializeSubscriptionEndAtIfFirstTime(); // ✅ now + 1개월 + } + + @Transactional + public void disableAutoRenew(Long companyId) { + Company company = companyRepository.findById(companyId).orElseThrow(); + company.disableAutoRenew(); + } +} diff --git a/src/main/java/com/joycrew/backend/service/SubscriptionBillingScheduler.java b/src/main/java/com/joycrew/backend/service/SubscriptionBillingScheduler.java new file mode 100644 index 0000000..45fb6b6 --- /dev/null +++ b/src/main/java/com/joycrew/backend/service/SubscriptionBillingScheduler.java @@ -0,0 +1,38 @@ +package com.joycrew.backend.service; + +import com.joycrew.backend.entity.Company; +import com.joycrew.backend.repository.CompanyRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Slf4j +public class SubscriptionBillingScheduler { + + private final CompanyRepository companyRepository; + private final SubscriptionBillingService subscriptionBillingService; + + @Scheduled(cron = "0 0 3 * * *") + public void autoBillingJob() { + LocalDateTime now = LocalDateTime.now(); + List targets = companyRepository.findAutoBillingTargets(now); + + log.info("[AUTO-BILLING] started at {}, targets={}", now, targets.size()); + + for (Company c : targets) { + try { + subscriptionBillingService.billCompany(c.getCompanyId()); + } catch (Exception e) { + log.error("[AUTO-BILL-ERROR] companyId={} error={}", c.getCompanyId(), e.getMessage(), e); + } + } + + log.info("[AUTO-BILLING] finished at {}", LocalDateTime.now()); + } +} diff --git a/src/main/java/com/joycrew/backend/service/SubscriptionBillingService.java b/src/main/java/com/joycrew/backend/service/SubscriptionBillingService.java new file mode 100644 index 0000000..d5e185f --- /dev/null +++ b/src/main/java/com/joycrew/backend/service/SubscriptionBillingService.java @@ -0,0 +1,94 @@ +package com.joycrew.backend.service; + +import com.joycrew.backend.entity.Company; +import com.joycrew.backend.entity.SubscriptionPayment; +import com.joycrew.backend.repository.CompanyRepository; +import com.joycrew.backend.repository.SubscriptionPaymentRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +@Service +@RequiredArgsConstructor +@Slf4j +public class SubscriptionBillingService { + + private final CompanyRepository companyRepository; + private final SubscriptionPaymentRepository paymentRepository; + private final TossBillingChargeService tossBillingChargeService; + + @Value("${subscription.monthly-price}") + private long monthlyPrice; // ✅ 정확한 amount 저장용 + + private static final DateTimeFormatter ORDER_FMT = DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); + + @Transactional + public void billCompany(Long companyId) { + Company company = companyRepository.findById(companyId).orElseThrow(); + + if (!company.canAutoBill()) { + log.warn("[AUTO-BILL-SKIP] companyId={} cannot auto bill", companyId); + return; + } + + LocalDateTime now = LocalDateTime.now(); + + // 이번 결제가 커버할 구독 기간: (현재 subscriptionEndAt) ~ +1month + LocalDateTime periodStart = (company.getSubscriptionEndAt() != null) ? company.getSubscriptionEndAt() : now; + LocalDateTime periodEnd = periodStart.plusMonths(1); + + // ✅ 멱등성 orderId: company + periodStart 기준 고정 + String orderId = generateOrderId(companyId, periodStart); + + // 이미 성공 이력이 있으면 재결제 방지 + paymentRepository.findByOrderId(orderId).ifPresent(existing -> { + if (existing.getStatus().name().equals("SUCCESS")) { + log.info("[AUTO-BILL-SKIP] already success orderId={}", orderId); + return; + } + }); + + // ✅ PENDING 생성(없으면) + amount 정확히 저장 + SubscriptionPayment payment = paymentRepository.findByOrderId(orderId) + .orElseGet(() -> paymentRepository.save( + SubscriptionPayment.pending( + company, + orderId, + monthlyPrice, // ✅ 여기! + periodStart, + periodEnd, + now + ) + )); + + // Toss 결제 호출 + TossBillingChargeService.TossChargeResult result = + tossBillingChargeService.charge(company, orderId); + + if (result.success()) { + payment.markSuccess(result.paymentKey(), result.approvedAt(), result.rawResponse()); + company.extendSubscription(1); // ✅ 성공 시 구독 연장 + + log.info("[AUTO-BILL-SUCCESS] companyId={}, orderId={}, newEndAt={}", + companyId, orderId, company.getSubscriptionEndAt()); + } else { + payment.markFailed( + (result.failCode() != null ? result.failCode() : "FAILED"), + result.rawResponse() + ); + company.markFailed(); + + log.error("[AUTO-BILL-FAILED] companyId={}, orderId={}, code={}, msg={}", + companyId, orderId, result.failCode(), result.failMessage()); + } + } + + private String generateOrderId(Long companyId, LocalDateTime periodStart) { + return "SUB_" + companyId + "_" + periodStart.format(ORDER_FMT); + } +} diff --git a/src/main/java/com/joycrew/backend/service/SubscriptionPaymentQueryService.java b/src/main/java/com/joycrew/backend/service/SubscriptionPaymentQueryService.java new file mode 100644 index 0000000..e9385d6 --- /dev/null +++ b/src/main/java/com/joycrew/backend/service/SubscriptionPaymentQueryService.java @@ -0,0 +1,40 @@ +package com.joycrew.backend.service; + +import com.joycrew.backend.dto.SubscriptionPaymentHistoryItem; +import com.joycrew.backend.dto.SubscriptionPaymentHistoryResponse; +import com.joycrew.backend.entity.SubscriptionPayment; +import com.joycrew.backend.entity.enums.PaymentStatus; +import com.joycrew.backend.repository.SubscriptionPaymentRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class SubscriptionPaymentQueryService { + + private final SubscriptionPaymentRepository subscriptionPaymentRepository; + + public SubscriptionPaymentHistoryResponse getHistory(Long companyId, int page, int size, PaymentStatus status) { + Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "requestedAt")); + + Page result = (status == null) + ? subscriptionPaymentRepository.findByCompany_CompanyIdOrderByRequestedAtDesc(companyId, pageable) + : subscriptionPaymentRepository.findByCompany_CompanyIdAndStatusOrderByRequestedAtDesc(companyId, status, pageable); + + List items = + result.getContent().stream().map(SubscriptionPaymentHistoryItem::from).toList(); + + return new SubscriptionPaymentHistoryResponse( + items, + result.getNumber(), + result.getSize(), + result.getTotalElements(), + result.getTotalPages() + ); + } +} diff --git a/src/main/java/com/joycrew/backend/service/SubscriptionQueryService.java b/src/main/java/com/joycrew/backend/service/SubscriptionQueryService.java new file mode 100644 index 0000000..82cbc09 --- /dev/null +++ b/src/main/java/com/joycrew/backend/service/SubscriptionQueryService.java @@ -0,0 +1,27 @@ +package com.joycrew.backend.service; + +import com.joycrew.backend.dto.SubscriptionSummaryResponse; +import com.joycrew.backend.entity.Company; +import com.joycrew.backend.repository.CompanyRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class SubscriptionQueryService { + + private final CompanyRepository companyRepository; + + public SubscriptionSummaryResponse getSubscriptionSummary(Long companyId) { + Company company = companyRepository.findById(companyId).orElseThrow(); + + return new SubscriptionSummaryResponse( + company.getCreatedAt(), // 가입일 + company.getSubscriptionEndAt(),// 다음 결제 예정일 + company.isAutoRenew(), + company.getStatus() + ); + } +} diff --git a/src/main/java/com/joycrew/backend/service/TossBillingChargeService.java b/src/main/java/com/joycrew/backend/service/TossBillingChargeService.java new file mode 100644 index 0000000..5863ff8 --- /dev/null +++ b/src/main/java/com/joycrew/backend/service/TossBillingChargeService.java @@ -0,0 +1,106 @@ +package com.joycrew.backend.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.joycrew.backend.entity.Company; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; + +@Service +@RequiredArgsConstructor +@Slf4j +public class TossBillingChargeService { + + @Value("${toss.secret-key}") + private String secretKey; + + @Value("${subscription.monthly-price}") + private long monthlyPrice; + + private final RestTemplate restTemplate = new RestTemplate(); + private final ObjectMapper objectMapper = new ObjectMapper(); + + private static final String BILLING_URL = + "https://api.tosspayments.com/v1/billing/{billingKey}"; + + public TossChargeResult charge(Company company, String orderId) { + String billingKey = company.getTossBillingKey(); + String customerKey = company.getTossCustomerKey(); + + if (billingKey == null || customerKey == null) { + throw new IllegalStateException("Company has no billingKey/customerKey"); + } + + String auth = Base64.getEncoder().encodeToString((secretKey + ":") + .getBytes(StandardCharsets.UTF_8)); + + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "Basic " + auth); + headers.setContentType(MediaType.APPLICATION_JSON); + + Map body = new HashMap<>(); + body.put("customerKey", customerKey); + body.put("orderId", orderId); + body.put("amount", monthlyPrice); + body.put("orderName", "JoyCrew 월 구독"); + + HttpEntity> entity = new HttpEntity<>(body, headers); + + ResponseEntity response = + restTemplate.postForEntity(BILLING_URL, entity, String.class, billingKey); + + String raw = response.getBody(); + + if (!response.getStatusCode().is2xxSuccessful()) { + return TossChargeResult.failed( + "HTTP_" + response.getStatusCode().value(), + "Billing failed", + raw + ); + } + + String paymentKey = null; + LocalDateTime approvedAt = null; + + try { + if (raw != null) { + JsonNode node = objectMapper.readTree(raw); + if (node.hasNonNull("paymentKey")) paymentKey = node.get("paymentKey").asText(); + if (node.hasNonNull("approvedAt")) { + approvedAt = LocalDateTime.parse(node.get("approvedAt").asText().replace("Z", "")); + } + } + } catch (Exception e) { + log.warn("[TOSS-PARSE-WARN] cannot parse billing response. raw={}", raw); + } + + return TossChargeResult.success(paymentKey, approvedAt, raw); + } + + public record TossChargeResult( + boolean success, + String paymentKey, + LocalDateTime approvedAt, + String failCode, + String failMessage, + String rawResponse + ) { + public static TossChargeResult success(String paymentKey, LocalDateTime approvedAt, String raw) { + return new TossChargeResult(true, paymentKey, approvedAt, null, null, raw); + } + + public static TossChargeResult failed(String failCode, String failMessage, String raw) { + return new TossChargeResult(false, null, null, failCode, failMessage, raw); + } + } +} diff --git a/src/main/java/com/joycrew/backend/service/TossBillingKeyService.java b/src/main/java/com/joycrew/backend/service/TossBillingKeyService.java new file mode 100644 index 0000000..9d4d81d --- /dev/null +++ b/src/main/java/com/joycrew/backend/service/TossBillingKeyService.java @@ -0,0 +1,57 @@ +package com.joycrew.backend.service; + +import com.joycrew.backend.dto.toss.TossIssueBillingKeyResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; + +@Service +@RequiredArgsConstructor +@Slf4j +public class TossBillingKeyService { + + @Value("${toss.secret-key}") + private String secretKey; + + private final RestTemplate restTemplate = new RestTemplate(); + + private static final String ISSUE_URL = + "https://api.tosspayments.com/v1/billing/authorizations/issue"; // 공식 엔드포인트 :contentReference[oaicite:3]{index=3} + + public String issueBillingKey(String authKey, String customerKey) { + String auth = Base64.getEncoder().encodeToString((secretKey + ":") + .getBytes(StandardCharsets.UTF_8)); + + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "Basic " + auth); + headers.setContentType(MediaType.APPLICATION_JSON); + + Map body = new HashMap<>(); + body.put("authKey", authKey); + body.put("customerKey", customerKey); + + HttpEntity> entity = new HttpEntity<>(body, headers); + + ResponseEntity response = + restTemplate.postForEntity(ISSUE_URL, entity, TossIssueBillingKeyResponse.class); + + if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) { + throw new IllegalStateException("Issue billingKey failed"); + } + + String billingKey = response.getBody().billingKey(); + if (billingKey == null || billingKey.isBlank()) { + throw new IllegalStateException("billingKey is empty"); + } + + return billingKey; + } +} diff --git a/src/main/java/com/joycrew/backend/tenant/DomainTenantFilter.java b/src/main/java/com/joycrew/backend/tenant/DomainTenantFilter.java index a082794..0ad2eaf 100644 --- a/src/main/java/com/joycrew/backend/tenant/DomainTenantFilter.java +++ b/src/main/java/com/joycrew/backend/tenant/DomainTenantFilter.java @@ -22,18 +22,18 @@ public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { HttpServletRequest http = (HttpServletRequest) req; - String host = extractHost(http); - String normalized = normalizeHost(host); + String host = extractHost(http); // X-Forwarded-Host 우선 + String normalized = normalizeHost(host); // 포트 제거, 소문자 변환 - // 🚨 [핵심 수정] 공통 도메인(메인, 로컬)은 테넌트 설정(필터) 없이 그냥 통과! - // 이유: 로그인 시 전체 회사를 뒤져서 유저를 찾아야 하기 때문. + // 공통 도메인(메인, API, 로컬)은 테넌트 설정 없이 통과 + // 이유: 로그인 등에서 전체 회사를 조회해야 하는 경우가 있기 때문 if (isCommonDomain(normalized)) { chain.doFilter(req, res); return; } Long companyId = resolveCompanyId(normalized) - .orElseGet(this::fallbackCompanyId); + .orElseGet(this::fallbackCompanyId); // 없으면 기본값(개발/로컬용) try { TenantContext.set(companyId); @@ -59,7 +59,9 @@ private Optional resolveCompanyId(String host) { } private Long fallbackCompanyId() { - return 1L; // 알 수 없는 서브도메인일 때만 1번으로 fallback + // 운영에선 404(UNKNOWN DOMAIN)로 처리하고 싶다면 예외를 던지도록 바꾸세요. + // throw new ServletException("Unknown domain"); + return 1L; // 개발/로컬 환경 기본 테넌트 } private String extractHost(HttpServletRequest http) { @@ -70,7 +72,7 @@ private String extractHost(HttpServletRequest http) { private String normalizeHost(String host) { if (host == null) return null; - int idx = host.indexOf(':'); + int idx = host.indexOf(':'); // :443 등 제거 String h = (idx > -1) ? host.substring(0, idx) : host; return h.toLowerCase(); } diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 6a3feec..88b9281 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -71,4 +71,9 @@ kakao: joycrew: points: - krw_per_point: ${JOYCREW_POINTS_KRW_PER_POINT:40} \ No newline at end of file + krw_per_point: ${JOYCREW_POINTS_KRW_PER_POINT:40} + +management: + health: + mail: + enabled: false \ No newline at end of file diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index b7a8d91..eb78468 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -1,7 +1,12 @@ +# src/main/resources/application-prod.yml + server: tomcat: accesslog: enabled: true + directory: /home/ec2-user/app/logs # 권한 문제 해결을 위해 로그 경로 변경 + prefix: access_log + suffix: .log pattern: common spring: @@ -20,6 +25,7 @@ spring: ddl-auto: update properties: hibernate: + # 환경 변수 로드 실패 시를 대비해 Dialect 명시 dialect: org.hibernate.dialect.MySQL8Dialect show-sql: false @@ -56,8 +62,11 @@ joycrew: points: krw_per_point: ${JOYCREW_POINTS_KRW_PER_POINT:40} -# 파일 로깅 삭제 -> 콘솔 로깅 유지 logging: + file: + name: /home/ec2-user/app/logs/app.log + max-size: 10MB + max-history: 7 level: root: INFO com.joycrew.backend: INFO \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index a87cb5c..5b3dbed 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -59,4 +59,10 @@ management: include: health, info, prometheus metrics: tags: - application: joycrew-backend \ No newline at end of file + application: joycrew-backend + +toss: + secret-key: test_gsk_docs_OaPz8L5KdmQXkzRz3y47BMw6 + +subscription: + monthly-price: 50000