Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,18 @@ public ResponseEntity<List<ExternalProductResponse>> listKakaoByCategory(
@PathVariable String category,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "POPULAR") SortOption sort
@RequestParam(defaultValue = "POPULAR") SortOption sort,
@RequestParam(required = false) String searchName // 상품명 검색 파라미터 추가
) {
GiftCategory gc = GiftCategory.valueOf(category.toUpperCase());
return ResponseEntity.ok(catalog.listByCategory(gc, page, size, sort));

// 서비스 레이어에도 searchName 파라미터 전달 (ExternalCatalogService.listByCategory 수정 필요)
return ResponseEntity.ok(catalog.listByCategory(gc, page, size, sort, searchName));
}

@GetMapping(value = "/kakao/product/{templateId}", produces = "application/json; charset=UTF-8")
public ResponseEntity<ExternalProductDetailResponse> detailPoints(@PathVariable String templateId) {
var resp = catalog.getDetailWithPoints(templateId);
return resp == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(resp);
}
}
}
43 changes: 22 additions & 21 deletions src/main/java/com/joycrew/backend/controller/KycController.java
Original file line number Diff line number Diff line change
@@ -1,17 +1,13 @@
package com.joycrew.backend.controller;

import com.joycrew.backend.dto.kyc.PhoneStartRequest;
import com.joycrew.backend.dto.kyc.PhoneStartResponse;
import com.joycrew.backend.dto.kyc.PhoneVerifyRequest;
import com.joycrew.backend.dto.kyc.PhoneVerifyResponse;
import com.joycrew.backend.dto.kyc.*;
import com.joycrew.backend.service.PhoneVerificationService;
import com.joycrew.backend.repository.EmployeeRepository;
import com.joycrew.backend.util.EmailMasker;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;

import java.time.LocalDateTime;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
Expand All @@ -35,25 +31,30 @@ public PhoneVerifyResponse verify(@RequestBody @Valid PhoneVerifyRequest req) {
// 1) 코드 검증 + KYC 토큰 생성 + phone 획득
var r = svc.verify(req.requestId(), req.code());

// 2) 해당 phone으로 직원들 조회 → 이메일/최근로그인 추출
// 2) 해당 phone으로 직원들 조회
var employees = employeeRepo.findByPhoneNumber(r.phone());

List<String> emails = employees.stream()
.flatMap(e -> Stream.of(e.getEmail(), e.getPersonalEmail()))
.filter(Objects::nonNull)
.map(EmailMasker::mask)
.distinct()
// 3) [수정된 로직]
// Employee 엔티티를 VerifiedEmailInfo DTO 리스트로 변환
// (회사 이메일과 개인 이메일을 별도 항목으로 취급)
List<VerifiedEmailInfo> accounts = employees.stream()
.flatMap(e -> Stream.of(
// 회사 이메일 정보
new VerifiedEmailInfo(EmailMasker.mask(e.getEmail()), e.getLastLoginAt()),
// 개인 이메일 정보 (null이 아닐 경우에만 생성)
(e.getPersonalEmail() != null)
? new VerifiedEmailInfo(EmailMasker.mask(e.getPersonalEmail()), e.getLastLoginAt())
: null
))
.filter(Objects::nonNull) // personalEmail이 null이었던 스트림 제거
.filter(info -> info.email() != null) // 마스킹된 이메일이 null이 아닌 경우
.distinct() // (이메일, 날짜)가 완전히 동일한 경우 중복 제거
// 최근 로그인 날짜 기준으로 내림차순 정렬 (프론트 편의성)
.sorted(Comparator.comparing(VerifiedEmailInfo::recentLoginAt, Comparator.nullsLast(Comparator.reverseOrder())))
.toList();

LocalDateTime recent = employees.stream()
.map(e -> e.getLastLoginAt()) // LocalDateTime
.filter(Objects::nonNull)
.max(Comparator.naturalOrder())
.orElse(null);

String recentStr = (recent != null) ? recent.toString() : null; // ISO-8601 문자열

// 3) 응답
return new PhoneVerifyResponse(r.verified(), r.kycToken(), emails, recentStr);
// 4) [수정된 생성자 호출] (3-인수)
return new PhoneVerifyResponse(r.verified(), r.kycToken(), accounts);
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@

import java.util.List;

/**
* 핸드폰 인증 완료 시 최종 응답 DTO
*/
public record PhoneVerifyResponse(
boolean verified,
String kycToken,
List<String> emails,
String recentLoginAt
List<VerifiedEmailInfo> accounts // 기존 List<String> emails, String recentLoginAt -> List<VerifiedEmailInfo> accounts
) {}
11 changes: 11 additions & 0 deletions src/main/java/com/joycrew/backend/dto/kyc/VerifiedEmailInfo.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.joycrew.backend.dto.kyc;

import java.time.LocalDateTime;

/**
* KYC(본인인증) 완료 시 반환하는 계정(이메일)과 최근 로그인 날짜 DTO
*/
public record VerifiedEmailInfo(
String email,
LocalDateTime recentLoginAt // JSON 직렬화 시 ISO-8601 날짜 문자열로 자동 변환
) {}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import org.springframework.http.HttpStatus;
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 java.time.LocalDateTime;
Expand All @@ -13,32 +14,45 @@
@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());
}

