From 96081de7c07930469e4d3af296e3b4ab39daa87b Mon Sep 17 00:00:00 2001 From: "[leedongguk]" <[ldg4077@nate.com]> Date: Thu, 27 Nov 2025 10:11:52 +0900 Subject: [PATCH 1/6] =?UTF-8?q?"=EB=81=9D"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../carrot_server/config/SecurityConfig.java | 5 +- .../group/controller/GroupController.java | 10 +- .../group/dto/GroupJoinRequestResponse.java | 20 ++++ .../GroupJoinRequestRepository.java | 5 +- .../group/service/GroupService.java | 17 +++ .../payment/controller/PaymentController.java | 13 +- .../payment/service/PaymentService.java | 32 +++-- .../product/controller/ProductController.java | 16 +++ .../product/controller/ReviewController.java | 57 +++++++++ .../dto/request/ReviewCreateRequest.java | 21 ++++ .../product/dto/response/ReviewResponse.java | 21 ++++ .../carrot_server/product/entity/Review.java | 47 ++++++++ .../product/repository/ReviewRepository.java | 14 +++ .../product/service/ReviewService.java | 113 ++++++++++++++++++ .../region/controller/MyRegionController.java | 33 +++++ .../region/dto/ChangeRegionRequest.java | 11 ++ .../region/dto/UserRegionResponse.java | 27 +++++ .../repository/UserRegionRepository.java | 15 ++- .../region/service/UserRegionService.java | 59 +++++++++ .../service/TransactionService.java | 94 +++++++++++++++ .../web/TransactionController.java | 71 +++++++++++ .../user/repository/UserRepository.java | 2 + .../user/service/UserService.java | 6 + 23 files changed, 689 insertions(+), 20 deletions(-) create mode 100644 src/main/java/com/mrokga/carrot_server/group/dto/GroupJoinRequestResponse.java create mode 100644 src/main/java/com/mrokga/carrot_server/product/controller/ReviewController.java create mode 100644 src/main/java/com/mrokga/carrot_server/product/dto/request/ReviewCreateRequest.java create mode 100644 src/main/java/com/mrokga/carrot_server/product/dto/response/ReviewResponse.java create mode 100644 src/main/java/com/mrokga/carrot_server/product/entity/Review.java create mode 100644 src/main/java/com/mrokga/carrot_server/product/repository/ReviewRepository.java create mode 100644 src/main/java/com/mrokga/carrot_server/product/service/ReviewService.java create mode 100644 src/main/java/com/mrokga/carrot_server/region/controller/MyRegionController.java create mode 100644 src/main/java/com/mrokga/carrot_server/region/dto/ChangeRegionRequest.java create mode 100644 src/main/java/com/mrokga/carrot_server/region/dto/UserRegionResponse.java create mode 100644 src/main/java/com/mrokga/carrot_server/region/service/UserRegionService.java create mode 100644 src/main/java/com/mrokga/carrot_server/transaction/service/TransactionService.java create mode 100644 src/main/java/com/mrokga/carrot_server/transaction/web/TransactionController.java diff --git a/src/main/java/com/mrokga/carrot_server/config/SecurityConfig.java b/src/main/java/com/mrokga/carrot_server/config/SecurityConfig.java index e08c250..fb2f8b6 100644 --- a/src/main/java/com/mrokga/carrot_server/config/SecurityConfig.java +++ b/src/main/java/com/mrokga/carrot_server/config/SecurityConfig.java @@ -28,7 +28,10 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers( "/actuator/health", "/actuator/info", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html", "/api/theTest", "/__test-error", - "/api/**" + "/api/**", + "/payment/kakao/success", + "/payment/kakao/cancel", + "/payment/kakao/fail" ).permitAll() .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() // 추가 .anyRequest().authenticated() diff --git a/src/main/java/com/mrokga/carrot_server/group/controller/GroupController.java b/src/main/java/com/mrokga/carrot_server/group/controller/GroupController.java index 8f78fb8..218793b 100644 --- a/src/main/java/com/mrokga/carrot_server/group/controller/GroupController.java +++ b/src/main/java/com/mrokga/carrot_server/group/controller/GroupController.java @@ -201,14 +201,16 @@ public ResponseEntity leave(@PathVariable Integer id) { @Operation(summary = "가입 요청 목록", description = "OWNER/MANAGER만 조회 가능. status=PENDING/APPROVED/REJECTED") @GetMapping("/{id}/requests") - public Page requests(@PathVariable Integer id, - @RequestParam(defaultValue = "PENDING") String status, - @ParameterObject @PageableDefault(size = 20) Pageable pg) { + public Page requests(@PathVariable Integer id, + @RequestParam(defaultValue = "PENDING") String status, + @ParameterObject @PageableDefault(size = 20) Pageable pg) { + var actor = membershipRepository.findByGroupIdAndUserId(id, me()) .orElseThrow(() -> new ResponseStatusException(HttpStatus.FORBIDDEN)); if (actor.getRole() == GroupMembership.Role.MEMBER) throw new ResponseStatusException(HttpStatus.FORBIDDEN); - return joinRequestRepository.findByGroupIdAndStatus(id, GroupJoinRequest.Status.valueOf(status), pg); + + return groupService.listJoinRequests(id, GroupJoinRequest.Status.valueOf(status), pg); } @Operation(summary = "가입 승인", description = "OWNER/MANAGER 권한 필요") diff --git a/src/main/java/com/mrokga/carrot_server/group/dto/GroupJoinRequestResponse.java b/src/main/java/com/mrokga/carrot_server/group/dto/GroupJoinRequestResponse.java new file mode 100644 index 0000000..4209e75 --- /dev/null +++ b/src/main/java/com/mrokga/carrot_server/group/dto/GroupJoinRequestResponse.java @@ -0,0 +1,20 @@ +package com.mrokga.carrot_server.group.dto; + +import lombok.*; + +import java.time.LocalDateTime; + +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class GroupJoinRequestResponse { + private Integer id; + private Integer groupId; + private Integer userId; + private String userNickname; + private String status; // PENDING/APPROVED/REJECTED + private String message; + private LocalDateTime createdAt; +} \ No newline at end of file diff --git a/src/main/java/com/mrokga/carrot_server/group/repository/GroupJoinRequestRepository.java b/src/main/java/com/mrokga/carrot_server/group/repository/GroupJoinRequestRepository.java index 4cbe5a7..40cbf9b 100644 --- a/src/main/java/com/mrokga/carrot_server/group/repository/GroupJoinRequestRepository.java +++ b/src/main/java/com/mrokga/carrot_server/group/repository/GroupJoinRequestRepository.java @@ -2,8 +2,11 @@ import com.mrokga.carrot_server.group.entity.GroupJoinRequest; import org.springframework.data.domain.*; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; public interface GroupJoinRequestRepository extends JpaRepository { - Page findByGroupIdAndStatus(Integer groupId, GroupJoinRequest.Status status, Pageable pageable); + @EntityGraph(attributePaths = {"user", "group"}) + Page findByGroupIdAndStatus(Integer groupId, + GroupJoinRequest.Status status, Pageable pageable); } diff --git a/src/main/java/com/mrokga/carrot_server/group/service/GroupService.java b/src/main/java/com/mrokga/carrot_server/group/service/GroupService.java index 9a4c27e..52f36d4 100644 --- a/src/main/java/com/mrokga/carrot_server/group/service/GroupService.java +++ b/src/main/java/com/mrokga/carrot_server/group/service/GroupService.java @@ -248,6 +248,23 @@ public GroupEventResponse toEventResponse(GroupEvent ev, Integer meId) { .build(); } + @Transactional(readOnly = true) + public Page listJoinRequests(Integer groupId, + GroupJoinRequest.Status status, Pageable pg) { + + return joinRequestRepository + .findByGroupIdAndStatus(groupId, status, pg) + .map(req -> GroupJoinRequestResponse.builder() + .id(req.getId()) + .groupId(req.getGroup().getId()) + .userId(req.getUser().getId()) + .userNickname(req.getUser().getNickname()) + .status(req.getStatus().name()) + .message(req.getMessage()) + .createdAt(req.getCreatedAt()) + .build()); + } + } diff --git a/src/main/java/com/mrokga/carrot_server/payment/controller/PaymentController.java b/src/main/java/com/mrokga/carrot_server/payment/controller/PaymentController.java index 403eb5a..a3782f9 100644 --- a/src/main/java/com/mrokga/carrot_server/payment/controller/PaymentController.java +++ b/src/main/java/com/mrokga/carrot_server/payment/controller/PaymentController.java @@ -8,10 +8,11 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.view.RedirectView; @RestController @RequiredArgsConstructor -@RequestMapping("/api/payment") +@RequestMapping("/payment") @Tag(name = "Payment API", description = "결제 관련 API") @Slf4j public class PaymentController { @@ -26,10 +27,12 @@ public KakaoReadyResponse kakaoReady(@PathVariable Integer transactionId) { // 결제 성공 @GetMapping("/kakao/success") - public KakaoApproveResponse kakaoSuccess(@RequestParam("transactionId") Integer transactionId, - @RequestParam("tid") String tid, - @RequestParam("pg_token") String pgToken) { - return paymentService.kakaoApprovePayment(transactionId, tid, pgToken); + public RedirectView kakaoSuccess(@RequestParam("transactionId") Integer transactionId, + @RequestParam("pg_token") String pgToken) { + + paymentService.kakaoApprovePayment(transactionId, pgToken); + + return new RedirectView("http://localhost:3000/payment/success?transactionId=" + transactionId); } // 결제 취소 diff --git a/src/main/java/com/mrokga/carrot_server/payment/service/PaymentService.java b/src/main/java/com/mrokga/carrot_server/payment/service/PaymentService.java index 320e6f2..2e2483d 100644 --- a/src/main/java/com/mrokga/carrot_server/payment/service/PaymentService.java +++ b/src/main/java/com/mrokga/carrot_server/payment/service/PaymentService.java @@ -9,6 +9,7 @@ import com.mrokga.carrot_server.payment.repository.PaymentRepository; import com.mrokga.carrot_server.transaction.entity.Transaction; import com.mrokga.carrot_server.transaction.repository.TransactionRepository; +import com.mrokga.carrot_server.transaction.service.TransactionService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -16,6 +17,8 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.LinkedMultiValueMap; @@ -33,6 +36,7 @@ public class PaymentService { private final PaymentRepository paymentRepository; private final TransactionRepository transactionRepository; + private final TransactionService transactionService; @Value("${kakaopay.host}") private String kakaoHost; @@ -48,6 +52,8 @@ public class PaymentService { private final RestTemplate restTemplate = new RestTemplate(); + + private HttpHeaders buildKakaoHeaders() { HttpHeaders headers = new HttpHeaders(); headers.set("Authorization", "KakaoAK " + kakaoAdminKey); @@ -111,14 +117,16 @@ public KakaoReadyResponse kakaoReadyPayment(Integer transactionId) { .build(); } - // ✅ 카카오페이 결제 승인 - public KakaoApproveResponse kakaoApprovePayment(Integer transactionId, String tid, String pgToken) { + /** ✅ 결제 승인 */ + public KakaoApproveResponse kakaoApprovePayment(Integer transactionId, String pgToken) { Transaction transaction = transactionRepository.findById(transactionId) .orElseThrow(() -> new IllegalArgumentException("Transaction not found")); Payment payment = paymentRepository.findByTransaction(transaction) .orElseThrow(() -> new IllegalArgumentException("Payment not found")); + String tid = payment.getTid(); + if (payment.getStatus() == PaymentStatus.APPROVED) { throw new IllegalStateException("이미 승인된 결제입니다."); } @@ -140,6 +148,9 @@ public KakaoApproveResponse kakaoApprovePayment(Integer transactionId, String ti ResponseEntity response = restTemplate.postForEntity(url, request, Map.class); Map body = response.getBody(); + if (response.getStatusCode().isError() || body == null) { + throw new IllegalStateException("카카오페이 승인 실패"); + } int kakaoAmount = (Integer) ((Map) body.get("amount")).get("total"); int expectedAmount = transaction.getProduct().getPrice(); @@ -147,15 +158,13 @@ public KakaoApproveResponse kakaoApprovePayment(Integer transactionId, String ti throw new IllegalStateException("결제 금액 불일치 (expected=" + expectedAmount + ", kakao=" + kakaoAmount + ")"); } - log.info("[KakaoPay] Approved - transactionId={}, tid={}", transactionId, tid); + // 내부 거래 완료 처리 (상품 SOLD) + transactionService.approve(transactionId); payment.setStatus(PaymentStatus.APPROVED); payment.setCompletedAt(LocalDateTime.now()); paymentRepository.save(payment); - transaction.setCompletedAt(LocalDateTime.now()); - transactionRepository.save(transaction); - return KakaoApproveResponse.builder() .aid((String) body.get("aid")) .tid((String) body.get("tid")) @@ -165,7 +174,7 @@ public KakaoApproveResponse kakaoApprovePayment(Integer transactionId, String ti .build(); } - // ✅ 카카오페이 결제 취소 + /** ✅ 결제 취소 */ public KakaoCancelResponse kakaoCancelPayment(String tid, int cancelAmount) { String url = kakaoHost + "/v1/payment/cancel"; @@ -183,6 +192,9 @@ public KakaoCancelResponse kakaoCancelPayment(String tid, int cancelAmount) { ResponseEntity response = restTemplate.postForEntity(url, request, Map.class); Map body = response.getBody(); + if (response.getStatusCode().isError() || body == null) { + throw new IllegalStateException("카카오페이 취소 실패"); + } Payment payment = paymentRepository.findByTid(tid) .orElseThrow(() -> new IllegalArgumentException("Payment not found")); @@ -191,7 +203,11 @@ public KakaoCancelResponse kakaoCancelPayment(String tid, int cancelAmount) { payment.setCompletedAt(LocalDateTime.now()); paymentRepository.save(payment); - log.info("[KakaoPay] Canceled - tid={}", tid); + // 예약 취소 처리(상품 ON_SALE 복귀) + Transaction tx = payment.getTransaction(); + if (tx != null) { + transactionService.cancel(tx.getId()); + } return KakaoCancelResponse.builder() .tid((String) body.get("tid")) diff --git a/src/main/java/com/mrokga/carrot_server/product/controller/ProductController.java b/src/main/java/com/mrokga/carrot_server/product/controller/ProductController.java index 99d9e65..f0bbc54 100644 --- a/src/main/java/com/mrokga/carrot_server/product/controller/ProductController.java +++ b/src/main/java/com/mrokga/carrot_server/product/controller/ProductController.java @@ -153,4 +153,20 @@ public ResponseEntity searchProduct(@PathVariable String keyword, return ResponseEntity.ok(ApiResponseDto.success(HttpStatus.OK.value(), "success", results)); } + + @Operation( + summary = "상품 삭제", + description = "상품 등록자(판매자)만 삭제할 수 있습니다." + ) + @DeleteMapping("/{id}") + public ResponseEntity> deleteProduct( + @PathVariable int id, + @Parameter(description = "판매자(요청자) ID", example = "11") + @RequestParam int sellerId + ) { + productService.deleteProduct(id, sellerId); + return ResponseEntity.ok( + ApiResponseDto.success(HttpStatus.OK.value(), "success", null) + ); + } } diff --git a/src/main/java/com/mrokga/carrot_server/product/controller/ReviewController.java b/src/main/java/com/mrokga/carrot_server/product/controller/ReviewController.java new file mode 100644 index 0000000..373df93 --- /dev/null +++ b/src/main/java/com/mrokga/carrot_server/product/controller/ReviewController.java @@ -0,0 +1,57 @@ +// package com.mrokga.carrot_server.product.controller; +package com.mrokga.carrot_server.product.controller; + +import com.mrokga.carrot_server.product.dto.request.ReviewCreateRequest; +import com.mrokga.carrot_server.product.dto.response.ReviewResponse; +import com.mrokga.carrot_server.product.service.ReviewService; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.data.domain.*; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api") +@RequiredArgsConstructor +@Tag(name = "후기(Review)") +public class ReviewController { + + private final ReviewService reviewService; + + private Integer me() { + var a = SecurityContextHolder.getContext().getAuthentication(); + if (a == null || !a.isAuthenticated() || "anonymousUser".equals(a.getPrincipal())) + throw new RuntimeException("UNAUTHORIZED"); + return Integer.valueOf(a.getName()); + } + + /** 후기 작성(구매자) */ + @PostMapping("/reviews") + public ResponseEntity create(@RequestBody ReviewCreateRequest req) { + return ResponseEntity.ok(reviewService.createReview(me(), req)); + } + + /** 내가 받은 후기(판매자 입장) */ + @GetMapping("/users/{userId}/reviews/received") + public Page received(@PathVariable Integer userId, + @ParameterObject @PageableDefault(size=20, sort = "id", direction=Sort.Direction.DESC) Pageable pg) { + return reviewService.listReceived(userId, pg); + } + + /** 내가 쓴 후기(구매자 입장) */ + @GetMapping("/users/{userId}/reviews/written") + public Page written(@PathVariable Integer userId, + @ParameterObject @PageableDefault(size=20, sort = "id", direction=Sort.Direction.DESC) Pageable pg) { + return reviewService.listWritten(userId, pg); + } + + /** 상품별 후기(상세 페이지에서 사용 가능) */ + @GetMapping("/product/{productId}/reviews") + public Page byProduct(@PathVariable Integer productId, + @ParameterObject @PageableDefault(size=20, sort = "id", direction=Sort.Direction.DESC) Pageable pg) { + return reviewService.listByProduct(productId, pg); + } +} diff --git a/src/main/java/com/mrokga/carrot_server/product/dto/request/ReviewCreateRequest.java b/src/main/java/com/mrokga/carrot_server/product/dto/request/ReviewCreateRequest.java new file mode 100644 index 0000000..577a98d --- /dev/null +++ b/src/main/java/com/mrokga/carrot_server/product/dto/request/ReviewCreateRequest.java @@ -0,0 +1,21 @@ +package com.mrokga.carrot_server.product.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.*; + + +@Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor +@Schema(name = "ReviewCreateRequest") +public class ReviewCreateRequest { + @Schema(description="구매 거래 ID", example="123", requiredMode = Schema.RequiredMode.REQUIRED) + private Integer transactionId; + + @Schema(description="상품 ID", example="15", requiredMode = Schema.RequiredMode.REQUIRED) + private Integer productId; + + @Schema(description="별점(1~5)", example="5", requiredMode = Schema.RequiredMode.REQUIRED) + private Integer rating; + + @Schema(description="후기 내용(선택)", example="친절하고 약속도 잘 지키셨어요!") + private String content; +} \ No newline at end of file diff --git a/src/main/java/com/mrokga/carrot_server/product/dto/response/ReviewResponse.java b/src/main/java/com/mrokga/carrot_server/product/dto/response/ReviewResponse.java new file mode 100644 index 0000000..b5b7884 --- /dev/null +++ b/src/main/java/com/mrokga/carrot_server/product/dto/response/ReviewResponse.java @@ -0,0 +1,21 @@ +package com.mrokga.carrot_server.product.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.*; + +import java.time.LocalDateTime; + +@Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor +@Schema(name = "ReviewResponse") +public class ReviewResponse { + private Integer id; + private Integer transactionId; + private Integer productId; + private Integer buyerId; + private String buyerNickname; + private Integer sellerId; + private String sellerNickname; + private Integer rating; + private String content; + private LocalDateTime createdAt; +} \ No newline at end of file diff --git a/src/main/java/com/mrokga/carrot_server/product/entity/Review.java b/src/main/java/com/mrokga/carrot_server/product/entity/Review.java new file mode 100644 index 0000000..e513a1e --- /dev/null +++ b/src/main/java/com/mrokga/carrot_server/product/entity/Review.java @@ -0,0 +1,47 @@ +// package com.mrokga.carrot_server.product.entity; +package com.mrokga.carrot_server.product.entity; + +import com.mrokga.carrot_server.user.entity.User; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "review", + uniqueConstraints = @UniqueConstraint(columnNames = {"transaction_id"})) +@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder +public class Review { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + /** 거래(구매) 식별자: 구매내역 DTO에 있던 transactionId */ + @Column(name = "transaction_id", nullable = false) + private Integer transactionId; + + /** 어떤 상품에 대한 후기인지(조회 편의를 위해 저장) */ + @Column(name = "product_id", nullable = false) + private Integer productId; + + /** 후기 작성자(구매자) */ + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "buyer_id", nullable = false) + private User buyer; + + /** 후기 대상자(판매자) */ + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "seller_id", nullable = false) + private User seller; + + /** 별점 1~5 */ + @Column(name = "rating", nullable = false) + private int rating; + + /** 코멘트(옵션) */ + @Column(name = "content", length = 500) + private String content; + + @CreationTimestamp + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/mrokga/carrot_server/product/repository/ReviewRepository.java b/src/main/java/com/mrokga/carrot_server/product/repository/ReviewRepository.java new file mode 100644 index 0000000..7e5ea09 --- /dev/null +++ b/src/main/java/com/mrokga/carrot_server/product/repository/ReviewRepository.java @@ -0,0 +1,14 @@ +// package com.mrokga.carrot_server.product.repository; +package com.mrokga.carrot_server.product.repository; + +import com.mrokga.carrot_server.product.entity.Review; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ReviewRepository extends JpaRepository { + boolean existsByTransactionId(Integer txId); + Page findBySeller_Id(Integer sellerId, Pageable pg); // 받은 후기 + Page findByBuyer_Id(Integer buyerId, Pageable pg); // 내가 쓴 후기 + Page findByProductId(Integer productId, Pageable pg); +} diff --git a/src/main/java/com/mrokga/carrot_server/product/service/ReviewService.java b/src/main/java/com/mrokga/carrot_server/product/service/ReviewService.java new file mode 100644 index 0000000..0bdf4c6 --- /dev/null +++ b/src/main/java/com/mrokga/carrot_server/product/service/ReviewService.java @@ -0,0 +1,113 @@ +// package com.mrokga.carrot_server.product.service; +package com.mrokga.carrot_server.product.service; + +import com.mrokga.carrot_server.product.dto.request.ReviewCreateRequest; +import com.mrokga.carrot_server.product.dto.response.ReviewResponse; +import com.mrokga.carrot_server.product.entity.Product; +import com.mrokga.carrot_server.product.entity.Review; +import com.mrokga.carrot_server.product.enums.TradeStatus; +import com.mrokga.carrot_server.product.repository.ProductRepository; +import com.mrokga.carrot_server.product.repository.ReviewRepository; +import com.mrokga.carrot_server.user.entity.User; +import com.mrokga.carrot_server.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class ReviewService { + + private final ReviewRepository reviewRepository; + private final ProductRepository productRepository; + private final UserRepository userRepository; + // private final ProductTransactionRepository txRepository; // 실제 사용 중인 거래 리포지토리로 교체 + + private double deltaByRating(int rating) { + return switch (rating) { + case 1 -> -2.0; + case 2 -> -1.0; + case 3 -> 0.0; + case 4 -> +1.0; + case 5 -> +2.0; + default -> throw new IllegalArgumentException("rating must be 1~5"); + }; + } + + private double clamp(double v, double min, double max) { + return Math.max(min, Math.min(max, v)); + } + + @Transactional + public ReviewResponse createReview(int meId, ReviewCreateRequest req) { + if (req.getRating() == null || req.getRating() < 1 || req.getRating() > 5) + throw new IllegalArgumentException("별점은 1~5입니다."); + if (reviewRepository.existsByTransactionId(req.getTransactionId())) + throw new IllegalStateException("이미 해당 거래에 대한 후기가 존재합니다."); + + // 1) 거래/상품 확인 (실제 구현에 맞게 바꾸세요) + Product product = productRepository.findById(req.getProductId()).orElseThrow(); + // ProductTransaction tx = txRepository.findById(req.getTransactionId()).orElseThrow(); + + // 가정: meId == tx.getBuyer().getId() + // if (!tx.getBuyer().getId().equals(meId)) throw new IllegalStateException("구매자만 작성할 수 있습니다."); + // if (tx.getStatus() != TradeStatus.SOLD) throw new IllegalStateException("거래완료 후 작성할 수 있습니다."); + + // 거래 테이블이 없다면: 상품이 SOLD인지와, meId가 실제 구매자인지 검사하는 별도 로직 필요 + if (product.getStatus() != TradeStatus.SOLD) throw new IllegalStateException("거래완료 후 작성할 수 있습니다."); + + User buyer = userRepository.findById(meId).orElseThrow(); + User seller = product.getUser(); // 상품의 판매자 + + // 2) 저장 + Review saved = reviewRepository.save( + Review.builder() + .transactionId(req.getTransactionId()) + .productId(product.getId()) + .buyer(buyer) + .seller(seller) + .rating(req.getRating()) + .content(req.getContent()) + .build() + ); + + // 3) 매너점수 반영 + double delta = deltaByRating(req.getRating()); + double next = clamp((seller.getMannerTemperature() == null ? 36.5 : seller.getMannerTemperature()) + delta, 0.0, 100.0); + seller.setMannerTemperature(next); + userRepository.save(seller); + + return toResponse(saved); + } + + @Transactional(readOnly = true) + public Page listReceived(Integer sellerId, Pageable pg) { + return reviewRepository.findBySeller_Id(sellerId, pg).map(this::toResponse); + } + + @Transactional(readOnly = true) + public Page listWritten(Integer buyerId, Pageable pg) { + return reviewRepository.findByBuyer_Id(buyerId, pg).map(this::toResponse); + } + + @Transactional(readOnly = true) + public Page listByProduct(Integer productId, Pageable pg) { + return reviewRepository.findByProductId(productId, pg).map(this::toResponse); + } + + private ReviewResponse toResponse(Review r) { + return ReviewResponse.builder() + .id(r.getId()) + .transactionId(r.getTransactionId()) + .productId(r.getProductId()) + .buyerId(r.getBuyer().getId()) + .buyerNickname(r.getBuyer().getNickname()) + .sellerId(r.getSeller().getId()) + .sellerNickname(r.getSeller().getNickname()) + .rating(r.getRating()) + .content(r.getContent()) + .createdAt(r.getCreatedAt()) + .build(); + } +} diff --git a/src/main/java/com/mrokga/carrot_server/region/controller/MyRegionController.java b/src/main/java/com/mrokga/carrot_server/region/controller/MyRegionController.java new file mode 100644 index 0000000..4b32e3b --- /dev/null +++ b/src/main/java/com/mrokga/carrot_server/region/controller/MyRegionController.java @@ -0,0 +1,33 @@ +package com.mrokga.carrot_server.region.controller; + +import com.mrokga.carrot_server.api.dto.ApiResponseDto; +import com.mrokga.carrot_server.region.dto.ChangeRegionRequest; +import com.mrokga.carrot_server.region.dto.UserRegionResponse; +import com.mrokga.carrot_server.region.service.UserRegionService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/me/region") +@Tag(name = "My Region API", description = "내 동네 관리 API") +public class MyRegionController { + + private final UserRegionService userRegionService; + + @PutMapping + @Operation(summary = "대표 동네 변경", description = "사용자의 대표(기본) 동네를 변경합니다.") + public ResponseEntity> changeMyRegion( + @RequestParam Integer userId, // ✅ 실제 운영에선 SecurityContext에서 꺼내세요 + @RequestBody ChangeRegionRequest request + ){ + UserRegionResponse result = userRegionService.changePrimaryRegion(userId, request); + return ResponseEntity.ok( + ApiResponseDto.success(HttpStatus.OK.value(), "동네가 변경되었습니다.", result) + ); + } +} diff --git a/src/main/java/com/mrokga/carrot_server/region/dto/ChangeRegionRequest.java b/src/main/java/com/mrokga/carrot_server/region/dto/ChangeRegionRequest.java new file mode 100644 index 0000000..53b29ad --- /dev/null +++ b/src/main/java/com/mrokga/carrot_server/region/dto/ChangeRegionRequest.java @@ -0,0 +1,11 @@ +package com.mrokga.carrot_server.region.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +@Getter @Setter +public class ChangeRegionRequest { + @Schema(description = "변경할 동네의 풀네임", example = "서울 동작구 대방동") + private String regionFullName; +} \ No newline at end of file diff --git a/src/main/java/com/mrokga/carrot_server/region/dto/UserRegionResponse.java b/src/main/java/com/mrokga/carrot_server/region/dto/UserRegionResponse.java new file mode 100644 index 0000000..71d185a --- /dev/null +++ b/src/main/java/com/mrokga/carrot_server/region/dto/UserRegionResponse.java @@ -0,0 +1,27 @@ +package com.mrokga.carrot_server.region.dto; + +import com.mrokga.carrot_server.region.entity.Region; +import lombok.*; + +import java.time.LocalDateTime; + +@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder +public class UserRegionResponse { + private Integer regionId; + private String regionName; + private String regionFullName; + private Boolean isPrimary; + private Boolean isActive; + private LocalDateTime verifiedAt; + + public static UserRegionResponse of(Region r, boolean primary, boolean active, LocalDateTime verifiedAt){ + return UserRegionResponse.builder() + .regionId(r.getId()) + .regionName(r.getName()) + .regionFullName(r.getFullName()) + .isPrimary(primary) + .isActive(active) + .verifiedAt(verifiedAt) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/mrokga/carrot_server/region/repository/UserRegionRepository.java b/src/main/java/com/mrokga/carrot_server/region/repository/UserRegionRepository.java index 2d3fe36..fef463e 100644 --- a/src/main/java/com/mrokga/carrot_server/region/repository/UserRegionRepository.java +++ b/src/main/java/com/mrokga/carrot_server/region/repository/UserRegionRepository.java @@ -12,8 +12,21 @@ @Repository public interface UserRegionRepository extends JpaRepository { - Optional findActiveByUserId(int userId); + // 활성화된 동네(필요시) + Optional findActiveByUserId(Integer userId); + // ✅ 현재 대표 동네 + @Query(""" + select ur + from UserRegion ur + where ur.user.id = :userId and ur.isPrimary = true + """) + Optional findPrimaryByUserId(@Param("userId") Integer userId); + + // ✅ 사용자 + 지역 매핑 존재 여부 + Optional findByUserIdAndRegion_Id(Integer userId, Integer regionId); + + // 목록 조회 (지역 join 포함) @Query(""" select ur from UserRegion ur diff --git a/src/main/java/com/mrokga/carrot_server/region/service/UserRegionService.java b/src/main/java/com/mrokga/carrot_server/region/service/UserRegionService.java new file mode 100644 index 0000000..7fa262e --- /dev/null +++ b/src/main/java/com/mrokga/carrot_server/region/service/UserRegionService.java @@ -0,0 +1,59 @@ +package com.mrokga.carrot_server.region.service; + +import com.mrokga.carrot_server.region.dto.ChangeRegionRequest; +import com.mrokga.carrot_server.region.dto.UserRegionResponse; +import com.mrokga.carrot_server.region.entity.Region; +import com.mrokga.carrot_server.region.entity.UserRegion; +import com.mrokga.carrot_server.region.repository.RegionRepository; +import com.mrokga.carrot_server.region.repository.UserRegionRepository; +import com.mrokga.carrot_server.user.entity.User; +import com.mrokga.carrot_server.user.service.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +@Service +@RequiredArgsConstructor +public class UserRegionService { + + private final RegionRepository regionRepository; + private final UserRegionRepository userRegionRepository; + private final UserService userService; // 현재 로그인 사용자 조회용 + + /** 대표 동네 변경 (없으면 생성-활성화) */ + @Transactional + public UserRegionResponse changePrimaryRegion(Integer userId, ChangeRegionRequest req){ + // 1) 유저/지역 조회 + User user = userService.getUserById(userId); + Region region = regionRepository.findByFullName(req.getRegionFullName()) + .orElseGet(() -> regionRepository.findByName(req.getRegionFullName()) + .orElseThrow(() -> new IllegalArgumentException("해당 동네가 존재하지 않습니다."))); + + // 2) 기존 대표 해제 + userRegionRepository.findPrimaryByUserId(userId).ifPresent(ur -> { + ur.setIsPrimary(false); + ur.setUpdatedAt(LocalDateTime.now()); + }); + + // 3) 기존 매핑 있으면 활성/대표로, 없으면 새로 생성 + UserRegion ur = userRegionRepository.findByUserIdAndRegion_Id(userId, region.getId()) + .orElse(UserRegion.builder() + .user(user) + .region(region) + .isActive(true) + .isPrimary(true) + .verifiedAt(LocalDateTime.now()) + .createdAt(LocalDateTime.now()) + .build()); + + ur.setIsActive(true); + ur.setIsPrimary(true); + if(ur.getVerifiedAt() == null) ur.setVerifiedAt(LocalDateTime.now()); + ur.setUpdatedAt(LocalDateTime.now()); + userRegionRepository.save(ur); + + return UserRegionResponse.of(region, true, true, ur.getVerifiedAt()); + } +} diff --git a/src/main/java/com/mrokga/carrot_server/transaction/service/TransactionService.java b/src/main/java/com/mrokga/carrot_server/transaction/service/TransactionService.java new file mode 100644 index 0000000..966b3dd --- /dev/null +++ b/src/main/java/com/mrokga/carrot_server/transaction/service/TransactionService.java @@ -0,0 +1,94 @@ +// src/main/java/com/mrokga/carrot_server/transaction/service/TransactionService.java +package com.mrokga.carrot_server.transaction.service; + +import com.mrokga.carrot_server.product.entity.Product; +import com.mrokga.carrot_server.product.enums.TradeStatus; +import com.mrokga.carrot_server.product.repository.ProductRepository; +import com.mrokga.carrot_server.transaction.entity.Transaction; +import com.mrokga.carrot_server.transaction.repository.TransactionRepository; +import com.mrokga.carrot_server.user.entity.User; +import com.mrokga.carrot_server.user.repository.UserRepository; +import jakarta.persistence.EntityNotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +@Service +@RequiredArgsConstructor +public class TransactionService { + + private final TransactionRepository txRepo; + private final ProductRepository productRepo; + private final UserRepository userRepo; + + /** 거래 시작: 상품을 예약중으로 만들고 트랜잭션 생성 */ + @Transactional + public Transaction start(Integer buyerId, Integer productId) { + User buyer = userRepo.findById(buyerId) + .orElseThrow(() -> new EntityNotFoundException("buyer not found")); + Product product = productRepo.findById(productId) + .orElseThrow(() -> new EntityNotFoundException("product not found")); + User seller = product.getUser(); + + if (product.getStatus() != TradeStatus.ON_SALE) { + throw new IllegalStateException("상품이 판매중 상태가 아닙니다."); + } + if (seller.getId().equals(buyerId)) { + throw new IllegalStateException("본인 상품은 구매할 수 없습니다."); + } + + product.setStatus(TradeStatus.RESERVED); + productRepo.save(product); + + Transaction tx = Transaction.builder() + .product(product) + .buyer(buyer) + .seller(seller) + .completedAt(null) // 예약 상태 + .build(); + return txRepo.save(tx); + } + + /** 결제 승인 성공 처리: 상품 판매완료 + 거래 완료시간 기록 */ + @Transactional + public Transaction approve(Integer txId) { + Transaction tx = txRepo.findById(txId) + .orElseThrow(() -> new EntityNotFoundException("transaction not found")); + + if (tx.getCompletedAt() != null) return tx; // 멱등성 + + Product p = tx.getProduct(); + p.setStatus(TradeStatus.SOLD); + productRepo.save(p); + + tx.setCompletedAt(LocalDateTime.now()); + return txRepo.save(tx); + } + + /** 결제 실패/취소: 상품 다시 판매중으로 */ + @Transactional + public Transaction cancel(Integer txId) { + Transaction tx = txRepo.findById(txId) + .orElseThrow(() -> new EntityNotFoundException("transaction not found")); + + // 이미 완료된 거래는 취소 불가(필요 시 예외정책 변경) + if (tx.getCompletedAt() != null) { + throw new IllegalStateException("이미 완료된 거래입니다."); + } + + Product p = tx.getProduct(); + p.setStatus(TradeStatus.ON_SALE); + productRepo.save(p); + + // completedAt 은 그대로 null 유지(예약 취소) + return txRepo.save(tx); + } + + @Transactional(readOnly = true) + public Transaction get(Integer txId) { + return txRepo.findById(txId) + .orElseThrow(() -> new EntityNotFoundException("transaction not found")); + } +} diff --git a/src/main/java/com/mrokga/carrot_server/transaction/web/TransactionController.java b/src/main/java/com/mrokga/carrot_server/transaction/web/TransactionController.java new file mode 100644 index 0000000..dd3e219 --- /dev/null +++ b/src/main/java/com/mrokga/carrot_server/transaction/web/TransactionController.java @@ -0,0 +1,71 @@ +// src/main/java/com/mrokga/carrot_server/transaction/web/TransactionController.java +package com.mrokga.carrot_server.transaction.web; + +import com.mrokga.carrot_server.transaction.entity.Transaction; +import com.mrokga.carrot_server.transaction.service.TransactionService; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; // ★ 추가 +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/transactions") +public class TransactionController { + + private final TransactionService txService; + + /** 거래 시작(예약) */ + @PostMapping + public ResponseEntity start(@AuthenticationPrincipal UserDetails principal, + @RequestBody StartReq req) { + if (principal == null) { + return ResponseEntity.status(401).body("로그인이 필요합니다."); + } + + // TokenProvider.generateToken 에서 subject = user.getId() + Integer buyerId = Integer.valueOf(principal.getUsername()); + + Transaction tx = txService.start(buyerId, req.getProductId()); + return ResponseEntity.ok(new TxRes(tx)); + } + + /** 단건 조회(디버그/확인용) */ + @GetMapping("/{id}") + public ResponseEntity get(@PathVariable Integer id) { + Transaction tx = txService.get(id); + return ResponseEntity.ok(new TxRes(tx)); + } + + /** 예약 취소(디버그/수동취소용) */ + @PostMapping("/{id}/cancel") + public ResponseEntity cancel(@PathVariable Integer id) { + Transaction tx = txService.cancel(id); + return ResponseEntity.ok(new TxRes(tx)); + } + + @Data + public static class StartReq { @NotNull private Integer productId; } + + @Data + public static class TxRes { + private Integer id; + private Integer productId; + private Integer buyerId; + private Integer sellerId; + private String productStatus; + private String completedAt; + + public TxRes(Transaction t) { + this.id = t.getId(); + this.productId = t.getProduct().getId(); + this.buyerId = t.getBuyer() != null ? t.getBuyer().getId() : null; + this.sellerId = t.getSeller() != null ? t.getSeller().getId() : null; + this.productStatus = t.getProduct().getStatus().name(); + this.completedAt = t.getCompletedAt() == null ? null : t.getCompletedAt().toString(); + } + } +} diff --git a/src/main/java/com/mrokga/carrot_server/user/repository/UserRepository.java b/src/main/java/com/mrokga/carrot_server/user/repository/UserRepository.java index 372273c..af82382 100644 --- a/src/main/java/com/mrokga/carrot_server/user/repository/UserRepository.java +++ b/src/main/java/com/mrokga/carrot_server/user/repository/UserRepository.java @@ -8,4 +8,6 @@ public interface UserRepository extends JpaRepository { User findByNickname(String nickname); User findByPhoneNumber(String phoneNumber); + + } diff --git a/src/main/java/com/mrokga/carrot_server/user/service/UserService.java b/src/main/java/com/mrokga/carrot_server/user/service/UserService.java index b976744..92e3931 100644 --- a/src/main/java/com/mrokga/carrot_server/user/service/UserService.java +++ b/src/main/java/com/mrokga/carrot_server/user/service/UserService.java @@ -65,4 +65,10 @@ public boolean isDuplicateNickname(String nickname) { public User getUserByPhoneNumber(String phoneNumber) { return userRepository.findByPhoneNumber(phoneNumber); } + + @Transactional(readOnly = true) + public User getUserById(Integer userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new EntityNotFoundException("User not found: " + userId)); + } } From 13d768ea9c1c29bfbc941a94fc134327759a1439 Mon Sep 17 00:00:00 2001 From: euijunlee Date: Tue, 2 Dec 2025 12:37:47 +0900 Subject: [PATCH 2/6] =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC=20[?= =?UTF-8?q?=EC=B1=84=ED=8C=85,=20=EB=8F=99=EB=84=A4=EC=83=9D=ED=99=9C?= =?UTF-8?q?=EA=B2=8C=EC=8B=9C=ED=8C=90]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/mrokga/carrot_server/.DS_Store | Bin 10244 -> 10244 bytes .../controller/ChatMessageController.java | 2 +- .../carrot_server/chat/entity/ChatRoom.java | 3 ++ .../carrot_server/chat/entity/QuickReply.java | 1 + .../repository/AppointmentRepository.java | 2 +- .../chat/service/AppointmentService.java | 20 ++++++--- .../chat/service/ChatMessageReadService.java | 6 ++- .../chat/service/ChatMessageService.java | 40 ++++++++++++------ .../chat/service/ChatRoomService.java | 34 +++++++++++---- .../chat/service/QuickReplyService.java | 3 ++ .../community/controller/PostController.java | 4 +- .../community/service/PostService.java | 28 +++++++----- 12 files changed, 100 insertions(+), 43 deletions(-) diff --git a/src/main/java/com/mrokga/carrot_server/.DS_Store b/src/main/java/com/mrokga/carrot_server/.DS_Store index 8725dc685b14b1bc4d90f25bf6c65148fa992444..fe5e08c54a326556a519a2e6de076ec4667b294d 100644 GIT binary patch delta 29 dcmZn(XbIS0D$1J9P{2^K*-G>`Kb(1r2>^_(2^|0c delta 29 dcmZn(XbIS0D$4545Wo`Kb(1r2>^nP2weaG diff --git a/src/main/java/com/mrokga/carrot_server/chat/controller/ChatMessageController.java b/src/main/java/com/mrokga/carrot_server/chat/controller/ChatMessageController.java index 56bb41e..a01bcc5 100644 --- a/src/main/java/com/mrokga/carrot_server/chat/controller/ChatMessageController.java +++ b/src/main/java/com/mrokga/carrot_server/chat/controller/ChatMessageController.java @@ -51,7 +51,7 @@ public List getMessages(@PathVariable Integer roomId) { /** - * ✅ WebSocket/STOMP 용 Controller + * WebSocket/STOMP 용 Controller * - @RestController 대신 @Controller 사용 * - /pub/chat/message 로 발행된 STOMP 메시지를 수신 */ diff --git a/src/main/java/com/mrokga/carrot_server/chat/entity/ChatRoom.java b/src/main/java/com/mrokga/carrot_server/chat/entity/ChatRoom.java index 1e70f53..1322201 100644 --- a/src/main/java/com/mrokga/carrot_server/chat/entity/ChatRoom.java +++ b/src/main/java/com/mrokga/carrot_server/chat/entity/ChatRoom.java @@ -20,14 +20,17 @@ public class ChatRoom { @GeneratedValue(strategy = GenerationType.IDENTITY) private Integer id; + // 상품 @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "product_id", nullable = false) private Product product; + // 판매자 @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "seller_id", nullable = false) private User seller; + // 구매 희망자 @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "buyer_id", nullable = false) private User buyer; diff --git a/src/main/java/com/mrokga/carrot_server/chat/entity/QuickReply.java b/src/main/java/com/mrokga/carrot_server/chat/entity/QuickReply.java index 4350f18..021d390 100644 --- a/src/main/java/com/mrokga/carrot_server/chat/entity/QuickReply.java +++ b/src/main/java/com/mrokga/carrot_server/chat/entity/QuickReply.java @@ -18,6 +18,7 @@ public class QuickReply { @GeneratedValue(strategy = GenerationType.IDENTITY) private Integer id; + // 유저 아이디 @Column(name = "user_id", nullable = false) private Integer userId; diff --git a/src/main/java/com/mrokga/carrot_server/chat/repository/AppointmentRepository.java b/src/main/java/com/mrokga/carrot_server/chat/repository/AppointmentRepository.java index 77862bb..44c43d0 100644 --- a/src/main/java/com/mrokga/carrot_server/chat/repository/AppointmentRepository.java +++ b/src/main/java/com/mrokga/carrot_server/chat/repository/AppointmentRepository.java @@ -16,7 +16,7 @@ Optional findByChatRoom_IdAndStatusIn(Integer chatRoomId, Optional findByChatRoom_Id(Integer chatRoomId); - // ✔ 나의 약속(채팅방 참여자이거나 제안자=나). 상태 필터 optional + // 나의 약속(채팅방 참여자이거나 제안자=나). 상태 필터 optional @Query(""" SELECT a FROM Appointment a diff --git a/src/main/java/com/mrokga/carrot_server/chat/service/AppointmentService.java b/src/main/java/com/mrokga/carrot_server/chat/service/AppointmentService.java index c48a2f0..5f140c4 100644 --- a/src/main/java/com/mrokga/carrot_server/chat/service/AppointmentService.java +++ b/src/main/java/com/mrokga/carrot_server/chat/service/AppointmentService.java @@ -18,6 +18,9 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +// 채팅방 내에서 이뤄지는 기능 +// 채팅방 조회 및 열람에서 본인 인증을 통해 채팅방 참여자 여부를 가려놨기에 +// 여기서는 따로 채팅 참여자 여부를 가리지 않음. (즉 약속 CRUD 권한 여부 가리지 않음. 아미 조회 및 열람했으면 약속에 대한 권한도 당연히 부여) @Service @RequiredArgsConstructor public class AppointmentService { @@ -41,6 +44,7 @@ private AppointmentResponseDto toDto(Appointment appointment) { .build(); } + // 약속 생성 @Transactional public AppointmentResponseDto create(Integer roomId, AppointmentRequestDto dto){ ChatRoom room = chatRoomRepository.findById(roomId) @@ -49,7 +53,7 @@ public AppointmentResponseDto create(Integer roomId, AppointmentRequestDto dto){ User proposer = userRepository.findById(dto.getProposerId()) .orElseThrow(() -> new EntityNotFoundException("AppointmentService.create(): 유저 없음")); - // ✅ 중복 약속 방지: 해당 채팅방에 PENDING/ACCEPTED 상태 약속이 있으면 생성 불가 + // 중복 약속 방지: 해당 채팅방에 PENDING/ACCEPTED 상태 약속이 있으면 생성 불가 appointmentRepository.findByChatRoom_IdAndStatusIn( roomId, java.util.List.of(AppointmentStatus.PENDING, AppointmentStatus.ACCEPTED) ).ifPresent(a -> { @@ -66,7 +70,7 @@ public AppointmentResponseDto create(Integer roomId, AppointmentRequestDto dto){ Appointment saved = appointmentRepository.save(appointment); - // ✅ 시스템 메시지 + // 시스템 메시지 생성 후 전송 String content = String.format("%s님이 %s %s에 만나자고 약속을 제안했습니다.", proposer.getNickname(), dto.getMeetingTime().toLocalDate(), @@ -76,6 +80,7 @@ public AppointmentResponseDto create(Integer roomId, AppointmentRequestDto dto){ return toDto(saved); } + // 약속 수락 @Transactional public AppointmentResponseDto acceptAppointment(Integer appointmentId) { Appointment appointment = appointmentRepository.findById(appointmentId) @@ -98,13 +103,14 @@ public AppointmentResponseDto acceptAppointment(Integer appointmentId) { productService.changeStatus(dto); - // ✅ 시스템 메시지 + // 시스템 메시지 생성 후 전송 String content = "약속이 수락되었습니다. 상품 상태가 예약중으로 변경됩니다."; chatMessageService.sendSystemMessage(room, content); return toDto(appointment); } + // 약속 거절 @Transactional public AppointmentResponseDto rejectAppointment(Integer appointmentId) { Appointment appointment = appointmentRepository.findById(appointmentId) @@ -112,13 +118,14 @@ public AppointmentResponseDto rejectAppointment(Integer appointmentId) { appointment.setStatus(AppointmentStatus.REJECTED); - // ✅ 시스템 메시지 + // 시스템 메시지 생성 후 전송 String content = "약속이 거절되었습니다."; chatMessageService.sendSystemMessage(appointment.getChatRoom(), content); return toDto(appointment); } + // 약속 취소 @Transactional public AppointmentResponseDto cancelAppointment(Integer appointmentId) { Appointment appointment = appointmentRepository.findById(appointmentId) @@ -129,7 +136,7 @@ public AppointmentResponseDto cancelAppointment(Integer appointmentId) { ChatRoom room = appointment.getChatRoom(); Product product = room.getProduct(); - // ✅ changeStatus 호출 (Transaction까지 정리) + // changeStatus 호출 (Transaction까지 정리) ChangeStatusRequestDto dto = ChangeStatusRequestDto.builder() .productId(product.getId()) .sellerId(room.getSeller().getId()) @@ -140,13 +147,14 @@ public AppointmentResponseDto cancelAppointment(Integer appointmentId) { productService.changeStatus(dto); - // ✅ 시스템 메시지 + // 시스템 메시지 생성 후 전송 String content = "약속이 취소되었습니다. 상품 상태가 판매중으로 돌아갑니다."; chatMessageService.sendSystemMessage(room, content); return toDto(appointment); } + // 약속 조회 @Transactional(readOnly = true) public AppointmentResponseDto getAppointmentByChatRoomId(Integer roomId) { Appointment appointment = appointmentRepository.findByChatRoom_Id(roomId) diff --git a/src/main/java/com/mrokga/carrot_server/chat/service/ChatMessageReadService.java b/src/main/java/com/mrokga/carrot_server/chat/service/ChatMessageReadService.java index 271878d..2fcc937 100644 --- a/src/main/java/com/mrokga/carrot_server/chat/service/ChatMessageReadService.java +++ b/src/main/java/com/mrokga/carrot_server/chat/service/ChatMessageReadService.java @@ -18,9 +18,13 @@ public class ChatMessageReadService { private final ChatRoomRepository chatRoomRepository; private final ChatMessageRepository chatMessageRepository; + // 읽음 처리 @Transactional(propagation = Propagation.REQUIRES_NEW) public void markAsRead(Integer roomId, Integer messageId, Integer userId) { - // 권한/검증 + /* 권한 확인 + 1. 다른 방 메시지 읽음 처리 불가 + 2. 해당 채팅방 참여자 여부 확인 + */ ChatRoom room = chatRoomRepository.findById(roomId) .orElseThrow(() -> new IllegalArgumentException("채팅방 없음")); diff --git a/src/main/java/com/mrokga/carrot_server/chat/service/ChatMessageService.java b/src/main/java/com/mrokga/carrot_server/chat/service/ChatMessageService.java index 9587e95..6c2d9cd 100644 --- a/src/main/java/com/mrokga/carrot_server/chat/service/ChatMessageService.java +++ b/src/main/java/com/mrokga/carrot_server/chat/service/ChatMessageService.java @@ -25,7 +25,7 @@ public class ChatMessageService { private final ChatRoomRepository chatRoomRepository; private final ChatMessageRepository chatMessageRepository; private final ChatMessageReadService chatMessageReadService; - private final SimpMessageSendingOperations messagingTemplate; // ✅ 실시간 전송 기능 추가 + private final SimpMessageSendingOperations messagingTemplate; // 실시간 전송 기능 추가 // 메세지 객체 DTO 형태로 변환 메소드 private MessageResponseDto toResponse(ChatMessage message){ @@ -37,7 +37,11 @@ private MessageResponseDto toResponse(ChatMessage message){ dto.setMessage(message.getMessage()); dto.setCreatedAt(message.getCreatedAt()); - // 부모(답장 대상) 있으면 요약 채우기 + /* 부모(답장 대상) 있으면 요약 채우기 + 예를 들어 상대방 메세지에 대한 답장 메세지일 경우, + 상대방 메세지(부모 메세지) 요약칸이 답장 메시지(자식 메세지) 위에 있는 + UI를 위한 부모 메세지 요약 속성. + */ ChatMessage p = message.getParentMessage(); if (p != null) { dto.setReplyToMessageId(p.getId()); @@ -52,19 +56,19 @@ private MessageResponseDto toResponse(ChatMessage message){ return dto; } - // 답장 메세지, 부모 메세지 미리보기 50자 + // 답장 메세지, 부모 메세지 미리보기 요약 50자 private String abbreviate(String s, int max) { if (s == null) return null; return s.length() <= max ? s : s.substring(0, max) + "…"; } - // ✅ 기존 sendMessage: REST API용 (메시지 저장만 수행) + // 기존 sendMessage: REST API용 (메시지 저장만 수행) @Transactional public MessageResponseDto sendMessage(MessageRequestDto dto, Integer senderId){ return saveMessage(dto, senderId); } - // ✅ 새로운 sendMessageAndBroadcast: WebSocket 전용 (저장 + 실시간 전송) + // 새로운 sendMessageAndBroadcast: WebSocket 전용 (저장 + 실시간 전송) @Transactional public void sendMessageAndBroadcast(MessageRequestDto dto, Integer senderId){ MessageResponseDto response = saveMessage(dto, senderId); @@ -109,7 +113,6 @@ public MessageResponseDto saveMessage(MessageRequestDto dto, Integer senderId){ if (!parent.getChatRoom().getId().equals(room.getId())) { throw new IllegalArgumentException("다른 채팅방의 메시지에는 답장할 수 없습니다."); } - // (선택) parent가 삭제되었으면 금지할지 허용할지 정책 결정 } ChatMessage message = chatMessageRepository.save( @@ -139,21 +142,29 @@ public List getMessages(Integer roomId, Integer requesterId) throw new AccessDeniedException("채팅방 메세지 조회 권한이 없습니다."); } - // 내 커트라인 가져오기 (없으면 0) + /* 내 삭제 지점 가져오기 (없으면 0) + => 즉, 이전에 채팅방을 삭제한 적이 있다면 삭제 지점 이후의 메세지 부터 보여줌. + 삭제한 적 없으면 커트라인은 0으로 전체 메세지를 다 보여줌. + */ Integer cutoff = chatRoomRepository.getDeleteCutoffForUser(roomId, requesterId); int cutoffId = cutoff == null ? 0 : cutoff; - // ✅ 커트라인 이후 메시지만 조회 + // 삭제 지점 이후 메시지만 조회 List messages = chatMessageRepository.findAfterCutoff(roomId, cutoffId); - // ===== 여기서 읽음 처리 ===== + /* ===== 여기서 읽음 처리 ===== + 조회했으면 당연히 읽음 처리 + */ if (!messages.isEmpty()) { int lastMessageId = messages.get(messages.size() - 1).getId(); - // 이거 markAsRead 서비스 호출 + // markAsRead 서비스 호출 chatMessageReadService.markAsRead(room.getId(), lastMessageId, requesterId); } - // ====== 마지막 메시지 '읽음' 판정 ====== + /* ====== '마지막' 메세지 읽음 표시 ====== + UI에 마지막 메세지 읽음 표시 띄울 때, 내 메세지를 상대가 읽었을 때만 표시. + 내가 상대 메세지를 읽었을 때 굳이 읽음 표시를 띄울 필요가 없기 때문. + */ Integer opponentReadId = Objects.equals(requesterId, buyerId) ? room.getSellerLastReadMessageId() : room.getBuyerLastReadMessageId(); @@ -161,7 +172,7 @@ public List getMessages(Integer roomId, Integer requesterId) Integer lastMsgId = (lastMsg != null) ? lastMsg.getId() : null; boolean lastIsMine = (lastMsg != null) && Objects.equals(lastMsg.getUser().getId(), requesterId); - // 마지막 메시지가 내가 보낸 거라면, 상대 포인터가 그 ID 이상인지로 판정 + // 마지막 메시지가 내가 보낸 거라면, 상대 메세지 읽음 포인터가 그 ID 이상인지로 판정 boolean lastMsgReadByOpponent = lastIsMine && opponentReadId != null && lastMsgId != null && opponentReadId >= lastMsgId; @@ -172,7 +183,7 @@ public List getMessages(Integer roomId, Integer requesterId) return messages.stream().map(m -> { MessageResponseDto dto = toResponse(m); - // 프론트 버블 정렬/색 구분에 유용 + // 프론트 채팅 버블 정렬/색 구분에 유용 boolean mine = Objects.equals(m.getUser().getId(), requesterId); dto.setMine(mine); @@ -185,10 +196,11 @@ public List getMessages(Integer roomId, Integer requesterId) } + //시스템 메세지 전송 @Transactional public ChatMessage sendSystemMessage(ChatRoom room, String content) { - // ✅ 문자열 리터럴 "System" 사용 + // 문자열 리터럴 "System" 사용 User systemUser = userRepository.findByNickname("SYSTEM"); if (systemUser == null) { throw new RuntimeException("시스템 계정을 찾을 수 없습니다."); diff --git a/src/main/java/com/mrokga/carrot_server/chat/service/ChatRoomService.java b/src/main/java/com/mrokga/carrot_server/chat/service/ChatRoomService.java index 97dffa3..6b3c3d1 100644 --- a/src/main/java/com/mrokga/carrot_server/chat/service/ChatRoomService.java +++ b/src/main/java/com/mrokga/carrot_server/chat/service/ChatRoomService.java @@ -39,6 +39,7 @@ private ChatRoomResponseDto toResponse(ChatRoom room) { return dto; } + // 채팅방 생성 @Transactional public ChatRoomResponseDto createOrGetRoom(Integer me, ChatRoomRequestDto dto){ Product product = productRepository.findById(dto.getProductId()) @@ -48,7 +49,7 @@ public ChatRoomResponseDto createOrGetRoom(Integer me, ChatRoomRequestDto dto){ final User seller; final User buyer; - // 판매자 선톡 + // 1. 판매자 선톡 if(Objects.equals(me, ownerId)){ if(dto.getBuyerId() == null){ throw new IllegalArgumentException("구매자 ID가 필요합니다.(판매자 선톡)"); @@ -58,7 +59,7 @@ public ChatRoomResponseDto createOrGetRoom(Integer me, ChatRoomRequestDto dto){ buyer = userRepository.findById(dto.getBuyerId()) .orElseThrow(() -> new IllegalArgumentException("구매자 없음")); } - // 구매자 선톡 + // 2. 구매자 선톡 else{ buyer = userRepository.findById(me) .orElseThrow(() -> new IllegalArgumentException("구매자 없음")); @@ -101,13 +102,17 @@ public ChatRoomResponseDto createOrGetRoom(Integer me, ChatRoomRequestDto dto){ } } + // 채팅방 리스트 조회 @Transactional(readOnly = true) public List getRoomByUser(Integer userId){ List rooms = chatRoomRepository.findByBuyer_IdOrSeller_Id(userId, userId); return rooms.stream().map(room -> { ChatRoomResponseDto dto = toResponse(room); // 기존 필드 세팅 - // 방의 마지막 보이는 메시지 (커트라인 이후) + /* + 해당 채팅방의 마지막 메시지 (커트라인 이후) + => UI에 각 채팅방마다 마지막 메세지 요약 후 미리보기로 보여주기 위해 + */ chatRoomRepository.findLastVisibleMessageId(room.getId(), userId) .ifPresent(lastId -> { chatMessageRepository.findById(lastId).ifPresent(last -> { @@ -116,13 +121,17 @@ public List getRoomByUser(Integer userId){ dto.setLastMessageAt(last.getCreatedAt()); dto.setLastMessagePreview( last.getMessageType() == MessageType.TEXT - ? abbreviate(last.getMessage(), 50) + ? abbreviate(last.getMessage()) : "[이미지]" ); }); }); - // 내가 보낸 마지막 메시지 id (커트라인 이후 기준) + /* + 내가 보낸 마지막 메시지 id (내 커트라인 이후 기준) + 채팅방을 삭제하지 않았으면 커트라인 0, 삭제한 적 있다면 삭제 지점 메세지 반환 + (내가 마지막으로 보내고 내가 채팅방을 삭제한 경우 등 다양한 경우 대비해서) + */ Integer cutoff = chatRoomRepository.getDeleteCutoffForUser(room.getId(), userId); int cutoffId = cutoff == null ? 0 : cutoff; Integer lastMyMessageId = chatMessageRepository.findAfterCutoff(room.getId(), cutoffId).stream() @@ -136,6 +145,7 @@ public List getRoomByUser(Integer userId){ ? room.getSellerLastReadMessageId() : room.getBuyerLastReadMessageId(); + // 마지막 메세지 읽음 여부 표시 boolean seen = (lastMyMessageId != null) && (opponentRead != null) && (opponentRead >= lastMyMessageId); dto.setLastMessageSeen(seen); @@ -145,23 +155,30 @@ public List getRoomByUser(Integer userId){ /** * 채팅방 소프트 삭제 (내 쪽에서만 삭제) - * - DB에서 실제로 지우지 않고 내 커트라인을 마지막 메시지 id로 설정 + * - DB에서 실제로 지우지 않고 내가 삭제한 지점(커트라인)을 마지막 메시지 id로 설정 * - 이후 이 방은 내 목록에서 안 보이고, 나중에 새 메시지가 오면 다시 등장 */ @Transactional public void deleteRoom(Integer roomId, Integer userId) { + //채팅방 조회 ChatRoom chatRoom = chatRoomRepository.findById(roomId) .orElseThrow(() -> new IllegalArgumentException("채팅방 없음")); Integer buyerId = chatRoom.getBuyer().getId(); Integer sellerId = chatRoom.getSeller().getId(); + // 권한 확인 if (!Objects.equals(userId, buyerId) && !Objects.equals(userId, sellerId)) { throw new AccessDeniedException("채팅방 삭제 권한이 없습니다."); } + // 마지막 메세지 조회 int lastMsgId = chatMessageRepository.findTopIdByRoomId(roomId).orElse(0); + /* 해당 지점까지는 읽었다는 것을 설정 후, 해당 지점을 삭제 지점으로 설정 + 1. 내가 buyer일 경우 + 2. 내가 seller일 경우 + */ if (Objects.equals(userId, buyerId)) { chatRoomRepository.markBuyerDeleted(roomId, buyerId, lastMsgId); chatRoomRepository.bumpBuyerLastRead(roomId, lastMsgId); @@ -171,8 +188,9 @@ public void deleteRoom(Integer roomId, Integer userId) { } } - private String abbreviate(String s, int max) { + // 메세지 요약 (50자 이상일 경우 이후 내용 ...으로) + private String abbreviate(String s) { if (s == null) return null; - return s.length() <= max ? s : s.substring(0, max) + "…"; + return s.length() <= 50 ? s : s.substring(0, 50) + "…"; } } diff --git a/src/main/java/com/mrokga/carrot_server/chat/service/QuickReplyService.java b/src/main/java/com/mrokga/carrot_server/chat/service/QuickReplyService.java index 935c6d5..32122cf 100644 --- a/src/main/java/com/mrokga/carrot_server/chat/service/QuickReplyService.java +++ b/src/main/java/com/mrokga/carrot_server/chat/service/QuickReplyService.java @@ -22,6 +22,7 @@ public class QuickReplyService { private final ChatMessageRepository chatMessageRepository; private final QuickReplyRepository quickReplyRepository; + // 자주 쓰는 문구 추가 @Transactional public QuickReplyAddResponseDto addQuikReply(Integer messageId){ Integer userId = QuickReplyUtils.currentUserIdOrThrow(); @@ -73,6 +74,7 @@ public QuickReplyAddResponseDto addQuikReply(Integer messageId){ } } + // 자주 쓰는 문구 조회 @Transactional(readOnly = true) public List listMine() { Integer userId = QuickReplyUtils.currentUserIdOrThrow(); @@ -82,6 +84,7 @@ public List listMine() { .toList(); } + // 자주 쓰는 문구 삭제 @Transactional public void deleteMine(Integer id) { Integer userId = QuickReplyUtils.currentUserIdOrThrow(); diff --git a/src/main/java/com/mrokga/carrot_server/community/controller/PostController.java b/src/main/java/com/mrokga/carrot_server/community/controller/PostController.java index 20a948b..9ba6268 100644 --- a/src/main/java/com/mrokga/carrot_server/community/controller/PostController.java +++ b/src/main/java/com/mrokga/carrot_server/community/controller/PostController.java @@ -45,7 +45,7 @@ private Integer getCurrentUserId() { return Integer.valueOf(auth.getName()); } - // ✅ 2-Step 방식 (JSON-only) + // 2-Step 방식 (JSON-only) @PostMapping @Operation(summary = "게시글 작성", description = "사용자가 새로운 게시글을 작성합니다.") public ResponseEntity> createPost(@RequestBody CreatePostRequestDto dto){ @@ -54,7 +54,7 @@ public ResponseEntity> createPost(@RequestBody CreatePostReque return ResponseEntity.ok(ApiResponseDto.success(HttpStatus.OK.value(), "success",null)); } - // ✅ 1-Step 방식 (멀티파트) + // 1-Step 방식 (멀티파트) @PostMapping(value = "/multipart", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @Operation( summary = "게시글 작성(멀티파트: JSON + 이미지 파일)", diff --git a/src/main/java/com/mrokga/carrot_server/community/service/PostService.java b/src/main/java/com/mrokga/carrot_server/community/service/PostService.java index 2992328..0e8b941 100644 --- a/src/main/java/com/mrokga/carrot_server/community/service/PostService.java +++ b/src/main/java/com/mrokga/carrot_server/community/service/PostService.java @@ -61,11 +61,11 @@ public void createPost(CreatePostRequestDto dto){ .dealPlaceLng(dto.getDealPlaceLng()) .build(); - // 2. 이미지가 있으면 PostImage 엔티티로 변환 후 매핑 + // 2. 이미지가 있으면 PostImage(Post 포함한 엔티티) 엔티티로 변환 후 매핑 if (dto.getImages() != null && !dto.getImages().isEmpty()) { List postImages = dto.getImages().stream() .map(imgDto -> PostImage.builder() - .post(post) + .post(post) // 1번에서 생성한 게시글 엔티티 .imageUrl(imgDto.getImageUrl()) .sortOrder(imgDto.getSortOrder() != null ? imgDto.getSortOrder() : 0) .isThumbnail(imgDto.getIsThumbnail() != null && imgDto.getIsThumbnail()) @@ -97,14 +97,14 @@ public void editPost(EditPostRequestDto dto, Integer me){ post.setContent(dto.getContent()); - // ✅ 장소 업데이트: null 이면 “변경 안함”, 빈문자면 “삭제” + // 장소 업데이트: null 이면 “변경 안함”, 빈문자면 “삭제” if (dto.getDealPlace() != null || dto.getDealPlaceLat() != null || dto.getDealPlaceLng() != null) { post.setDealPlace(dto.getDealPlace()); post.setDealPlaceLat(dto.getDealPlaceLat()); post.setDealPlaceLng(dto.getDealPlaceLng()); } - // ✅ 이미지 교체 로직 + // 이미지 교체 로직 if (dto.getImages() != null) { // 기존 이미지들 List oldImages = post.getImages(); @@ -114,7 +114,7 @@ public void editPost(EditPostRequestDto dto, Integer me){ .map(img -> img.getImageUrl()) .toList(); - // 삭제될 이미지 추출 = old(DB) list 에는 있는데 new list 에는 없음 + // 삭제될 이미지 추출 = old list(DB) 에는 있는데 new list 에는 없는 이미지들 List toDelete = oldImages.stream() .filter(img -> !newUrls.contains(img.getImageUrl())) .toList(); @@ -125,7 +125,7 @@ public void editPost(EditPostRequestDto dto, Integer me){ post.getImages().remove(img); }); - // 새로 추가된 이미지 = new list 에는 있는데 old(DB) list 에는 없음 + // 새로 추가된 이미지 = new list 에는 있는데 old list(DB) 에는 없는 이미지들 dto.getImages().forEach(imgDto -> { boolean exists = oldImages.stream() .anyMatch(img -> img.getImageUrl().equals(imgDto.getImageUrl())); @@ -151,7 +151,7 @@ public void deletePost(Integer postId, Integer me){ throw new SecurityException("PostService.deletePost(): 삭제 권한 없음"); } - // ✅ S3에서 이미지 삭제 + // S3에서 이미지 삭제 post.getImages().forEach(img -> awsS3Service.deleteFileByUrl(img.getImageUrl())); // 해당 게시글의 댓글 좋아요 삭제 @@ -170,7 +170,11 @@ public void deletePost(Integer postId, Integer me){ postRepository.delete(post); } - // 게시글 목록 조회(페이징) + // 게시글 지역 & 카테고리 목록 조회(페이징) + /** + * 지역은 필수, 카테고리는 nullable + * 하나의 메서드로 지역만 필터 / 지역+카테고리 필터 + */ @Transactional(readOnly = true) public Page getPostList(Integer regionId, Integer categoryId, String keyword, Pageable pageable){ Region region = regionRepository.findById(regionId) @@ -216,11 +220,13 @@ public PostDetailResponseDto getPostDetail(Integer postId, Integer me){ List comments = commentRepository.findByPostIdOrderByCreatedAtAsc(postId); - // 내가 좋아요 누른 댓글 ID들을 한 번에 조회 + // 내가 좋아요 누른 댓글 ID 한 번에 조회 List commentIds = comments.stream().map(Comment::getId).toList(); List likedIds = commentLikeRepository.findLikedCommentIds(me, commentIds); Set likedIdSet = likedIds.stream().collect(Collectors.toSet()); + // 게시글에 달린 댓글들 조회 + // 이떄 내가 좋아요 누른 댓글 ID 한 번에 조회한 것들로 내 좋아요 여부 판단 List commentDtos = comments.stream() .map(c -> CommentResponseDto.builder() .id(c.getId()) @@ -228,11 +234,12 @@ public PostDetailResponseDto getPostDetail(Integer postId, Integer me){ .nickname(c.getUser().getNickname()) .content(c.getContent()) .likeCount(c.getLikeCount()) - .likedByMe(likedIdSet.contains(c.getId())) + .likedByMe(likedIdSet.contains(c.getId())) // 댓글 좋아요 여부 .createdAt(c.getCreatedAt()) .build()) .toList(); + // 게시글 좋아요 여부 boolean likedByMe = postLikeRepository.findByUserIdAndPostId(me, postId) != null; return PostDetailResponseDto.builder() @@ -282,6 +289,7 @@ public void togglePostLike(Integer postId, Integer me){ } + // 게시글 지역별 조회 @Transactional(readOnly = true) public Page getPostsByRegion(Integer regionId, Pageable pageable) { Region region = regionRepository.findById(regionId) From 7bc166c8aafc9d267457552daec695260b46ea3d Mon Sep 17 00:00:00 2001 From: ariana9rande Date: Sun, 7 Dec 2025 22:24:49 +0900 Subject: [PATCH 3/6] =?UTF-8?q?=EC=BD=94=EB=93=9C=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 32 ++++ .../auth/service/AuthService.java | 38 ++++ .../payment/service/PaymentService.java | 59 +++++- .../product/controller/ProductController.java | 73 ++++++- .../product/service/ProductService.java | 178 ++++++++++++------ .../repository/TransactionRepository.java | 4 + .../user/service/UserService.java | 22 +++ 7 files changed, 332 insertions(+), 74 deletions(-) diff --git a/src/main/java/com/mrokga/carrot_server/auth/controller/AuthController.java b/src/main/java/com/mrokga/carrot_server/auth/controller/AuthController.java index 44096f5..33cbfda 100644 --- a/src/main/java/com/mrokga/carrot_server/auth/controller/AuthController.java +++ b/src/main/java/com/mrokga/carrot_server/auth/controller/AuthController.java @@ -34,6 +34,11 @@ public class AuthController { private final AuthService authService; private final UserService userService; + /** + * 휴대폰 번호로 인증번호 SMS를 발송하는 api + * @param phoneNumber 인증번호를 받을 휴대폰 번호 + * @return 성공 응답 DTO + */ @PostMapping("/send") @Operation(summary = "인증번호 sms 발송", description = "사용자 휴대폰 번호로 인증번호 sms 발송") @ApiResponses(value = { @@ -45,6 +50,12 @@ public ResponseEntity> sendSms(@Parameter(description = " return ResponseEntity.ok(ApiResponseDto.success(HttpStatus.OK.value(), "success")); } + /** + * 사용자가 입력한 인증번호를 검증하는 api + * Redis에 저장된 인증번호와 비교하여 결과를 return + * @param request 휴대폰번호와 인증번호 + * @return 인증 결과에 따른 응답 (200 OK, 400 BAD_REQUEST, 410 GONE) + */ @PostMapping("/verify") @Operation(summary = "인증번호 인증", description = "사용자가 입력한 인증번호와 redis에 저장된 값 비교") @ApiResponses(value = { @@ -75,6 +86,11 @@ public ResponseEntity> verifyCode(@RequestBody VerifyCodeRe }; } + /** + * 닉네임 중복 여부를 검사하는 api + * @param nickname 검사할 닉네임 + * @return 중복 여부에 따른 응답 (중복 시 400 BAD_REQUEST, 사용 가능 시 200 OK) + */ @PostMapping("/validate-nickname") @Operation(summary = "닉네임 중복검사", description = "사용자가 입력한 닉네임이 중복되었는지 검사") @ApiResponses(value = { @@ -100,6 +116,11 @@ public ResponseEntity> validateNickname(@Parameter(descri } + /** + * 새로운 사용자 회원가입을 처리하는 api + * @param request 회원가입 정보 + * @return 생성된 user entity 포함된 응답 DTO + */ @PostMapping("/signup") @Operation(summary = "회원가입 요청", description = "회원가입 요청") @ApiResponses(value = { @@ -115,6 +136,11 @@ public ResponseEntity> signup(@RequestBody SignupRequestDto return ResponseEntity.ok(ApiResponseDto.success(HttpStatus.OK.value(), "success", user)); } + /** + * 인증번호 SMS를 재발송하는 api + * @param phoneNumber 재발송을 요청할 휴대폰 번호 + * @return 성공 응답 DTO + */ @PostMapping("/resend") @Operation(summary = "인증번호 sms 재발송", description = "사용자 휴대폰 번호로 인증번호 sms 재발송") @ApiResponses(value = { @@ -126,6 +152,12 @@ public ResponseEntity> resendSms(@Parameter(description = " return ResponseEntity.ok(ApiResponseDto.success(HttpStatus.OK.value(), "success")); } + /** + * 로그인 처리 api + * 인증 성공 시 사용자 정보를 조회하고 jwt를 발급한 뒤 return + * @param request 전화번호 및 입력된 인증번호 + * @return 로그인 성공 시 토큰과 사용자 정보를 포함한 응답 DTO, 실패 시 에러 응답 + */ @PostMapping("/login") @Operation(summary = "로그인 요청", description = "로그인 요청") @ApiResponses(value = { diff --git a/src/main/java/com/mrokga/carrot_server/auth/service/AuthService.java b/src/main/java/com/mrokga/carrot_server/auth/service/AuthService.java index ab1f05b..52496cc 100644 --- a/src/main/java/com/mrokga/carrot_server/auth/service/AuthService.java +++ b/src/main/java/com/mrokga/carrot_server/auth/service/AuthService.java @@ -35,9 +35,14 @@ public class AuthService { private static final String ACCESS_TOKEN_PREFIX = "access_token:"; private static final String REFRESH_TOKEN_PREFIX = "refresh_token:"; + // SMS 발신 번호 @Value("${sms.sender}") private String sender; + /** + * 지정된 발신번호로 인증번호 SMS를 전송하고, 인증번호를 Redis에 저장 + * @param phoneNumber 인증번호를 받을 전화번호 + */ public void sendSms(String phoneNumber) { String code = generateCode(); @@ -53,24 +58,35 @@ public void sendSms(String phoneNumber) { try { messageService.send(message); } catch (NurigoMessageNotReceivedException e) { + redisTemplate.delete(key); log.info("failed message list = {}", e.getFailedMessageList()); log.info("exception = {}", e.getMessage()); } catch (Exception e) { + redisTemplate.delete(key); log.info("exception = {}", e.getMessage()); } } + /** + * 사용자가 입력한 인증번호의 유효성 검증 + * @param phoneNumber 전화번호 (Redis Key 조회용) + * @param code 사용자 입력 인증번호 + * @return 인증 결과 {@link VerifyCodeResult} + */ public VerifyCodeResult verifyCode(String phoneNumber, String code) { log.info("[AuthService] verifyCode starts"); String key = SMS_PREFIX + phoneNumber; + // 1. Redis에서 해당 휴대폰번호로 저장된 인증번호 조회 String saved = redisTemplate.opsForValue().get(key); log.info("saved = {}", saved); + // 2. 저장된 인증번호가 없는 경우 (만료로 판단) if (saved == null) { return VerifyCodeResult.EXPIRED; } + // 3. Redis에서 조회한 인증번호와 사용자가 입력한 인증번호가 일치하지 않는 경우 if(!saved.equals(code)) { return VerifyCodeResult.MISMATCH; } @@ -91,6 +107,10 @@ public static String generateCode() { return String.format("%06d", number); } + /** + * 기존 인증번호를 삭제하고 새로운 인증번호 SMS를 재전송 + * @param phoneNumber 인증번호를 받을 전화번호 + */ public void resendSms(String phoneNumber) { redisTemplate.delete(SMS_PREFIX + phoneNumber); @@ -98,6 +118,11 @@ public void resendSms(String phoneNumber) { sendSms(phoneNumber); } + /** + * Access Token과 Refresh Token을 발급하고, Refresh Token을 Redis에 저장 후 token이 담긴 DTO를 반환 + * @param user 토큰을 발급받을 유저 + * @return 발급된 토큰 정보가 담긴 DTO + */ public TokenResponseDto issueAndReturnTokens(User user) { String accessToken = tokenProvider.generateAccessToken(user); String refreshToken = tokenProvider.generateRefreshToken(user); @@ -111,17 +136,30 @@ public TokenResponseDto issueAndReturnTokens(User user) { .build(); } + /** + * Refresh Token을 사용하여 Access Token과 Refresh Token 갱신 + * @param user 토큰을 갱신할 유저 + * @param oldRefreshToken 갱신 요청 시 사용될 기존 Refresh Token + * @return 새롭게 발급된 토큰 정보가 담긴 DTO + */ public TokenResponseDto renew(User user, String oldRefreshToken) { String key = REFRESH_TOKEN_PREFIX + user.getId(); String storedRefreshToken = redisTemplate.opsForValue().get(key); + // 1. 저장된 토큰이 없거나, 요청된 토큰과 저장된 토큰이 일치하지 않거나, 토큰 자체의 유효성 검증에 실패한 경우 if(storedRefreshToken == null || !storedRefreshToken.equals(oldRefreshToken) || !tokenProvider.validToken(oldRefreshToken)) { throw new RuntimeException("INVALID REFRESH TOKEN"); } + // 2. 유효한 경우, 새로운 토큰 발급 및 저장 return issueAndReturnTokens(user); } + /** + * Refresh Token을 Redis에 저장 + * @param user + * @param refreshToken 저장할 Refresh Token + */ public void saveRefreshToken(User user, String refreshToken) { String key = REFRESH_TOKEN_PREFIX + user.getId(); diff --git a/src/main/java/com/mrokga/carrot_server/payment/service/PaymentService.java b/src/main/java/com/mrokga/carrot_server/payment/service/PaymentService.java index 2e2483d..cadfcd6 100644 --- a/src/main/java/com/mrokga/carrot_server/payment/service/PaymentService.java +++ b/src/main/java/com/mrokga/carrot_server/payment/service/PaymentService.java @@ -17,8 +17,6 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; -import org.springframework.util.LinkedMultiValueMap; -import org.springframework.util.MultiValueMap; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.LinkedMultiValueMap; @@ -52,8 +50,11 @@ public class PaymentService { private final RestTemplate restTemplate = new RestTemplate(); - - + /** + * 카카오페이 API 통신을 위한 공통 헤더 생성 method + * Authorization header와 Content-Type 설정 + * @return HttpHeaders 객체 + */ private HttpHeaders buildKakaoHeaders() { HttpHeaders headers = new HttpHeaders(); headers.set("Authorization", "KakaoAK " + kakaoAdminKey); @@ -61,21 +62,30 @@ private HttpHeaders buildKakaoHeaders() { return headers; } - // ✅ 카카오페이 결제 준비 + /** + * 카카오페이 결제 준비를 요청하고, DB에 결제 정보를 저장 + * @param transactionId 거래 ID + * @return 카카오페이 결제 준비 응답 DTO + */ public KakaoReadyResponse kakaoReadyPayment(Integer transactionId) { + // 1. 트랜잭션 조회 및 유효성 검증 Transaction transaction = transactionRepository.findById(transactionId) .orElseThrow(() -> new IllegalArgumentException("Transaction not found")); + // 2. 이미 결제가 진행 중인지 확인 (중복 요청 방지) if (paymentRepository.findByTransaction(transaction).isPresent()) { throw new IllegalStateException("이미 결제가 진행 중인 거래입니다."); } + // 3. 카카오페이 결제 준비 API URL String url = kakaoHost + "/v1/payment/ready"; + // 4. 요청 헤더 설정 HttpHeaders headers = new HttpHeaders(); headers.set("Authorization", "KakaoAK " + kakaoAdminKey); headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + // 5. 요청 파라미터 설정 MultiValueMap params = new LinkedMultiValueMap<>(); params.add("cid", kakaoCid); params.add("partner_order_id", String.valueOf(transaction.getId())); @@ -88,17 +98,20 @@ public KakaoReadyResponse kakaoReadyPayment(Integer transactionId) { params.add("cancel_url", kakaoBaseUrl + "/cancel"); params.add("fail_url", kakaoBaseUrl + "/fail"); + // 6. API 요청 HttpEntity> request = new HttpEntity<>(params, headers); ResponseEntity response = restTemplate.postForEntity(url, request, Map.class); Map body = response.getBody(); + // 7. 응답 확인 및 예외 처리 if (response.getStatusCode().isError() || body == null) { throw new IllegalStateException("카카오페이 결제 준비 실패: " + body.get("msg")); } log.info("[KakaoPay] Ready - transactionId={}, tid={}", transactionId, body.get("tid")); + // 8. DB에 결제 정보 저장 (상태: READY) Payment payment = Payment.builder() .transaction(transaction) .method(PaymentMethod.KAKAOPAY) @@ -109,6 +122,7 @@ public KakaoReadyResponse kakaoReadyPayment(Integer transactionId) { .build(); paymentRepository.save(payment); + // 9. 클라이언트에 Redirect 정보 반환 return KakaoReadyResponse.builder() .tid((String) body.get("tid")) .redirectPcUrl((String) body.get("next_redirect_pc_url")) @@ -117,8 +131,15 @@ public KakaoReadyResponse kakaoReadyPayment(Integer transactionId) { .build(); } - /** ✅ 결제 승인 */ + /** + * 카카오페이 결제 승인 요청 + * 결제 금액 검증 후 상태를 APPROVED로 업데이트 + * @param transactionId 거래 ID + * @param pgToken 결제 승인 요청을 위한 토큰 + * @return 카카오페이 결제 승인 응답 DTO + */ public KakaoApproveResponse kakaoApprovePayment(Integer transactionId, String pgToken) { + // 1. 트랜잭션, 결제 정보 조회 및 유효성 검사 Transaction transaction = transactionRepository.findById(transactionId) .orElseThrow(() -> new IllegalArgumentException("Transaction not found")); @@ -127,16 +148,20 @@ public KakaoApproveResponse kakaoApprovePayment(Integer transactionId, String pg String tid = payment.getTid(); + // 2. 이미 승인된 결제인지 확인(중복 요청 방지) if (payment.getStatus() == PaymentStatus.APPROVED) { throw new IllegalStateException("이미 승인된 결제입니다."); } + // 3. 카카오페이 결제 승인 API URL String url = kakaoHost + "/v1/payment/approve"; + // 4. 요청 헤더 설정 HttpHeaders headers = new HttpHeaders(); headers.set("Authorization", "KakaoAK " + kakaoAdminKey); headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + // 5. 요청 파라미터 설정 MultiValueMap params = new LinkedMultiValueMap<>(); params.add("cid", kakaoCid); params.add("tid", tid); @@ -144,6 +169,7 @@ public KakaoApproveResponse kakaoApprovePayment(Integer transactionId, String pg params.add("partner_user_id", String.valueOf(transaction.getBuyer().getId())); params.add("pg_token", pgToken); + // 6. API 요청 HttpEntity> request = new HttpEntity<>(params, headers); ResponseEntity response = restTemplate.postForEntity(url, request, Map.class); @@ -152,8 +178,10 @@ public KakaoApproveResponse kakaoApprovePayment(Integer transactionId, String pg throw new IllegalStateException("카카오페이 승인 실패"); } + // 7. 결제 금액 검증 int kakaoAmount = (Integer) ((Map) body.get("amount")).get("total"); int expectedAmount = transaction.getProduct().getPrice(); + // 결제 금액이 DB에 기록된 기대 금액과 일치하지 않을 경우 예외 처리 if (kakaoAmount != expectedAmount) { throw new IllegalStateException("결제 금액 불일치 (expected=" + expectedAmount + ", kakao=" + kakaoAmount + ")"); } @@ -161,10 +189,16 @@ public KakaoApproveResponse kakaoApprovePayment(Integer transactionId, String pg // 내부 거래 완료 처리 (상품 SOLD) transactionService.approve(transactionId); + // 8. DB 상태 업데이트 (결제 상태: APPROVED) payment.setStatus(PaymentStatus.APPROVED); payment.setCompletedAt(LocalDateTime.now()); paymentRepository.save(payment); + // 9. 트랜잭션 완료 시간 업데이트 + transaction.setCompletedAt(LocalDateTime.now()); + transactionRepository.save(transaction); + + // 10. 승인 응답 DTO 반환 return KakaoApproveResponse.builder() .aid((String) body.get("aid")) .tid((String) body.get("tid")) @@ -174,20 +208,29 @@ public KakaoApproveResponse kakaoApprovePayment(Integer transactionId, String pg .build(); } - /** ✅ 결제 취소 */ + /** + * 카카오페이 결제 취소 요청 + * @param tid 카카오페이 거래 고유번호 + * @param cancelAmount 취소할 금액 + * @return 카카오페이 결제 취소 응답 DTO + */ public KakaoCancelResponse kakaoCancelPayment(String tid, int cancelAmount) { + // 1. 카카오페이 결제 취소 API URL String url = kakaoHost + "/v1/payment/cancel"; + // 2. 요청 헤더 설정 HttpHeaders headers = new HttpHeaders(); headers.set("Authorization", "KakaoAK " + kakaoAdminKey); headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + // 3. 요청 파라미터 설정 MultiValueMap params = new LinkedMultiValueMap<>(); params.add("cid", kakaoCid); params.add("tid", tid); params.add("cancel_amount", String.valueOf(cancelAmount)); params.add("cancel_tax_free_amount", "0"); + // 4. API 요청 HttpEntity> request = new HttpEntity<>(params, headers); ResponseEntity response = restTemplate.postForEntity(url, request, Map.class); @@ -196,6 +239,7 @@ public KakaoCancelResponse kakaoCancelPayment(String tid, int cancelAmount) { throw new IllegalStateException("카카오페이 취소 실패"); } + // 5. DB에서 결제 정보 조회 및 상태 업데이트 (상태: CANCELED) Payment payment = paymentRepository.findByTid(tid) .orElseThrow(() -> new IllegalArgumentException("Payment not found")); @@ -209,6 +253,7 @@ public KakaoCancelResponse kakaoCancelPayment(String tid, int cancelAmount) { transactionService.cancel(tx.getId()); } + // 6. 취소 응답 DTO 반환 return KakaoCancelResponse.builder() .tid((String) body.get("tid")) .canceledAmount((Integer) ((Map) body.get("canceled_amount")).get("total")) diff --git a/src/main/java/com/mrokga/carrot_server/product/controller/ProductController.java b/src/main/java/com/mrokga/carrot_server/product/controller/ProductController.java index f0bbc54..dc50f20 100644 --- a/src/main/java/com/mrokga/carrot_server/product/controller/ProductController.java +++ b/src/main/java/com/mrokga/carrot_server/product/controller/ProductController.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.mrokga.carrot_server.aws.service.AwsS3Service; import com.mrokga.carrot_server.product.dto.request.ProductImageRequestDto; +import com.mrokga.carrot_server.product.dto.response.ChangeStatusResponseDto; import com.mrokga.carrot_server.product.dto.response.ProductDetailResponseDto; import com.mrokga.carrot_server.api.dto.ApiResponseDto; import com.mrokga.carrot_server.product.dto.request.ChangeStatusRequestDto; @@ -15,11 +16,14 @@ import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.data.web.PageableDefault; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; @@ -40,7 +44,12 @@ public class ProductController { private final ProductService productService; private final AwsS3Service awsS3Service; - // 기존 JSON 방식 유지 (@RequestBody) — 2-Step 등록용 + /** + * 상품 등록 api (이미지 URL을 JSON 내부에 포함하는 2-step 방식) + * 이미지는 미리 S3에 업로드되어 URL 형태로 요청 본문에 포함되어야 함 + * @param req 상품 생성 요청 DTO (이미지 URL 포함) + * @return 등록된 상품 정보 + */ @PostMapping @Operation(summary = "상품 등록(JSON, 이미지 URL 포함)", description = "이미지를 먼저 /file/upload로 올리고, 반환된 S3 URL을 본 API에 images.imageUrl로 전달하세요.") @ApiResponse(responseCode = "200", description = "상품 등록 성공", content = @Content(schema = @Schema(implementation = ApiResponseDto.class))) @@ -49,7 +58,14 @@ public ResponseEntity> create(@RequestBody CreateProductReques return ResponseEntity.ok(ApiResponseDto.success(HttpStatus.OK.value(), "success", product)); } - // 신규: 멀티파트 한방 등록(JSON + 파일) + /** + * 상품 정보와 이미지 파일을 한 번에 받아 처리하는 api + * 이미지 파일을 S3에 업로드 후, 반환된 URL을 DTO에 주입하여 상품 등록 서비스에 전달 + * @param metaJson 상품 메타데이터 JSON 문자열 (CreateProductRequestDto) + * @param images 업로드할 이미지 파일 리스트 + * @return 등록된 상품 정보 + * @throws Exception JSON 파싱 오류 또는 파일 처리 오류 + */ @PostMapping(value = "/multipart", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @Operation( summary = "상품 등록(멀티파트: JSON + 이미지 파일)", @@ -108,15 +124,36 @@ public ResponseEntity> createMultipart( return ResponseEntity.ok(ApiResponseDto.success(HttpStatus.OK.value(), "success", product)); } + /** + * 상품의 거래 상태(TradeStatus)를 변경하는 api + * @param req 상태 변경 요청 DTO + * @return 변경된 거래 상태 정보가 담긴 응답 DTO + */ @Operation(summary = "상품 거래 상태 변경", description = "상품 거래 상태 변경") @PutMapping("/status") public ResponseEntity changeStatus(@RequestBody ChangeStatusRequestDto req) { productService.changeStatus(req); + // 상품 상태 변경 로직을 호출하고 결과를 응답 DTO에 저장 + ChangeStatusResponseDto response = productService.changeStatus(req); + + // 상태 변경 후 상품 상세 정보를 리턴 + Product product = productService.getProductDetail(req.getProductId()); + + if (product != null) { + + return ResponseEntity.ok(ApiResponseDto.success(HttpStatus.OK.value(), "success", response)); + } + return ResponseEntity.ok(ApiResponseDto.success(HttpStatus.OK.value(), "success", null)); } + /** + * 사용자의 현재 위치를 기준으로 상품 목록을 조회하는 api + * @param userId 사용자의 ID + * @return 페이지네이션된 상품 목록 DTO + */ @Operation(summary = "상품 목록 조회", description = "현재 위치에 노출 설정한 상품 목록 조회") @GetMapping("/list") public ResponseEntity> getProductList(@RequestParam int userId) { @@ -124,6 +161,11 @@ public ResponseEntity> getProductList(@RequestParam int userId return ResponseEntity.ok(ApiResponseDto.success(HttpStatus.OK.value(), "success", list)); } + /** + * 상품의 상세 정보를 조회하는 api + * @param id 조회할 상품의 ID + * @return 상품 상세 정보 DTO + */ @Operation(summary = "상품 상세 조회", description = "상품 상세 조회") @GetMapping("/{id}") public ResponseEntity> getProductDetail(@Parameter(description = "상품 ID", example = "7") @PathVariable int id) { @@ -137,6 +179,12 @@ public ResponseEntity> getProductDetail(@Parameter(description return ResponseEntity.ok(ApiResponseDto.error(HttpStatus.NOT_FOUND.value(), "not found")); } + /** + * 특정 상품을 찜하는 api. 토글 방식 + * @param userId 사용자 ID + * @param productId 찜하기 대상 상품 ID + * @return 성공 응답 (상태 변경 완료) + */ @Operation(summary = "찜하기", description = "찜하기") @PostMapping("/{productId}/favorite") public ResponseEntity favoriteProduct(@Parameter(description = "유저 ID", example = "7") @RequestParam int userId, @Parameter(description = "상품 ID", example = "7") @PathVariable int productId) { @@ -145,10 +193,23 @@ public ResponseEntity favoriteProduct(@Parameter(description = "유저 ID", e return ResponseEntity.ok(ApiResponseDto.success(HttpStatus.OK.value(), "success", null)); } - @Operation(summary = "상품 이름 검색", description = "사용자가 입력한 상품 이름으로 상품 목록 검색") - @GetMapping("/search/{keyword}") - public ResponseEntity searchProduct(@PathVariable String keyword, - @PageableDefault(size = 10, sort = "createdAt") Pageable pageable) { + /** + * 상품 이름 키워드를 기반으로 상품 목록을 검색하고 페이지네이션하여 반환하는 api + * @param keyword 검색할 상품 이름 키워드 + * @param page 페이지 번호 (0부터 시작) + * @param size 한 페이지당 상품 개수 + * @param sort 정렬 기준 필드 + * @return 페이지네이션된 검색 결과 DTO + */ + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "상품 검색 성공", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ApiResponseDto.ApiPageProductResponse.class))) + }) + public ResponseEntity searchProduct(@Parameter(description = "검색할 키워드", example = "test") @PathVariable String keyword, + @Parameter(description = "페이지 번호 (0부터 시작)", example = "0") @RequestParam(defaultValue = "0") int page, + @Parameter(description = "한 페이지 크기", example = "10") @RequestParam(defaultValue = "10") int size, + @Parameter(description = "정렬 기준 (예: createdAt)", example = "createdAt") @RequestParam(defaultValue = "createdAt") String sort) { + // Pageable 객체 생성: 페이지 번호, 크기, 정렬 기준으로 구성 + Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt")); Page results = productService.searchProduct(keyword, pageable); return ResponseEntity.ok(ApiResponseDto.success(HttpStatus.OK.value(), "success", results)); diff --git a/src/main/java/com/mrokga/carrot_server/product/service/ProductService.java b/src/main/java/com/mrokga/carrot_server/product/service/ProductService.java index 65e254a..ee63b26 100644 --- a/src/main/java/com/mrokga/carrot_server/product/service/ProductService.java +++ b/src/main/java/com/mrokga/carrot_server/product/service/ProductService.java @@ -19,6 +19,7 @@ import com.mrokga.carrot_server.transaction.repository.TransactionRepository; import com.mrokga.carrot_server.user.entity.User; import com.mrokga.carrot_server.user.repository.UserRepository; +import com.openai.models.beta.threads.runs.Run; import jakarta.persistence.EntityNotFoundException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -45,13 +46,20 @@ public class ProductService { private final ProductExposureRegionRepository productExposureRegionRepository; private final UserRegionRepository userRegionRepository; private final NotificationService notificationService; + private final ReviewRepository reviewRepository; + /** + * 새로운 상품을 등록 + * + * @param req 상품 생성에 필요한 데이터 DTO + * @return 저장된 Product 엔티티 + */ @Transactional public Product createProduct(CreateProductRequestDto req) { try { log.info("[ProductService.createProduct] req = {}", req); - // 1) 기본 엔티티 조회 + // 1) 기본 엔티티 조회 및 유효성 검증 User user = userRepository.findById(req.getUserId()) .orElseThrow(() -> new EntityNotFoundException("[ProductService.createProduct] User not found")); @@ -61,23 +69,26 @@ public Product createProduct(CreateProductRequestDto req) { Category category = categoryRepository.findById(req.getCategoryId()) .orElseThrow(() -> new EntityNotFoundException("[ProductService.createProduct] Category not found")); - // 2) 이미지 매핑 (여러 장) + // 2) 이미지 매핑 및 처리 (순서, 썸네일 지정) List productImages = null; if (req.getImages() != null && !req.getImages().isEmpty()) { AtomicInteger index = new AtomicInteger(0); - // 썸네일 지정 여부 체크 + // 요청에서 썸네일(isThumbnail=true)이 명시적으로 지정되었는지 확인 boolean hasThumb = req.getImages().stream() .anyMatch(i -> Boolean.TRUE.equals(i.getIsThumbnail())); productImages = req.getImages().stream() + // sortOrder 기준으로 정렬 .sorted(Comparator.comparingInt(ProductImageRequestDto::getSortOrder)) .map(imageDto -> ProductImage.builder() .imageUrl(imageDto.getImageUrl()) .sortOrder(index.getAndIncrement()) .isThumbnail( hasThumb + // 썸네일이 지정되어 있다면 그 값을 따르고 ? Boolean.TRUE.equals(imageDto.getIsThumbnail()) - : index.get() == 1 // 썸네일 지정 없으면 첫 번째만 true + // 지정이 안 되어 있으면 첫 번째 이미지(index.get() == 1)를 썸네일로 자동 설정 + : index.get() == 1 ) .build()) .toList(); @@ -93,7 +104,7 @@ public Product createProduct(CreateProductRequestDto req) { .build(); } - // 4) Product 생성 + // 4) Product 엔티티 생성 Product product = Product.builder() .user(user) .region(region) @@ -107,7 +118,7 @@ public Product createProduct(CreateProductRequestDto req) { .preferredLocation(preferredLocation) .build(); - // 연관관계 설정 + // 연관관계 설정 (ProductImage와 Product 맵핑) if (productImages != null) { productImages.forEach(img -> img.setProduct(product)); } @@ -135,6 +146,7 @@ public Product createProduct(CreateProductRequestDto req) { Favorite favorite = favoriteRepository.findByUserIdAndCategoryId(req.getUserId(), req.getCategoryId()); + // 7) 카테고리 알림 전송 (찜한 카테고리 알림) if (favorite != null) { notificationService.sendCategoryProductNotification(user, product); } @@ -147,7 +159,9 @@ public Product createProduct(CreateProductRequestDto req) { } } - /*** + /** + * 상품의 거래 상태(TradeStatus)를 변경하고 관련 트랜잭션(Transaction) 정보를 업데이트 + * * 판매중: 예약중/거래완료 둘 다 전이가능 * 판매중 -> 예약중: status 예약중으로 변경, transaction 생성, buyer_id가 null이 아니면 추가 * 판매중 -> 거래완료: status 거래완료로 변경, transaction 생성, buyer_id가 null이 아니면 추가, completed_at 추가 @@ -157,9 +171,14 @@ public Product createProduct(CreateProductRequestDto req) { * 예약중 -> 거래완료: status 거래완료로 변경, buyer_id가 null이면 삭제, null이 아니면 변경 * * 거래완료 -> 판매중: transaction 삭제, 리뷰가 있을 경우 삭제, status 판매중으로 변경 + * + * @param req 상태 변경 요청 DTO (상품 ID, 변경할 상태, 구매자 ID 등 포함) + * @return 변경된 상태 정보를 포함하는 응답 DTO + * @throws EntityNotFoundException 상품, 판매자, 구매자 또는 트랜잭션을 찾을 수 없을 때 발생 */ @Transactional public ChangeStatusResponseDto changeStatus(ChangeStatusRequestDto req) { + log.info("[ProductService.changeStatus] req = {}", req); Product product = productRepository.findById(req.getProductId()).orElseThrow(() -> new EntityNotFoundException("Product not found")); @@ -167,117 +186,132 @@ public ChangeStatusResponseDto changeStatus(ChangeStatusRequestDto req) { TradeStatus status = product.getStatus(); - switch (status) { - case ON_SALE -> { + Transaction transaction = null; - Transaction transaction = Transaction.builder() + switch (status) { + case ON_SALE -> { // 현재: 판매중 + // 판매중 -> 예약중 or 거래완료로 변경 시 새로운 transaction 생성 + transaction = Transaction.builder() .product(product) .seller(user) .build(); + // 요청된 상태로 변경 product.setStatus(req.getStatus()); + // 요청에 구매자가 지정된 경우 트랜잭션에 구매자 정보 업데이트 if (req.getBuyerId() != null) { transaction.setBuyer(userRepository.findById(req.getBuyerId()).orElseThrow(() -> new EntityNotFoundException("Buyer not found"))); } + // 거래 완료(SOLD)로 변경하는 경우 완료 시간 설정 if (req.getStatus().equals(TradeStatus.SOLD) && req.getCompletedAt() != null) { transaction.setCompletedAt(req.getCompletedAt()); } transactionRepository.save(transaction); - - return ChangeStatusResponseDto.builder() - .productId(transaction.getProduct().getId()) - .sellerId(transaction.getSeller().getId()) - .buyerId(transaction.getBuyer().getId()) - .status(product.getStatus()) - .completedAt(transaction.getCompletedAt()) - .build(); } - case RESERVED -> { + case RESERVED -> { // 현재: 예약중 - Transaction transaction = transactionRepository.findById(req.getProductId()).orElseThrow(() -> new EntityNotFoundException("Transaction not found")); + // product ID로 트랜잭션 조회 + transaction = transactionRepository.findByProductId(req.getProductId()).orElseThrow(() -> new EntityNotFoundException("Transaction not found")); + log.info("[ProductService.changeStatus] transaction = {}", transaction); if (req.getStatus().equals(TradeStatus.ON_SALE)) { + // 판매중으로 변경 시 기존 트랜잭션 삭제 transactionRepository.delete(transaction); } else if (req.getStatus().equals(TradeStatus.SOLD)) { - if (req.getBuyerId() == null) { - transaction.setBuyer(null); - } else { - transaction.setBuyer(userRepository.findById(req.getBuyerId()).orElseThrow(() -> new EntityNotFoundException("Buyer not found"))); + // 거래완료로 변경 시 구매자 정보 업데이트 + transaction.setBuyer(userRepository.findById(req.getBuyerId()).orElseThrow(() -> new EntityNotFoundException("Buyer not found"))); + + // 거래 완료 시간 설정 + if (req.getCompletedAt() != null) { + transaction.setCompletedAt(req.getCompletedAt()); } } product.setStatus(req.getStatus()); - - return ChangeStatusResponseDto.builder() - .productId(transaction.getProduct().getId()) - .sellerId(transaction.getSeller().getId()) - .buyerId(transaction.getBuyer().getId()) - .status(product.getStatus()) - .completedAt(transaction.getCompletedAt()) - .build(); } - case SOLD -> { - Transaction transaction = transactionRepository.findById(req.getProductId()).orElseThrow(() -> new EntityNotFoundException("Transaction not found")); + case SOLD -> { // 현재: 거래완료 + // 기존 트랜잭션 조회 + transaction = transactionRepository.findByProductId(req.getProductId()).orElseThrow(() -> new EntityNotFoundException("Transaction not found")); + + // 해당 트랜잭션에 리뷰가 있는 경우 예외처리 + if (reviewRepository.existsByTransactionId(transaction.getId())) { + throw new RuntimeException("리뷰가 작성된 트랜잭션은 변경 불가"); + } if (req.getStatus().equals(TradeStatus.ON_SALE)) { + + // 판매중으로 변경 시 기존 트랜잭션 삭제 transactionRepository.delete(transaction); - //TODO 당근에서는 리뷰가 있는 경우에만 리뷰 삭제 경고가 뜸. 어떻게 할지 결정 필요 - // 당근처럼 하려면 review 유무 확인 후 프론트와 한차례 더 통신 필요 -// if (transaction.getBuyer() != null) { -// Review review = reviewRepository.findBySellerIdAndBuyerId(); -// -// if (review != null) { -// return ~~~ -// } -// } - - product.setStatus(req.getStatus()); - - return ChangeStatusResponseDto.builder() - .productId(transaction.getProduct().getId()) - .sellerId(transaction.getSeller().getId()) - .buyerId(transaction.getBuyer().getId()) - .status(product.getStatus()) - .completedAt(transaction.getCompletedAt()) - .build(); + } else if (req.getStatus().equals(TradeStatus.RESERVED)) { + // 구매자 정보 업데이트 + transaction.setBuyer(userRepository.findById(req.getBuyerId()).orElseThrow(() -> new EntityNotFoundException("Buyer not found"))); } + // 기존 거래 완료 시각 삭제 + transaction.setCompletedAt(null); + + product.setStatus(req.getStatus()); } default -> throw new RuntimeException("Invalid status [" + status + "]"); } - return null; + return ChangeStatusResponseDto.builder() + .productId(transaction.getProduct().getId()) + .sellerId(transaction.getSeller().getId()) + .buyerId(transaction.getBuyer().getId()) + .status(product.getStatus()) + .completedAt(transaction.getCompletedAt()) + .build(); + } + /** + * 상품 상세 정보를 조회하고 조회수 1 증가 + * + * @param id 조회할 상품의 ID + * @return 조회된 Product 엔티티 + * @throws EntityNotFoundException 상품을 찾을 수 없을 때 발생 + */ @Transactional public Product getProductDetail(int id) { + // 모든 연관 관계 entity들을 한 번에 fetch하여 N+1 문제 방지 Product product = productRepository.findByIdWithAllRelations(id).orElseThrow(() -> new EntityNotFoundException("[ProductService.getProductDetail] Product not found")); + // 조회수 증가 product.increaseViewCount(); return product; } + /** + * 카테고리 또는 상품에 대한 찜(Favorite) 상태를 토글합니다. + * + * @param userId 사용자 ID + * @param type 찜하기 대상 타입 ('C' - Category, 'P' - Product) + * @param targetId 대상 엔티티의 ID (Category ID 또는 Product ID) + * @throws EntityNotFoundException 사용자, 카테고리 또는 상품을 찾을 수 없을 때 발생 + */ @Transactional public void toggleFavorite(int userId, String type, int targetId) { User user = userRepository.findById(userId).orElseThrow(() -> new EntityNotFoundException("[ProductService.toggleFavorite] User not found")); switch (type.toUpperCase()) { - case "C" -> { + case "C" -> { // 카테고리 찜하기 Category category = categoryRepository.findById(targetId).orElseThrow(() -> new EntityNotFoundException("[ProductService.toggleFavorite] Category not found")); Favorite favorite = favoriteRepository.findByUserIdAndCategoryId(userId, category.getId()); + // 찜하기 등록 if (favorite == null) { favoriteRepository.save(Favorite.builder() .category(category) @@ -285,15 +319,17 @@ public void toggleFavorite(int userId, String type, int targetId) { .build()); } else { + // 찜하기 취소 favoriteRepository.delete(favorite); } } - case "P" -> { + case "P" -> { // 상품 찜하기 Product product = productRepository.findById(targetId).orElseThrow(() -> new EntityNotFoundException("[ProductService.toggleFavorite] Product not found")); Favorite favorite = favoriteRepository.findByUserIdAndProductId(userId, targetId); + // 찜하기 등록 + count 증가 if (favorite == null) { favoriteRepository.save(Favorite.builder() .product(product) @@ -302,6 +338,7 @@ public void toggleFavorite(int userId, String type, int targetId) { product.increaseFavoriteCount(); } else { + // 찜하기 취소 + count 감소 favoriteRepository.delete(favorite); product.decreaseFavoriteCount(); } @@ -311,18 +348,37 @@ public void toggleFavorite(int userId, String type, int targetId) { } } + /** + * 특정 사용자의 활성 노출 지역을 기준으로 상품 목록을 조회합니다. + * + * @param userId 사용자 ID + * @return 해당 지역에 노출 설정된 상품 목록 DTO + * @throws EntityNotFoundException 활성 사용자 지역 정보를 찾을 수 없을 때 발생 + */ @Transactional - public List getProductList(int userId) { - UserRegion userRegion = userRegionRepository.findActiveByUserId(userId) - .orElseThrow(() -> new EntityNotFoundException("[ProductService.getProductList] UserRegion not found")); - return productRepository.findAllListItemByExposureRegion(userRegion.getRegion()); + public List getProductList(int userId) { + + // 사용자 ID로 활성(Active) 지역 정보를 조회 + UserRegion userRegion = userRegionRepository.findActiveByUserId(userId).orElseThrow(() -> new EntityNotFoundException("[ProductService.getProductList] UserRegion not found")); + + // 해당 지역에 노출하도록 설정된 상품 목록 조회 + return productRepository.findAllDtoByExposureRegion(userRegion.getRegion()); } + /** + * 상품 제목을 기준으로 키워드 검색을 수행하고 페이지네이션된 결과를 반환합니다. + * + * @param keyword 검색할 키워드 + * @param pageable 페이지네이션 정보 (페이지 번호, 크기, 정렬) + * @return 검색 결과가 담긴 페이지 DTO + */ @Transactional(readOnly = true) public Page searchProduct(String keyword, Pageable pageable) { + // 키워드가 없으면 빈 페이지 반환 if (keyword == null || keyword.trim().isEmpty()) { - return Page.empty(pageable); // 빈 결과 반환 + return Page.empty(pageable); } + // 제목에 키워드가 포함된 상품 목록을 페이지네이션하여 조회 return productRepository.findAllByTitleContaining(keyword, pageable); } diff --git a/src/main/java/com/mrokga/carrot_server/transaction/repository/TransactionRepository.java b/src/main/java/com/mrokga/carrot_server/transaction/repository/TransactionRepository.java index 37cde76..f29fce6 100644 --- a/src/main/java/com/mrokga/carrot_server/transaction/repository/TransactionRepository.java +++ b/src/main/java/com/mrokga/carrot_server/transaction/repository/TransactionRepository.java @@ -9,6 +9,8 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; +import java.util.Optional; + @Repository public interface TransactionRepository extends JpaRepository { @@ -32,4 +34,6 @@ public interface TransactionRepository extends JpaRepository findPurchasedItemsByBuyerId(Integer buyerId, Pageable pageable); + + Optional findByProductId(Integer productId); } diff --git a/src/main/java/com/mrokga/carrot_server/user/service/UserService.java b/src/main/java/com/mrokga/carrot_server/user/service/UserService.java index 92e3931..d5473d7 100644 --- a/src/main/java/com/mrokga/carrot_server/user/service/UserService.java +++ b/src/main/java/com/mrokga/carrot_server/user/service/UserService.java @@ -28,8 +28,16 @@ public class UserService { @Value("${default-profile-image-url}") private String defaultProfileImageUrl; + /** + * 새로운 사용자를 등록하고, 해당 사용자의 초기 지역 정보 설정 + * + * @param dto 회원가입 요청 데이터 DTO + * @return 저장된 User 엔티티 + * @throws EntityNotFoundException 지역 정보를 찾을 수 없을 때 발생 + */ @Transactional public User signup(SignupRequestDto dto) { + // 1. User 엔티티 생성 및 저장 User user = User.builder() .phoneNumber(dto.getPhoneNumber()) .nickname(dto.getNickname()) @@ -39,9 +47,11 @@ public User signup(SignupRequestDto dto) { userRepository.save(user); + // 2. 지역 정보 조회 및 유효성 검증 Region region = regionRepository.findByFullName(dto.getRegion()).orElseThrow(() -> new EntityNotFoundException("[UserService.signup] Region not found")); // List userRegionList = userRegionRepository.findAllByUserId(user.getId()); + // 3. UserRegion 엔티티 생성 및 저장 UserRegion userRegion = UserRegion.builder() .user(user) .region(region) @@ -56,12 +66,24 @@ public User signup(SignupRequestDto dto) { return user; } + /** + * 닉네임 중복 여부 리턴 + * + * @param nickname 확인할 닉네임 + * @return 닉네임이 이미 사용 중이면 true, 아니면 false + */ public boolean isDuplicateNickname(String nickname) { User user = userRepository.findByNickname(nickname); return user != null; } + /** + * 전화번호로 사용자 조회 + * + * @param phoneNumber 조회할 전화번호 + * @return 조회된 User 엔티티, 없으면 null 반환 + */ public User getUserByPhoneNumber(String phoneNumber) { return userRepository.findByPhoneNumber(phoneNumber); } From 1efdbbd507e5b461f13957865b8620a901508126 Mon Sep 17 00:00:00 2001 From: LeeDongGuk <39736916+leedongguk@users.noreply.github.com> Date: Sun, 18 Jan 2026 13:51:18 +0900 Subject: [PATCH 4/6] Update README.md --- README.md | 438 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 437 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 91177c5..8498215 100644 --- a/README.md +++ b/README.md @@ -1 +1,437 @@ -# carrot_server +# 🥕당근마켓🥕 + +![readme_mockup2](https://github.com/MRoKGA/image/blob/main/%EC%A0%9C%EB%AA%A9%EC%9D%84%20%EC%9E%85%EB%A0%A5%ED%95%B4%EC%A3%BC%EC%84%B8%EC%9A%94..png?raw=true) + +- 배포 URL : https://d1elknx4d22bup.cloudfront.net (점검 중) +- 배포 서버 : http://3.35.219.116:8080 (점검 중) + +
+ +## 프로젝트 소개 + +- “지역 기반 거래 플랫폼을 실제 서비스 아키텍처로 구현한 당근마켓 클론 프로젝트입니다.” +- “실제 서비스 수준의 로그인·상품·채팅·예약·이미지 업로드 기능을 갖춘 풀스택 프로젝트입니다.” +- "서비스 전체 흐름을 경험하기 위해 진행한 클론코딩입니다.” + +
+ +## 팀원 구성 + +
+ +| **이동국** | **홍재호** | **이의준** | +| :------: | :------: | :------: | +| [
@LeeDongGuk](https://github.com/leedongguk) | [
@ariana9rande](https://github.com/ariana9rande) | [
@euijunlee98](https://github.com/euijunlee98) | + +
+ +
+ +## 1. 개발 환경 + +- Front : HTML, React, styled-components +- Back-end : Spring-Boot +- 버전 및 이슈관리 : Github, Github Issues, Github Project +- 협업 툴 : Discord, Notion, Github Wiki +- 서비스 배포 환경 : AWS +- 사용 기술 및 API: COOL SMS API, KAKAO MAP, KAKAO PAY, SWAGGER, CI/CD, CHATGPT +- 디자인 : Figma +
+ +## 2. 채택한 개발 기술과 브랜치 전략 + +### React, styled-component + +- React + - 컴포넌트화를 통해 추후 유지보수와 재사용성을 고려했습니다. + - 유저 배너, 상단과 하단 배너 등 중복되어 사용되는 부분이 많아 컴포넌트화를 통해 리소스 절약이 가능했습니다. + +- AWS 기반 배포(EC2, S3, RDS) + - EC2를 활용해 React 프론트엔드와 Spring Boot 백엔드를 실시간으로 운영 가능한 형태로 배포했습니다. + - S3를 이용해 상품 이미지 업로드 및 정적 파일 관리를 진행하며 저장 비용과 안정성을 확보했습니다. + - RDS(MySQL)를 도입하여 데이터의 안정적인 저장 및 백업/관리 환경을 구축했습니다. + - CI/CD 파이프라인을 구성해 GitHub Actions 기반 자동 배포 환경을 구축함으로써 코드를 push하는 즉시 자동 빌드·배포가 이루어지는 지속적 배포 환경을 완성했습니다. + + - COOL SMS API (문자 본인인증) + - 회원가입 과정에서 문자 인증 번호 발송 기능을 구현해 사용자 신뢰성과 보안 레벨을 강화했습니다. + - 인증 절차를 REST API와 연동하여, 유저 경험(UX) 손상 없이 빠르게 인증 절차를 완료할 수 있는 구조를 만들었습니다. + + - Kakao Map API + - 사용자 위치 기반으로 상품을 조회하거나 등록할 수 있도록 Kakao Map을 이용해 지도 기반 UI 기능을 구현했습니다. + - 주소 검색, 좌표 변환, 마커 커스터마이징 등 지도 서비스의 핵심 기능을 연동해 동네 기반 플랫폼으로서의 특성을 완성했습니다. + + - Swagger를 활용한 API 문서화 + - Swagger UI를 통해 백엔드 API 전체를 문서화하여, 팀원 간의 API 소통 비용을 크게 줄이고 개발 효율성을 향상시켰습니다. + - API 스펙 변경 시 자동 문서 업데이트가 가능해 유지보수 과정에서도 높은 생산성을 확보했습니다. + + - ChatGPT를 활용한 에러 로그 자동 분석 + - 프론트·백엔드에서 발생하는 오류 로그를 ChatGPT API로 전달하여 자동으로 에러 원인을 분석하고 해결 방법을 제안받는 시스템을 구축했습니다. + - 이를 통해 디버깅 시간을 크게 단축시키고, 개발 생산성과 안정성을 향상했습니다. + +
+ +## 노션관리 + +![readme_mockup2](https://github.com/MRoKGA/image/blob/main/screencapture-notion-so-245269b368be803a9bbbecb07a83a157-2025-11-27-18_08_22.png?raw=true) + +
+ +## 3. 역할 분담 + +### 🍊이동국 + +- **FULL-STACK** + +
+ +### 👻홍재호 + +- **BACK-END** + +
+ +### 😎이의준 + +- **BACK-END** + +
+ +## 4. 개발 기간 및 작업 관리 + +### 개발 기간 + +- 전체 개발 기간 : 2025-09-01 ~ 2025-11-27 +- UI 구현 : 2025-09-01 ~ 2025-09-08 +- 기능 구현 : 2025-09-09 ~ 2025-11-05 +- 테스트 : 2025-11-05 ~ 2025-11-27 + +
+ +### 작업 관리 + +- GitHub 및 NOTION을 통해 진행 상황을 공유했습니다. +- 주간회의를 진행하며 작업 순서와 방향성에 대한 고민을 나누고 NOTION에 회의 내용을 기록했습니다. + +
+ +## 5. 신경 쓴 부분 + +- ChatGPT를 활용한 에러 로그 자동 분석 +![readme_mockup2](https://github.com/MRoKGA/image/blob/main/%EC%97%90%EB%9F%AC%EA%B4%80%EB%A6%AC.png?raw=true) + +- SWAGGER를 활용한 API 문서 +![readme_mockup2](https://github.com/MRoKGA/image/blob/main/%EC%8A%A4%EC%9B%A8%EA%B1%B0.png?raw=true) + +
+ +## 6. 페이지별 기능 + +### [초기화면] +- 기능 설명 + - 서비스 접속 시 가장 먼저 나타나는 화면 + - 시작하기(회원가입) + - 로그인(핸드폰 인증기반 로그인) + +| 초기화면 | +|----------| +|![splash](https://github.com/MRoKGA/image/blob/main/1.png?raw=true)| + +
+ +### [회원가입(동네설정)] +- 입력창에 지역을 입력하면 지역이 검색됩니다. +- 현재위치로 찾기로 클릭 시 GPS기반으로 동네설정이 가능합니다. + +| 회원가입(동네설정) | +|----------| +|![join](https://github.com/MRoKGA/image/blob/main/1-2.png?raw=true)| + +
+ +### [회원가입(핸드폰번호 입력)] +- 핸드폰 번호를 입력하면 핸드폰 인증번호를 받을 수 있습니다. +- 핸드폰 번호를 입력하지 않으면 다음 화면으로 넘어 갈 수 업습니다. + +| 회원가입(핸드폰번호 입력) | +|----------| +|![join](https://github.com/MRoKGA/image/blob/main/1-3.png?raw=true)| + +
+ +### [회원가입(핸드폰번호 인증)] +- 사용자가 입력한 휴대폰 번호로 CoolSMS API를 통해 인증번호(6자리)를 발송하고, + 이 인증번호를 Redis 서버에 임시 저장(5분 TTL)한 뒤 사용자가 입력한 인증번호와 Redis에 저장된 인증번호를 비교하여 본인인증을 완료하는 구조입니다. +- 기술 흐름
+ 1.사용자가 휴대폰 번호 입력 → 인증번호 요청
+ 2.서버는 랜덤 6자리 인증번호 생성
+ 3.CoolSMS API로 해당 번호로 인증번호 발송
+ 4.인증번호를 REDIS에 저장
+ 5. 사용자가 인증번호 입력
+ 6. 서버는 REDIS에 저장된 값과 비교
+ 7. 일치 -> 본인인증 자동 성공
+ 불일치/만료 -> 인증 실패 처리
+ +| 회원가입(핸드폰번호 인증) | +|----------| +|![setProfile](https://github.com/MRoKGA/image/blob/main/1-4.png?raw=true)| + +
+ +### [프로필 설정] +- 카메라 아이콘 클릭 시 이미지 선택 +- 선택된 이미지 파일은 프론트에서 FormData로 백엔드 전송 +- 백엔드는 파일을 AWS S3 버킷에 업로드 +- S3에 저장된 파일의 URL을 DB에 저장하여 프로필 이미지로 사용 +- 이미지를 바꾸면 자동으로 “완료” 버튼이 활성화됨 +- 입력창에 새 닉네임을 입력하면 중복확인 버튼 활성화 +- 중복확인 API 호출 → 사용 가능 여부 반환 +- 사용 가능: 초록색 메시지, 저장 가능 +- 중복됨: 빨간색 메시지, 저장 불가 +- 실제 서비스와 동일하게 “닉네임 고유성”을 확보 + +| 프로필 설정 | +|----------| +|![login](https://github.com/MRoKGA/image/blob/main/1-5.png?raw=true)| + +
+ +### [홈 화면] +- 상단 카테고리 필터 +- 지역(동네) 기반 상품 리스트 +- 상품 썸네일 + 정보 표시 +- 하단 Floating Button (상품 등록 버튼) +- 검색 버튼 + +| 홈 화면 | +|----------| +|![login](https://github.com/MRoKGA/image/blob/main/2.png?raw=true)| + +
+ +### [상품 상세] +- 상품 이미지 슬라이드 +- 판매자 정보 표시 +- 좋아요 기능 +- 조회수 기능 +- 채팅하기 기능 +- 선호 거래 지역(지도 표시) + +| 상품 상세 | +|----------| +|![login](https://github.com/MRoKGA/image/blob/main/2-1(%EC%98%81%EC%83%81).gif?raw=true)| + +
+ +### [상품 검색] +- 상품 검색 + +| 상품 검색 | +|----------| +|![login](https://github.com/MRoKGA/image/blob/main/2-2(%EC%98%81%EC%83%81).gif?raw=true)| + +
+ +### [내 물건 팔기] +- 상품 이미지 업로드 (0/5) +- 실제 이미지는 백엔드로 전송되어 AWS S3 버킷에 저장 +- 카테고리 선택 +- 거래 방식 선택 (판매하기 / 나눔하기) +- 역제안 받기(가격 제안) 옵션 +- 거래 희망 장소 설정 (Kakao Map API 팝업) + + +| 내 물건 팔기 | 위치 찾기 | +|----------|----------| +|![login](https://github.com/MRoKGA/image/blob/main/2-3.png?raw=true)|![login](https://github.com/MRoKGA/image/blob/main/2-4.png?raw=true)| + +
+ +### [동네생활 피드] +- 동네 모임/주제 카테고리 노출 +- 전체 / 카테고리 필터 +- 게시글 리스트 +- 인기 게시글(“맛집 추천합니다!” 등) +- 좋아요 기능 +- 조회수 증가 +- 게시글 본문 + 이미지 표시 +- 지도 기반 위치 표시 +- 댓글 기능 (CRUD) +- 본인 글 수정/삭제 메뉴 + + +| 동네생활 피드 | 동네생활 게시물 | +|----------|----------| +|![login](https://github.com/MRoKGA/image/blob/main/3-1.png?raw=true)|![login](https://github.com/MRoKGA/image/blob/main/3-2.png?raw=true)| + +
+ +### [동네생활 글쓰기] +- 게시글 주제 선택 Ex)동네정보, 이웃과함께, 소식 +- 질문글 여부 체크박스 +- 주제 클릭 시 즉시 선택 & 팝업 닫힘 +- 선택된 주제는 글쓰기 상단에 표시 + + +| 동네생활 글쓰기 | 카테고리 팝업 | +|----------|----------| +|![login](https://github.com/MRoKGA/image/blob/main/3-3.png?raw=true)|![login](https://github.com/MRoKGA/image/blob/main/3-4.png?raw=true)| + +
+ +### [모임 만들기] +- 대표 이미지 업로드 +- 대표 동네 표시 +- 모임 이름 입력 +- 공개범위 설정(PUBLIC, PRIVATE) +- 가입정책 선택(OPEN, APPROVAL, CLOSED) + + +| 모임 만들기 | 모임 썸네일 | +|----------|----------| +|![login](https://github.com/MRoKGA/image/blob/main/3-5.png?raw=true)|![login](https://github.com/MRoKGA/image/blob/main/3-6.png?raw=true)| + +
+ + + +### [동네모임 상세 화면] +- 모임 대표 정보 +- 관리기능 +- 모임 탈퇴 기능 +- 이벤트 리스트 표시 +- 가입정책 선택(OPEN, APPROVAL, CLOSED) +- 모임 만들기 +- 지도에서 위치 선택 기능 + + +| 모임 만들기 | 모임 썸네일 | 모임 썸네일 | +|----------|----------|----------| +|![login](https://github.com/MRoKGA/image/blob/main/3-7.png?raw=true)|![login](https://github.com/MRoKGA/image/blob/main/3-8.png?raw=true)|![login](https://github.com/MRoKGA/image/blob/main/3-9.png?raw=true)| + +
+ +### [거래 채팅] +- 실시간 메시지 전송 +- 판매자 매너온도 표시 +- 상품 정보 고정 영역 +- 약속잡기 기능(핵심) +- 약속잡기 입력 폼 기능 +- 약속 제안 메시지 자동 생성 +- 약속 취소 기능 +- 당근페이(카카오페이 연동) + + +| 채팅 | 약속잡기 | 모임 썸네일 | +|----------|----------|----------| +|![login](https://github.com/MRoKGA/image/blob/main/5-9.png?raw=true)|![login](https://github.com/MRoKGA/image/blob/main/%EC%95%BD%EC%86%8D%EC%9E%A1%EA%B8%B0.png?raw=true)|![login](https://github.com/MRoKGA/image/blob/main/5-8.png?raw=true)| + +| 거래1 | 거래2 | +|----------|----------| +|![login](https://github.com/MRoKGA/image/blob/main/5-6.png?raw=true)|![login](https://github.com/MRoKGA/image/blob/main/5-7.png?raw=true)|![login] + +| 거래3 | 거래4 | +|----------|----------| +|![login](https://github.com/MRoKGA/image/blob/main/%EC%B9%B4%EC%B9%B4%EC%98%A4%ED%8E%98%EC%9D%B4%EA%B2%B0%EC%A0%9C.png?raw=true)|![login](https://github.com/MRoKGA/image/blob/main/%EA%B2%B0%EC%A0%9C%20%EC%B9%B4%EC%B9%B4%EC%98%A4%ED%86%A1.png?raw=true)|![login] + +| 거래5 | +|----------| +|![login](https://github.com/MRoKGA/image/blob/main/5-10.png?raw=true)| + +
+ +### [동네지도] +- 동네지도 추천 서비스 +- KAKAO MAP 사용 + +| 동네지도 | +|----------| +|![login](https://github.com/MRoKGA/image/blob/main/3-11.png?raw=true)| + +
+ +### [프로필 화면] +- 프로필 이미지 / 닉네임 / 계정정보 표시 +- 매너온도 그래프 시각화 + +| 프로필 화면 | +|----------| +|![login](https://github.com/MRoKGA/image/blob/main/5-1.png?raw=true)| + +
+ +### [나의 판매내역] +- 판매중 / 예약중 / 거래완료 탭 구분 +- 상품 수정 / 삭제 / 거래완료 처리 버튼 제공 +- 채팅수·관심수·조회수 표시 + +| 나의 판매내역 | +|----------| +|![login](https://github.com/MRoKGA/image/blob/main/5-2.png?raw=true)| + +
+ +### [나의 구매내역] +- 내가 구매한 상품 리스트 표시 +- 리뷰 작성 버튼 제공 +- 리뷰 작성 시 별점 + 후기 입력 가능 + +| 나의 구매내역 | 나의 구매내역 후기작성 | 나의 구매내역 후기완료 | +|----------|----------|----------| +|![login](https://github.com/MRoKGA/image/blob/main/5-3.png?raw=true)|![login](https://github.com/MRoKGA/image/blob/main/5-3-1.png?raw=true)|![login](https://github.com/MRoKGA/image/blob/main/5-3-2.png?raw=true)| + +
+ +### [나의 관심목록] +- 관심 등록한 상품 목록 표시 +- 상품 상세 페이지로 이동 가능 +- 시간 정보(예: 78일 전) 표시 + +| 나의 관심목록 | +|----------| +|![login](https://github.com/MRoKGA/image/blob/main/5-4.png?raw=true)| + +
+ +### [나의 약속화면] +- 캘린더 기반 일정 확인 +- 약속 상태별 필터(전체 / 제안됨 / 수락됨 / 거절됨 / 취소됨) +- 약속 상세 정보 제공 + +| 나의 약속화면 | +|----------| +|![login](https://github.com/MRoKGA/image/blob/main/5-5.png?raw=true)| + +
+ +## 어려웠던점 + +### 🍊이동국 + +### 문제상황 +- Chrome, Android에서는 위치 권한 요청이 잘 동작함 +- 하지만 iPhone Safari에서는 현재위치로 찾기 버튼을 눌러도 아무 반응 없음 +- 콘솔엔 "GPS 접근이 거부되었습니다.", "주소 정보를 찾을 수 없습니다."만 출력됨 +- Kakao API에서 401 Unauthorized 오류 발생 + +### 원인 + - iOS Safari는 HTTPS 환경이 아닌 경우 navigator.geolocation 접근을 차단합니다. + + +### 해결방법 + - LocalTunnel 사용하기 + + ### 🍊홍재호 +- + ### 🍊이의준 +- + +## 소감 + +### 🍊이동국 +- ㅇㅇㅇ + +### 🍊이의준 +- ㅇㅇㅇ + +### 🍊홍재호 +- ㅇㅇㅇ From 1fe2b327abbf6d8a8121c08810571d4914bb0f04 Mon Sep 17 00:00:00 2001 From: LeeDongGuk <39736916+leedongguk@users.noreply.github.com> Date: Tue, 3 Feb 2026 15:19:12 +0900 Subject: [PATCH 5/6] Update README.md --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 8498215..aeabf87 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ - 배포 URL : https://d1elknx4d22bup.cloudfront.net (점검 중) - 배포 서버 : http://3.35.219.116:8080 (점검 중) +- 시연 영상 : https://youtu.be/Ou8EJn5kFhk
From e4ff87572ab0e4dbb59c427f8251d9bbed3652ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=9D=98=EC=A4=80?= <136044552+euijunlee98@users.noreply.github.com> Date: Tue, 3 Feb 2026 17:52:17 +0900 Subject: [PATCH 6/6] =?UTF-8?q?=EB=A6=AC=EB=93=9C=EB=AF=B8=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1(=EC=9D=98=EC=A4=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added sections for problem situations, causes, and solutions related to deployment issues. Updated personal reflections on the CI/CD environment and its importance. --- README.md | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index aeabf87..f64a262 100644 --- a/README.md +++ b/README.md @@ -421,10 +421,24 @@ ### 해결방법 - LocalTunnel 사용하기 - ### 🍊홍재호 -- - ### 🍊이의준 +### 🍊홍재호 - +### 🍊이의준 + +### 문제상황 + 1. GitHub Actions 워크플로우는 정상적으로 성공, 하지만 배포 완료 후 퍼블릭 IP:8080 접속 불가 + 2. 배포 완료 후 서버에서 애플리케이션이 정상 실행되지 않음 + 3. SMS 등 외부 API 연동 기능 추가 또는 호출 시 런타임 에러 발생 + +### 원인 + 1. AWS 보안 그룹에 8080 포트 인바운드 규칙 누락 + 2. 로컬 환경 기준 설정(application.yml)파일이 그대로 배포되어 서버 환경과 불일치 + 3. 민감 정보(API Key, Secret)가 서버 환경 변수로 설정되지 않음. 로컬 인텔리제이 프로젝트 환경변수 설정에만 입력 해놓음. + +### 해결방법 + 1. AWS 보안 그룹에 8080 포트 오픈 + 2. 배포 서버 환경에 맞춘 application.yml 설정 및 환경(로컬,배포)별 프로파일 적용 + 3. 서버 환경 변수 등록 및 GitHub Actions에서 환경 변수 주입 방식으로 관리 ## 소감 @@ -432,7 +446,8 @@ - ㅇㅇㅇ ### 🍊이의준 -- ㅇㅇㅇ - +- CI/CD 환경을 직접 구축하며 환경변수 관리의 중요성을 매우 크게 체감했다. 초반부터 체계적으로 관리하고 팀원끼리 공유하며 관리해야한다는 말을 머리가 아닌 피부로 느끼게 되었다. 배포 과정에서는 사소한 설정 하나가 서비스 전체에 영향을 줘 나중엔 어디서부터 고쳐야할지 감도 안 오고 막막했다. 앞으로 꼼꼼하게 하나하나 설정하고 점검하며 넘어가자. +- 실시간 채팅 기능 구현 + 소프트 삭제 로직 설계를 통해 일상적으로 사용하던 채팅 서비스의 정교함을 체감했다. 삭제 시점 기준 데이터 조회, 읽음 처리 등 상태 관리 로직을 구현하면서 복잡한 쿼리 설계와 백엔드 로직의 디테일함을 경험했다. 뿌듯했다. + ### 🍊홍재호 - ㅇㅇㅇ