Skip to content

Commit 348c3da

Browse files
authored
Merge pull request #63 from 9oormthon-univ/feat/store
Feat : 포인트 상점 기능 구현
2 parents fd447f3 + 2f93e15 commit 348c3da

33 files changed

+796
-63
lines changed

build.gradle

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ dependencies {
6262
// qr
6363
implementation group: 'com.google.zxing', name: 'javase', version: '3.5.4'
6464
implementation group: 'com.google.zxing', name: 'core', version: '3.5.4'
65+
66+
// retry
67+
implementation "org.springframework.retry:spring-retry"
68+
implementation "org.springframework:spring-aspects"
6569
}
6670

6771
dependencyManagement {

src/main/java/com/trashheroesbe/feature/auth/dto/request/LoginPartnerRequest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ public record LoginPartnerRequest(
1010
@NotBlank(message = "이메일 입력은 필수입니다.")
1111
String email,
1212

13-
@Schema(description = "패스워드", example = "usus123!")
13+
@Schema(description = "패스워드", example = "123123qwe!")
1414
@NotBlank(message = "비밀번호 입력은 필수입니다.")
1515
String password
1616
) {
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package com.trashheroesbe.feature.coupon.api;
2+
3+
import static com.trashheroesbe.global.response.type.SuccessCode.OK;
4+
5+
import com.trashheroesbe.feature.coupon.application.CouponStoreService;
6+
import com.trashheroesbe.feature.coupon.dto.request.CouponPurchaseRequest;
7+
import com.trashheroesbe.feature.coupon.dto.response.CouponStoreListResponse;
8+
import com.trashheroesbe.feature.coupon.dto.response.CouponStoreResponse;
9+
import com.trashheroesbe.feature.coupon.dto.response.PurchaseUserCouponResponse;
10+
import com.trashheroesbe.global.auth.security.CustomerDetails;
11+
import com.trashheroesbe.global.response.ApiResponse;
12+
import jakarta.validation.Valid;
13+
import java.util.List;
14+
import lombok.RequiredArgsConstructor;
15+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
16+
import org.springframework.web.bind.annotation.GetMapping;
17+
import org.springframework.web.bind.annotation.PathVariable;
18+
import org.springframework.web.bind.annotation.PostMapping;
19+
import org.springframework.web.bind.annotation.RequestBody;
20+
import org.springframework.web.bind.annotation.RequestMapping;
21+
import org.springframework.web.bind.annotation.RestController;
22+
23+
@RestController
24+
@RequiredArgsConstructor
25+
@RequestMapping("/api/v1/store/coupons")
26+
public class CouponStoreController implements CouponStoreControllerApi {
27+
28+
private final CouponStoreService couponStoreService;
29+
30+
@Override
31+
@GetMapping()
32+
public ApiResponse<List<CouponStoreListResponse>> getCouponStoreList() {
33+
List<CouponStoreListResponse> responses = couponStoreService.getCouponStoreList();
34+
return ApiResponse.success(OK, responses);
35+
}
36+
37+
@Override
38+
@GetMapping("/{couponId}")
39+
public ApiResponse<CouponStoreResponse> getCouponStoreById(@PathVariable Long couponId) {
40+
CouponStoreResponse response = couponStoreService.getCouponStoreById(couponId);
41+
return ApiResponse.success(OK, response);
42+
}
43+
44+
@Override
45+
@PostMapping("/purchase")
46+
public ApiResponse<PurchaseUserCouponResponse> purchaseCoupon(
47+
@RequestBody @Valid CouponPurchaseRequest request,
48+
@AuthenticationPrincipal CustomerDetails customerDetails
49+
) {
50+
PurchaseUserCouponResponse response = couponStoreService.purchaseCoupon(
51+
request,
52+
customerDetails.getUser()
53+
);
54+
return ApiResponse.success(OK, response);
55+
}
56+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package com.trashheroesbe.feature.coupon.api;
2+
3+
4+
import com.trashheroesbe.feature.coupon.dto.request.CouponPurchaseRequest;
5+
import com.trashheroesbe.feature.coupon.dto.response.CouponStoreListResponse;
6+
import com.trashheroesbe.feature.coupon.dto.response.CouponStoreResponse;
7+
import com.trashheroesbe.feature.coupon.dto.response.PurchaseUserCouponResponse;
8+
import com.trashheroesbe.global.auth.security.CustomerDetails;
9+
import com.trashheroesbe.global.response.ApiResponse;
10+
import io.swagger.v3.oas.annotations.Operation;
11+
import io.swagger.v3.oas.annotations.tags.Tag;
12+
13+
import java.util.List;
14+
15+
@Tag(name = "Store", description = "상점 관련 API")
16+
public interface CouponStoreControllerApi {
17+
18+
@Operation(summary = "상점 아이템 전체조회", description = "상점의 아이템을 전체조회 합니다.")
19+
ApiResponse<List<CouponStoreListResponse>> getCouponStoreList();
20+
21+
@Operation(summary = "상점 아이템 상세조회", description = "상점의 아이템을 상세조회 합니다.")
22+
ApiResponse<CouponStoreResponse> getCouponStoreById(Long couponId);
23+
24+
@Operation(summary = "상점 아이템 구매하기", description = "상점의 아이템을 구매합니다.")
25+
ApiResponse<PurchaseUserCouponResponse> purchaseCoupon(
26+
CouponPurchaseRequest request,
27+
CustomerDetails customerDetails
28+
);
29+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package com.trashheroesbe.feature.coupon.api;
2+
3+
import static com.trashheroesbe.global.response.type.SuccessCode.OK;
4+
5+
6+
import com.trashheroesbe.feature.coupon.application.UserCouponService;
7+
import com.trashheroesbe.feature.coupon.dto.response.UserCouponListResponse;
8+
import com.trashheroesbe.feature.coupon.dto.response.UserCouponResponse;
9+
import com.trashheroesbe.global.auth.security.CustomerDetails;
10+
import com.trashheroesbe.global.response.ApiResponse;
11+
import java.util.List;
12+
import lombok.RequiredArgsConstructor;
13+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
14+
import org.springframework.web.bind.annotation.GetMapping;
15+
import org.springframework.web.bind.annotation.PathVariable;
16+
import org.springframework.web.bind.annotation.RequestMapping;
17+
import org.springframework.web.bind.annotation.RestController;
18+
19+
@RestController
20+
@RequiredArgsConstructor
21+
@RequestMapping("/api/v1/my/coupons")
22+
public class UserCouponController implements UserCouponControllerApi {
23+
24+
private final UserCouponService userCouponService;
25+
26+
@Override
27+
@GetMapping
28+
public ApiResponse<List<UserCouponListResponse>> getUserCouponList(
29+
@AuthenticationPrincipal CustomerDetails customerDetails
30+
) {
31+
List<UserCouponListResponse> responses = userCouponService.getUserCouponList(
32+
customerDetails.getUser());
33+
return ApiResponse.success(OK, responses);
34+
}
35+
36+
@Override
37+
@GetMapping("/{userCouponId}")
38+
public ApiResponse<UserCouponResponse> getUserCouponById(
39+
@PathVariable Long userCouponId,
40+
@AuthenticationPrincipal CustomerDetails customerDetails
41+
) {
42+
UserCouponResponse response = userCouponService.getUserCouponById(
43+
userCouponId,
44+
customerDetails.getUser()
45+
);
46+
return ApiResponse.success(OK, response);
47+
}
48+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package com.trashheroesbe.feature.coupon.api;
2+
3+
import com.trashheroesbe.feature.coupon.dto.response.CouponStoreListResponse;
4+
import com.trashheroesbe.feature.coupon.dto.response.UserCouponListResponse;
5+
import com.trashheroesbe.feature.coupon.dto.response.UserCouponResponse;
6+
import com.trashheroesbe.global.auth.security.CustomerDetails;
7+
import com.trashheroesbe.global.response.ApiResponse;
8+
import io.swagger.v3.oas.annotations.Operation;
9+
import io.swagger.v3.oas.annotations.tags.Tag;
10+
import java.util.List;
11+
12+
@Tag(name = "UserCoupon", description = "내가 구매한 쿠폰 관련 API")
13+
public interface UserCouponControllerApi {
14+
15+
@Operation(summary = "내가 구매한 쿠폰 전체조회", description = "내가 구매한 쿠폰을 전체조회 합니다.")
16+
ApiResponse<List<UserCouponListResponse>> getUserCouponList(CustomerDetails customerDetails);
17+
18+
@Operation(summary = "내가 구매한 쿠폰 상세조회", description = "내가 구매한 쿠폰을 상세조회 합니다.")
19+
ApiResponse<UserCouponResponse> getUserCouponById(
20+
Long userCouponId,
21+
CustomerDetails customerDetails
22+
);
23+
}

src/main/java/com/trashheroesbe/feature/coupon/application/CouponService.java

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package com.trashheroesbe.feature.coupon.application;
22

3-
import com.trashheroesbe.feature.coupon.domain.Coupon;
3+
import static com.trashheroesbe.global.response.type.ErrorCode.ENTITY_NOT_FOUND;
4+
5+
import com.trashheroesbe.feature.coupon.domain.entity.Coupon;
46
import com.trashheroesbe.feature.coupon.dto.request.CouponCreateRequest;
57
import com.trashheroesbe.feature.coupon.dto.response.CouponCreateResponse;
68
import com.trashheroesbe.feature.coupon.dto.response.CouponQrResponse;
@@ -30,10 +32,12 @@ public class CouponService {
3032
@Value("${qr.coupon-url}")
3133
private String couponQrBaseUrl;
3234

33-
public CouponCreateResponse createCoupon(CustomerDetails customerDetails,
34-
CouponCreateRequest request) {
35-
Long partnerId = extractPartnerId(customerDetails);
36-
Coupon saved = couponRepository.save(Coupon.create(request, partnerId));
35+
public CouponCreateResponse createCoupon(
36+
CustomerDetails customerDetails,
37+
CouponCreateRequest request
38+
) {
39+
Partner partner = extractPartner(customerDetails);
40+
Coupon saved = couponRepository.save(Coupon.create(request, partner));
3741

3842
String qrToken = UUID.randomUUID().toString();
3943
String payload = CouponQrUtil.buildPayload(couponQrBaseUrl, saved.getId(), qrToken);
@@ -51,11 +55,12 @@ public CouponQrResponse findByQr(Long couponId, String qrToken) {
5155
throw new BusinessException(ErrorCode.VALIDATION_FAILED);
5256
}
5357
Coupon coupon = couponRepository.findByIdAndQrToken(couponId, qrToken)
54-
.orElseThrow(() -> new BusinessException(ErrorCode.ENTITY_NOT_FOUND));
58+
.orElseThrow(() -> new BusinessException(ENTITY_NOT_FOUND));
59+
5560
return CouponQrResponse.from(coupon);
5661
}
5762

58-
private Long extractPartnerId(CustomerDetails customerDetails) {
63+
private Partner extractPartner(CustomerDetails customerDetails) {
5964
if (customerDetails == null || customerDetails.getUser() == null) {
6065
throw new BusinessException(ErrorCode.ACCESS_DENIED_EXCEPTION);
6166
}
@@ -64,6 +69,6 @@ private Long extractPartnerId(CustomerDetails customerDetails) {
6469
throw new BusinessException(ErrorCode.ACCESS_DENIED_EXCEPTION);
6570
}
6671

67-
return partner.getId();
72+
return partner;
6873
}
6974
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package com.trashheroesbe.feature.coupon.application;
2+
3+
import static com.trashheroesbe.global.response.type.ErrorCode.COUPON_NOT_AVAILABLE;
4+
import static com.trashheroesbe.global.response.type.ErrorCode.COUPON_NOT_FOUND;
5+
import static com.trashheroesbe.global.response.type.ErrorCode.COUPON_OUT_OF_STOCK;
6+
import static com.trashheroesbe.global.response.type.ErrorCode.ENTITY_NOT_FOUND;
7+
8+
import com.trashheroesbe.feature.coupon.domain.entity.Coupon;
9+
import com.trashheroesbe.feature.coupon.domain.entity.UserCoupon;
10+
import com.trashheroesbe.feature.coupon.dto.request.CouponPurchaseRequest;
11+
import com.trashheroesbe.feature.coupon.dto.response.CouponStoreListResponse;
12+
import com.trashheroesbe.feature.coupon.dto.response.CouponStoreResponse;
13+
import com.trashheroesbe.feature.coupon.dto.response.PurchaseUserCouponResponse;
14+
import com.trashheroesbe.feature.coupon.infrastructure.CouponRepository;
15+
import com.trashheroesbe.feature.coupon.infrastructure.UserCouponRepository;
16+
import com.trashheroesbe.feature.point.application.PointService;
17+
import com.trashheroesbe.feature.point.domain.type.PointReason;
18+
import com.trashheroesbe.feature.user.domain.entity.User;
19+
import com.trashheroesbe.global.exception.BusinessException;
20+
import java.util.List;
21+
import java.util.stream.Collectors;
22+
import lombok.RequiredArgsConstructor;
23+
import lombok.extern.slf4j.Slf4j;
24+
import org.springframework.orm.ObjectOptimisticLockingFailureException;
25+
import org.springframework.retry.annotation.Backoff;
26+
import org.springframework.retry.annotation.Retryable;
27+
import org.springframework.stereotype.Service;
28+
import org.springframework.transaction.annotation.Transactional;
29+
30+
@Slf4j
31+
@Service
32+
@Transactional(readOnly = true)
33+
@RequiredArgsConstructor
34+
public class CouponStoreService {
35+
36+
private final PointService pointService;
37+
38+
private final CouponRepository couponRepository;
39+
private final UserCouponRepository userCouponRepository;
40+
41+
public List<CouponStoreListResponse> getCouponStoreList() {
42+
List<Coupon> coupons = couponRepository.findAll();
43+
return coupons.stream()
44+
.map(CouponStoreListResponse::from)
45+
.collect(Collectors.toList());
46+
}
47+
48+
public CouponStoreResponse getCouponStoreById(Long couponId) {
49+
Coupon coupon = couponRepository.findByIdWithPartner(couponId)
50+
.orElseThrow(() -> new BusinessException(COUPON_NOT_FOUND));
51+
return CouponStoreResponse.from(coupon);
52+
}
53+
54+
@Transactional
55+
@Retryable(
56+
retryFor = ObjectOptimisticLockingFailureException.class,
57+
maxAttempts = 3,
58+
backoff = @Backoff(delay = 100)
59+
)
60+
public PurchaseUserCouponResponse purchaseCoupon(
61+
CouponPurchaseRequest request,
62+
User user
63+
) {
64+
Coupon coupon = couponRepository.findByIdWithPartner(request.couponId())
65+
.orElseThrow(() -> new BusinessException(COUPON_NOT_FOUND));
66+
67+
if (!coupon.getIsActive()) {
68+
throw new BusinessException(COUPON_NOT_AVAILABLE);
69+
}
70+
71+
if (coupon.getIssuedCount() >= coupon.getTotalStock()) {
72+
throw new BusinessException(COUPON_OUT_OF_STOCK);
73+
}
74+
75+
Integer pointCost = coupon.getPointCost();
76+
pointService.usePoint(
77+
user.getId(),
78+
pointCost,
79+
PointReason.COUPON_PURCHASE,
80+
request.couponId()
81+
);
82+
83+
coupon.issue();
84+
85+
UserCoupon userCoupon = UserCoupon.create(user, coupon);
86+
UserCoupon savedUserCoupon = userCouponRepository.save(userCoupon);
87+
88+
log.info("쿠폰 구매 완료 - userId: {}, couponId: {}, pointsUsed: {}",
89+
user.getId(), request.couponId(), pointCost);
90+
91+
return PurchaseUserCouponResponse.from(savedUserCoupon);
92+
}
93+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package com.trashheroesbe.feature.coupon.application;
2+
3+
import static com.trashheroesbe.global.response.type.ErrorCode.ENTITY_NOT_FOUND;
4+
5+
import com.trashheroesbe.feature.coupon.domain.entity.UserCoupon;
6+
import com.trashheroesbe.feature.coupon.dto.response.UserCouponListResponse;
7+
import com.trashheroesbe.feature.coupon.dto.response.UserCouponResponse;
8+
import com.trashheroesbe.feature.coupon.infrastructure.UserCouponRepository;
9+
import com.trashheroesbe.feature.user.domain.entity.User;
10+
import com.trashheroesbe.global.exception.BusinessException;
11+
import com.trashheroesbe.global.response.type.ErrorCode;
12+
import java.util.List;
13+
import java.util.stream.Collectors;
14+
import lombok.RequiredArgsConstructor;
15+
import org.springframework.stereotype.Service;
16+
17+
@Service
18+
@RequiredArgsConstructor
19+
public class UserCouponService {
20+
21+
private final UserCouponRepository userCouponRepository;
22+
23+
public List<UserCouponListResponse> getUserCouponList(User user) {
24+
25+
List<UserCoupon> userCouponList = userCouponRepository.findByUserId(user.getId());
26+
return userCouponList.stream()
27+
.map(UserCouponListResponse::from)
28+
.collect(Collectors.toList());
29+
}
30+
31+
public UserCouponResponse getUserCouponById(Long userCouponId, User user) {
32+
UserCoupon userCoupon = userCouponRepository.findById(userCouponId)
33+
.orElseThrow(() -> new BusinessException(ENTITY_NOT_FOUND));
34+
35+
if (!userCoupon.getUser().getId().equals(user.getId())) {
36+
throw new BusinessException(ErrorCode.ACCESS_DENIED_EXCEPTION);
37+
}
38+
39+
return UserCouponResponse.from(userCoupon);
40+
}
41+
}

0 commit comments

Comments
 (0)