@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());
}

@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());
}

@ExceptionHandler(BadCredentialsException.class)
public ResponseEntity<ErrorResponse> handleAuthenticationFailed(BadCredentialsException ex, HttpServletRequest req) { // 1. HttpServletRequest 추가
ErrorResponse errorResponse = new ErrorResponse(
"AUTHENTICATION_FAILED",
ex.getMessage(),
LocalDateTime.now(),
req.getRequestURI()
);
return new ResponseEntity<>(errorResponse, HttpStatus.UNAUTHORIZED);
}
}
@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());
}

@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());
}

@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());
}

// '가입되지 않은 이메일' 처리
@ExceptionHandler(UsernameNotFoundException.class)
public ResponseEntity<ErrorResponse> handleUsernameNotFound(UsernameNotFoundException ex, HttpServletRequest req) {
ErrorResponse errorResponse = new ErrorResponse(
"AUTH_002", // 가입되지 않은 이메일
"이메일 또는 비밀번호가 잘못되었습니다.", // 보안을 위해 메시지는 동일하게 유지
LocalDateTime.now(),
req.getRequestURI()
);
return new ResponseEntity<>(errorResponse, HttpStatus.UNAUTHORIZED);
}

// '비밀번호 불일치' 처리
@ExceptionHandler(BadCredentialsException.class)
public ResponseEntity<ErrorResponse> handleAuthenticationFailed(BadCredentialsException ex, HttpServletRequest req) {
ErrorResponse errorResponse = new ErrorResponse(
"AUTH_003", // 비밀번호 불일치
"이메일 또는 비밀번호가 잘못되었습니다.", // 보안을 위해 메시지는 동일하게 유지
LocalDateTime.now(),
req.getRequestURI()
);
return new ResponseEntity<>(errorResponse, HttpStatus.UNAUTHORIZED);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,10 @@
import org.springframework.data.jpa.repository.JpaRepository;

public interface KakaoTemplateRepository extends JpaRepository<KakaoTemplate, String> {

// Existing method
Page<KakaoTemplate> findByJoyCategory(GiftCategory category, Pageable pageable);

// [NEW] Added method for searching by name (resolves error in ExternalCatalogService)
Page<KakaoTemplate> findByJoyCategoryAndNameContainingIgnoreCase(GiftCategory category, String name, Pageable pageable);
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.*;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils; // 1. StringUtils import 추가

import java.util.List;

Expand All @@ -22,14 +23,28 @@ public class ExternalCatalogService {
@Value("${joycrew.points.krw_per_point:40}")
private int krwPerPoint;

public List<ExternalProductResponse> listByCategory(GiftCategory category, int page, int size, SortOption sort) {
// 2. 메소드 시그니처에 searchName 파라미터 추가
public List<ExternalProductResponse> listByCategory(GiftCategory category, int page, int size, SortOption sort, String searchName) {
Sort s = switch (sort) {
case PRICE_ASC -> Sort.by(Sort.Direction.ASC, "basePriceKrw");
case PRICE_DESC -> Sort.by(Sort.Direction.DESC, "basePriceKrw");
case POPULAR, NEW -> Sort.by(Sort.Direction.DESC, "updatedAt");
};
Page<KakaoTemplate> p = templateRepo.findByJoyCategory(category, PageRequest.of(page, size, s));

Pageable pageRequest = PageRequest.of(page, size, s);
Page<KakaoTemplate> p;

// 3. searchName (검색어) 유무에 따라 분기 처리
if (StringUtils.hasText(searchName)) {
// 검색어가 있는 경우: 이름으로 검색
// (참고: KakaoTemplateRepository에 이 메소드가 정의되어 있어야 합니다)
p = templateRepo.findByJoyCategoryAndNameContainingIgnoreCase(category, searchName, pageRequest);
} else {
// 검색어가 없는 경우: 기존 로직 (카테고리로만 조회)
p = templateRepo.findByJoyCategory(category, pageRequest);
}

// 4. 조회 결과를 DTO로 변환하여 반환
return p.getContent().stream().map(t -> {
int point = (int) Math.ceil(t.getBasePriceKrw() / (double) krwPerPoint);
return new ExternalProductResponse(
Expand All @@ -53,3 +68,4 @@ public ExternalProductDetailResponse getDetailWithPoints(String templateId) {
);
}
}

74 changes: 74 additions & 0 deletions src/main/resources/application-dev.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# H2 Database settings for 'dev' profile
spring:
# --- H2 Datasource Configuration ---
datasource:
# Use H2 in-memory database named 'testdb'
# DB_CLOSE_DELAY=-1 keeps the database alive as long as the JVM is running
url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1
# H2 JDBC Driver
driver-class-name: org.h2.Driver
# Default H2 username
username: sa
# Default H2 password (can be empty or set)
password: password

# --- H2 Console Configuration (Optional but recommended) ---
h2:
console:
# Enable H2 web console
enabled: true
# Access path for H2 console (e.g., http://localhost:8082/h2-console)
path: /h2-console
settings:
# Allows access from web browsers
web-allow-others: false
# Disables trace output in console
trace: false

# --- JPA/Hibernate Configuration ---
jpa:
# Let Hibernate automatically detect H2 dialect (no need to specify)
# database-platform: org.hibernate.dialect.H2Dialect # Optional
hibernate:
# Automatically update schema based on entities (create/update tables)
ddl-auto: update
properties:
hibernate:
# format_sql: true # Optional: Pretty print SQL logs
# use_sql_comments: true # Optional: Add comments to SQL logs
# Show executed SQL statements in the logs (optional)
show-sql: true

# --- Other configurations (like jwt, mail, etc. remain the same) ---
jwt:
expiration-ms: 3600000
password-reset-expiration-ms: 900000
secret: ${JWT_SECRET:defaultSecretValueForDev} # Provide a default for dev if env var is not set

app:
frontend-url: http://localhost:3000 # Use localhost for dev frontend
sms:
provider: solapi
from-number: 123
kyc:
token-secret: 123
token-ttl-minutes: 10

solapi:
api-key: 123
api-secret: 123
base-url: "https://api.solapi.com"

kakao:
giftbiz:
base-url: 123
api-key: 123
timeout-ms: 5000
callback:
success-url: 123
fail-url: 123
gift-cancel-url: 123

joycrew:
points:
krw_per_point: ${JOYCREW_POINTS_KRW_PER_POINT:40}