Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
e38f8a4
feat: 주문 가능 상점 전환(주문 서비스 가입) POST API 틀제작 (#2036)
asa9874 Oct 13, 2025
c8e0d7e
feat: 주문 가능 상점 전환(주문 서비스 가입) POST API 검증로직 추가 및 테스트 추가 (#2048)
asa9874 Oct 27, 2025
1c2c021
feat : flyway 추가 및 컬럼 명 추가
asa9874 Oct 27, 2025
b93a413
refact : isOpen 삭제
asa9874 Oct 27, 2025
4c79e3c
fix : isOpen 테스트 부분 삭제
asa9874 Oct 27, 2025
a5e9b71
refact : idx제거
asa9874 Oct 27, 2025
813e8d7
refact : 형식 준수
asa9874 Oct 27, 2025
e8286ba
feat : fk_shop_to_orderable_shop 외래키 추가
asa9874 Oct 27, 2025
e2613fc
fix : approvedAt NULL 불일치 제거
asa9874 Oct 27, 2025
c713291
feat: 상점 @ManyToOne으로 변경
asa9874 Oct 27, 2025
a45169e
feat: ShopToOrderableDeliveryOption 추가
asa9874 Oct 27, 2025
b67f462
refact: @NotNULL 추가
asa9874 Oct 28, 2025
dee35e5
refact: 검증로직 메서드화
asa9874 Oct 28, 2025
ad017d3
refact: 개행제거
asa9874 Oct 28, 2025
697929e
fix : flyway fk 테이블명 불일치 해결
asa9874 Oct 28, 2025
769d069
feat: Swagger 패키지 추가
asa9874 Oct 28, 2025
14d3471
refact: takeout -> isTakeout 컬럼명 수정
asa9874 Oct 28, 2025
67d540c
refact: outside_delivery_tip -> off_campus_delivery_tip 컬럼명 수정
asa9874 Oct 28, 2025
683f121
feat : 동시성 방지 @DuplicateGuard 추가
asa9874 Oct 28, 2025
7755783
refact : 가게 사장 검증로직 shop 내부 메서드로 이동
asa9874 Oct 28, 2025
de843fd
refact: 예외 Static import처리
asa9874 Oct 30, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -335,4 +335,8 @@ public void cancelDelete() {
public String getFullAddress() {
return String.join(" ", this.address, this.addressDetail);
}

public boolean isOwner(Integer ownerId) {
return this.owner != null && this.owner.getId().equals(ownerId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package in.koreatech.koin.domain.shoptoOrderable.controller;

import static in.koreatech.koin.domain.user.model.UserType.OWNER;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;

import in.koreatech.koin.domain.shoptoOrderable.dto.ShopToOrderableRequest;
import in.koreatech.koin.global.auth.Auth;
import in.koreatech.koin.global.code.ApiResponseCode;
import in.koreatech.koin.global.code.ApiResponseCodes;
import in.koreatech.koin.global.duplicate.DuplicateGuard;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;

@Tag(name = "(Normal) Shop To Orderable Request: 주문 서비스 가입 요청", description = "사장님이 주문 서비스 가입을 요청하기위한 API")
public interface ShopToOrderableApi {

@ApiResponseCodes({
ApiResponseCode.OK,
ApiResponseCode.ILLEGAL_ARGUMENT,
ApiResponseCode.FORBIDDEN_USER_TYPE,
ApiResponseCode.NOT_FOUND_USER,
ApiResponseCode.NOT_FOUND_SHOP,
})
@Operation(summary = "사장님 주문 서비스 가입 요청")
@SecurityRequirement(name = "Jwt Authentication")
@PostMapping("/owner/shops/{shopId}/orderable-requests")
@DuplicateGuard(key = "#ownerId + ':' + #shopId + ':' + #request.toString()", timeoutSeconds = 300)
ResponseEntity<Void> createOrderableRequest(
@Auth(permit = {OWNER}) Integer ownerId,
@PathVariable Integer shopId,
@RequestBody @Valid ShopToOrderableRequest request
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package in.koreatech.koin.domain.shoptoOrderable.controller;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;

import in.koreatech.koin.domain.shoptoOrderable.dto.ShopToOrderableRequest;
import in.koreatech.koin.domain.shoptoOrderable.service.ShopToOrderableService;
import in.koreatech.koin.global.duplicate.DuplicateGuard;
import lombok.RequiredArgsConstructor;

import static in.koreatech.koin.domain.user.model.UserType.OWNER;

import org.springframework.web.bind.annotation.RequestBody;

import in.koreatech.koin.global.auth.Auth;
import jakarta.validation.Valid;

//Todo: ShopToOrderable 이라는 명칭은 임시임 추후 변경
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

C

ShopToOrderable으로 확정하신건가용
개인적으로는 ownershop 패키지 하위로 들어가도 괜찮을 거 같은데 어떻게 생각하시나요 ??

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

해당 스프린트가 아직 명확하게 요구사항이 정해지지 않아서, 우선 독립적으로 패키지를 만들어 제작을 시작했습니다.
추후 요구사항이 명확해지게 되면, 프로젝트 구조에 맞게 패키지 구조를 조정할려고 하는데 괜찮을까요?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

개인적인 생각으로는 요구사항이랑 별개로 주변상점 -> 주문가능상점은 무조건 필요해서 input/output이 변하는 거 말고는 크게 변경될 사항이 없을 거 같아요. (사장님은 주변상점 -> 주문가능상점 신청, 이력 조회, 취소 / 어드민은 승인, 반려) 사장님이 상점에 대해 조작을 가하는 거기 때문에 ownershop 패키지 하위에 들어가도 괜찮지 않을까 생각을 했습니다.

개인적으로 ShopToOrderable 네이밍을 조금만 더 다듬어보면 좋을 거 같아요. 주변 상점 -> 주문 서비스(주문 가능 상점)를 신청한 이력을 관리하고 있으니 ShopOrderServiceRequest 도 괜찮아 보입니다.

종범님의 생각이 궁금합니다 👀

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ownershop 패키지의 하위로 들어가는게 좋을거같습니다.
네이밍은 ShopApprovalRequest | ShopEnableRequest 로 생각하고 있었는데, 의미적으로 뭔가 명확하게 다가오지 않아서 ShopOrderServiceRequest 가 좋을거같습니다.

ownershop 패키지의 구성이 그러면 다음처럼 변경하면 될까요?

Ownershop
├── controller
├── dto
├── exception
└── service

=>

Ownershop
├── Ownershop
│   └── controller
│   └── service
│     ..........
└── ShopOrderServiceRequest
    └── controller
    └── service
      ..........

@RestController
@RequiredArgsConstructor
public class ShopToOrderableController implements ShopToOrderableApi {

private final ShopToOrderableService shopToOrderableService;

@PostMapping("/owner/shops/{shopId}/orderable-requests")
@DuplicateGuard(key = "#ownerId + ':' + #shopId + ':' + #request.toString()", timeoutSeconds = 300)
public ResponseEntity<Void> createOrderableRequest(
@Auth(permit = {OWNER}) Integer ownerId,
@PathVariable Integer shopId,
@RequestBody @Valid ShopToOrderableRequest request
) {
shopToOrderableService.createOrderableRequest(ownerId, request, shopId);
return ResponseEntity.status(HttpStatus.CREATED).build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package in.koreatech.koin.domain.shoptoOrderable.dto;

import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED;

import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy;
import com.fasterxml.jackson.databind.annotation.JsonNaming;

import in.koreatech.koin.domain.shoptoOrderable.model.ShopToOrderableDeliveryOption;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;

@JsonNaming(SnakeCaseStrategy.class)
public record ShopToOrderableRequest(

@Schema(description = "최소 주문 금액", example = "5000", requiredMode = REQUIRED)
@NotNull(message = "최소 주문 금액은 필수입니다.")
@Min(value = 0, message = "최소 주문 금액은 0원 이상이어야 합니다.")
Integer minimumOrderAmount,

@Schema(description = "포장 가능 여부", example = "true", requiredMode = REQUIRED)
@NotNull(message = "포장 가능 여부는 필수입니다.")
Boolean isTakeout,

@Schema(description = "배달 옵션 (CAMPUS/OUTSIDE/BOTH)", example = "BOTH", requiredMode = REQUIRED)
@NotNull(message = "배달 옵션은 필수입니다.")
ShopToOrderableDeliveryOption deliveryOption,

@Schema(description = "교내 배달팁", example = "1000", requiredMode = REQUIRED)
@NotNull(message = "교내 배달팁은 필수입니다.")
@Min(value = 0, message = "교내 배달팁은 0원 이상이어야 합니다.")
Integer campusDeliveryTip,

@Schema(description = "교외 배달팁", example = "2000", requiredMode = REQUIRED)
@NotNull(message = "교외 배달팁은 필수입니다.")
@Min(value = 0, message = "교외 배달팁은 0원 이상이어야 합니다.")
Integer offCampusDeliveryTip,

@Schema(description = "사업자 등록증 URL", example = "https://example.com/business_license.jpg", requiredMode = REQUIRED)
@NotBlank(message = "사업자 등록증 URL은 필수입니다.")
String businessLicenseUrl,

@Schema(description = "영업 신고증 URL", example = "https://example.com/business_certificate.jpg", requiredMode = REQUIRED)
@NotBlank(message = "영업 신고증 URL은 필수입니다.")
String businessCertificateUrl,

@Schema(description = "통장 사본 URL", example = "https://example.com/bank_copy.jpg", requiredMode = REQUIRED)
@NotBlank(message = "통장 사본 URL은 필수입니다.")
String bankCopyUrl,

@Schema(description = "은행명", example = "국민은행", requiredMode = REQUIRED)
@NotBlank(message = "은행명은 필수입니다.")
@Size(max = 10, message = "은행명은 10자 이하여야 합니다.")
String bank,

@Schema(description = "계좌번호", example = "123-456-789", requiredMode = REQUIRED)
@NotBlank(message = "계좌번호는 필수입니다.")
@Size(max = 20, message = "계좌번호는 20자 이하여야 합니다.")
String accountNumber
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package in.koreatech.koin.domain.shoptoOrderable.model;

import static lombok.AccessLevel.PROTECTED;

import in.koreatech.koin.common.model.BaseEntity;
import in.koreatech.koin.domain.shop.model.shop.Shop;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;

import java.time.LocalDateTime;

import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Getter
@Table(name = "shop_to_orderable")
@NoArgsConstructor(access = PROTECTED)
public class ShopToOrderable extends BaseEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;

@NotNull
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "shop_id", nullable = false)
private Shop shop;

@NotNull
@Column(name = "minimum_order_amount", nullable = false)
private Integer minimumOrderAmount;

@Column(name = "is_takeout", nullable = false)
private Boolean isTakeout = false;

@NotNull
@Enumerated(EnumType.STRING)
@Column(name = "delivery_option", nullable = false)
private ShopToOrderableDeliveryOption deliveryOption;

@Column(name = "campus_delivery_tip", nullable = false)
private Integer campusDeliveryTip = 0;

@Column(name = "off_campus_delivery_tip", nullable = false)
private Integer offCampusDeliveryTip = 0;

@NotNull
@Column(name = "business_license_url", nullable = false)
private String businessLicenseUrl;

@NotNull
@Column(name = "business_certificate_url", nullable = false)
private String businessCertificateUrl;

@NotNull
@Column(name = "bank_copy_url", nullable = false)
private String bankCopyUrl;

@NotNull
@Size(max = 10)
@Column(name = "bank", length = 10, nullable = false)
private String bank;
Comment on lines +73 to +75
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A

bank를 String 말고 enum으로 관리할 수 있을까요 👀
db에서 관리하기 ?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

해당 부분은 요구사항측에서 은행의 종류같은게 정해지면 enum으로 관리 하는게 좋을거같아요.

db에서 관리는 enum 말고 테이블로 (1, "OO은행") , (2,"AA은행")으로해서 연관으로 관리한다는 의미인가요?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

db에서 관리는 enum 말고 테이블로 (1, "OO은행") , (2,"AA은행")으로해서 연관으로 관리한다는 의미인가요?

맞습니당
요건 말씀해주신 것 처럼 추후에 내용이 나오면 반영해도 괜찮을 거 같아요


@NotNull
@Size(max = 20)
@Column(name = "account_number", length = 20, nullable = false)
private String accountNumber;

@Enumerated(EnumType.STRING)
@Column(name = "request_status", nullable = false)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A

status으로 표현해도 괜찮을 거 같아요

private ShopToOrderableRequestStatus requestStatus = ShopToOrderableRequestStatus.PENDING;

@Column(name = "approved_at", columnDefinition = "TIMESTAMP")
private LocalDateTime approvedAt = null;

@Builder
public ShopToOrderable(
Shop shop,
Integer minimumOrderAmount,
Boolean isTakeout,
ShopToOrderableDeliveryOption deliveryOption,
Integer campusDeliveryTip,
Integer offCampusDeliveryTip,
String businessLicenseUrl,
String businessCertificateUrl,
String bankCopyUrl,
String bank,
String accountNumber
) {
this.shop = shop;
this.minimumOrderAmount = minimumOrderAmount;
this.isTakeout = isTakeout;
this.deliveryOption = deliveryOption;
this.campusDeliveryTip = campusDeliveryTip;
this.offCampusDeliveryTip = offCampusDeliveryTip;
this.businessLicenseUrl = businessLicenseUrl;
this.businessCertificateUrl = businessCertificateUrl;
this.bankCopyUrl = bankCopyUrl;
this.bank = bank;
this.accountNumber = accountNumber;
this.approvedAt = null;
}

public void approveRequest() {
this.requestStatus = ShopToOrderableRequestStatus.APPROVED;
this.approvedAt = LocalDateTime.now();
}

public void rejectRequest() {
this.requestStatus = ShopToOrderableRequestStatus.REJECTED;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package in.koreatech.koin.domain.shoptoOrderable.model;

public enum ShopToOrderableDeliveryOption {
CAMPUS,
OUTSIDE,
BOTH
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package in.koreatech.koin.domain.shoptoOrderable.model;

public enum ShopToOrderableRequestStatus {
PENDING, APPROVED, REJECTED
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package in.koreatech.koin.domain.shoptoOrderable.repository;

import in.koreatech.koin.domain.shoptoOrderable.model.ShopToOrderable;
import in.koreatech.koin.domain.shoptoOrderable.model.ShopToOrderableRequestStatus;

import org.springframework.data.repository.Repository;

public interface ShopToOrderableRepository extends Repository<ShopToOrderable, Integer> {

ShopToOrderable save(ShopToOrderable shopToOrderable);

boolean existsByShopId(Integer shopId);

boolean existsByShopIdAndRequestStatus(Integer shopId, ShopToOrderableRequestStatus requestStatus);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package in.koreatech.koin.domain.shoptoOrderable.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import in.koreatech.koin.domain.shop.model.shop.Shop;
import in.koreatech.koin.domain.shop.repository.shop.ShopRepository;
import in.koreatech.koin.domain.shoptoOrderable.dto.ShopToOrderableRequest;
import in.koreatech.koin.domain.shoptoOrderable.model.ShopToOrderable;
import in.koreatech.koin.domain.shoptoOrderable.repository.ShopToOrderableRepository;
import in.koreatech.koin.global.exception.CustomException;
import lombok.RequiredArgsConstructor;

import static in.koreatech.koin.domain.shoptoOrderable.model.ShopToOrderableRequestStatus.*;
import static in.koreatech.koin.global.code.ApiResponseCode.*;

@Service
@RequiredArgsConstructor
public class ShopToOrderableService {

private final ShopToOrderableRepository shopToOrderableRepository;
private final ShopRepository shopRepository;

@Transactional
public void createOrderableRequest(Integer ownerId, ShopToOrderableRequest request, Integer shopId) {
Shop shop = shopRepository.getById(shopId);

validateShopOwner(shop, ownerId);
validateDuplicateRequest(shop);

ShopToOrderable shopToOrderable = ShopToOrderable.builder()
.shop(shop)
.minimumOrderAmount(request.minimumOrderAmount())
.isTakeout(request.isTakeout())
.deliveryOption(request.deliveryOption())
.campusDeliveryTip(request.campusDeliveryTip())
.offCampusDeliveryTip(request.offCampusDeliveryTip())
.businessLicenseUrl(request.businessLicenseUrl())
.businessCertificateUrl(request.businessCertificateUrl())
.bankCopyUrl(request.bankCopyUrl())
.bank(request.bank())
.accountNumber(request.accountNumber())
.build();

shopToOrderableRepository.save(shopToOrderable);
}

private void validateShopOwner(Shop shop, Integer ownerId) {
// 가게 사장님인지 확인
if (!shop.isOwner(ownerId)) {
throw CustomException.of(FORBIDDEN_SHOP_OWNER, "ownerId: " + ownerId + ", shopId: " + shop.getId());
}
}

private void validateDuplicateRequest(Shop shop) {
// 이미 신청한 내역이 있는지 확인
if (shopToOrderableRepository.existsByShopIdAndRequestStatus(shop.getId(), PENDING)) {
throw CustomException.of(DUPLICATE_REQUESTED_ORDERABLE_SHOP, "shopId: " + shop.getId());
}

// 이미 주문가능 상점인지 확인
if (shopToOrderableRepository.existsByShopIdAndRequestStatus(shop.getId(), APPROVED)) {
throw CustomException.of(DUPLICATE_ORDERABLE_SHOP, "shopId: " + shop.getId());
}
}
}
Loading
Loading