Skip to content
Original file line number Diff line number Diff line change
@@ -1,21 +1,25 @@
package com.example.Tokkit_server;

import java.util.TimeZone;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;

@SpringBootApplication(scanBasePackages = "com.example")
@EnableAsync
@EnableJpaAuditing
@EnableScheduling
@EnableJpaRepositories(basePackages = "com.example")
@EntityScan(basePackages = "com.example")
public class TokkitServerApplication {

public static void main(String[] args) {
TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul"));
SpringApplication.run(TokkitServerApplication.class, args);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@
import com.example.Tokkit_server.user.utils.SseEmitters;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

Expand Down Expand Up @@ -76,7 +78,6 @@ public void sendNotification(User user, NotificationTemplate template, Object...
notificationRepository.save(notification);
}

@Transactional
public SseEmitter subscribe(Long userId) {
SseEmitter emitter = new SseEmitter(DEFAULT_TIMEOUT);
sseEmitters.add(userId, emitter);
Expand All @@ -91,9 +92,10 @@ public SseEmitter subscribe(Long userId) {
sseEmitters.remove(userId);
}

userRepository.findById(userId).ifPresent(this::sendUnsentNotifications);
// 트랜잭션 점유 방지를 위해 비동기로 분리된 메서드 호출
userRepository.findById(userId).ifPresent(this::sendUnsentNotificationsAsync);

// 연결 유지용 ping
// Ping 유지
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
scheduler.scheduleAtFixedRate(() -> {
try {
Expand Down Expand Up @@ -123,6 +125,12 @@ public SseEmitter subscribe(Long userId) {
return emitter;
}

@Async
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void sendUnsentNotificationsAsync(User user) {
sendUnsentNotifications(user);
}

@Transactional
public void deleteNotification(Long notificationId, User user) {
Notification notification = notificationRepository.findByIdAndUser(notificationId, user)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,23 @@

import com.example.Tokkit_server.global.apiPayload.ApiResponse;
import com.example.Tokkit_server.store.dto.response.KakaoMapSearchResponse;
import com.example.Tokkit_server.store.dto.response.StoreBasicInfoResponseDto;
import com.example.Tokkit_server.store.dto.response.StoreInfoResponse;
import com.example.Tokkit_server.store.dto.response.StoreSimpleResponse;
import com.example.Tokkit_server.store.dto.response.VoucherPageResponseDto;
import com.example.Tokkit_server.store.service.StoreService;
import com.example.Tokkit_server.store.service.command.StoreCommandService;
import com.example.Tokkit_server.store.service.query.StoreQueryService;
import com.example.Tokkit_server.user.auth.CustomUserDetails;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;

import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
Expand All @@ -27,6 +34,7 @@ public class StoreController {

private final StoreCommandService storeCommandService;
private final StoreService storeService;
private final StoreQueryService storeQueryService;

@GetMapping("/nearby")
@Operation(
Expand Down Expand Up @@ -69,4 +77,17 @@ public ApiResponse<StoreSimpleResponse> getStoreSimpleInfo(@RequestParam Long st
StoreSimpleResponse response = storeService.getSimpleStoreInfo(storeId);
return ApiResponse.onSuccess(response);
}
@GetMapping("/{storeId}")
public ApiResponse<StoreBasicInfoResponseDto> getStoreInfo(@PathVariable Long storeId) {
return ApiResponse.onSuccess(storeQueryService.getStoreInfo(storeId));
}

@GetMapping("/{storeId}/vouchers")
public ApiResponse<VoucherPageResponseDto> getAvailabㄴleVouchers(
@PathVariable Long storeId,
@AuthenticationPrincipal CustomUserDetails userDetails,
@PageableDefault(size = 5) Pageable pageable
) {
return ApiResponse.onSuccess(storeQueryService.getAvailableVouchers(storeId, userDetails.getId(), pageable));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.example.Tokkit_server.store.dto.response;

import com.example.Tokkit_server.store.entity.Store;

public record StoreBasicInfoResponseDto(
Long storeId,
String storeName,
String category,
String address,
String postalCode
) {
public static StoreBasicInfoResponseDto from(Store store) {
return new StoreBasicInfoResponseDto(
store.getId(),
store.getStoreName(),
store.getStoreCategory().name(),
store.getRoadAddress(),
store.getNewZipcode()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.example.Tokkit_server.store.dto.response;

import com.example.Tokkit_server.voucher.entity.Voucher;
import com.example.Tokkit_server.voucher_ownership.entity.VoucherOwnership;

import java.time.LocalDate;
import java.util.List;

import org.springframework.data.domain.Page;

public record VoucherPageResponseDto(
List<VoucherInfoDto> content,
int currentPage,
int pageSize,
int totalPages,
long totalElements,
boolean hasNext
) {
public static VoucherPageResponseDto from(Page<VoucherOwnership> page) {
List<VoucherInfoDto> content = page.getContent().stream()
.map(VoucherInfoDto::from)
.toList();

return new VoucherPageResponseDto(
content,
page.getNumber(),
page.getSize(),
page.getTotalPages(),
page.getTotalElements(),
page.hasNext()
);
}

public record VoucherInfoDto(
Long voucherId,
String name,
LocalDate validUntil,
Long balance
) {
public static VoucherInfoDto from(VoucherOwnership ownership) {
Voucher v = ownership.getVoucher();
return new VoucherInfoDto(
v.getId(),
v.getName(),
v.getValidDate().toLocalDate(),
ownership.getRemainingAmount()
);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public interface StoreRepository extends JpaRepository<Store, Long> {
"FROM VoucherStore vs JOIN vs.store s " +
"WHERE vs.voucher.id = :voucherId")
Page<StoreResponse> findByVoucherId(@Param("voucherId") Long voucherId, Pageable pageable);

/*

@Query(value = """
SELECT
Expand Down Expand Up @@ -48,7 +48,37 @@ List<Object[]> findNearbyStoresRaw(
@Param("radius") double radius,
@Param("category") String category,
@Param("keyword") String keyword
);
);*/
@Query(value = """
SELECT
s.id AS id,
s.store_name AS storeName,
s.road_address AS roadAddress,
s.new_zipcode AS newZipcode,
s.latitude AS latitude,
s.longitude AS longitude,
s.store_category AS storeCategory,
ST_Distance_Sphere(POINT(s.longitude, s.latitude), POINT(:lng, :lat)) AS distance
FROM wallet w
JOIN voucher_ownership vo ON w.id = vo.wallet_id
JOIN voucher_store vs ON vo.voucher_id = vs.voucher_id
JOIN store s ON vs.store_id = s.id
WHERE w.user_id = :userId
AND s.latitude BETWEEN :lat - (:radius / 111320) AND :lat + (:radius / 111320)
AND s.longitude BETWEEN :lng - (:radius / (111320 * COS(RADIANS(:lat)))) AND :lng + (:radius / (111320 * COS(RADIANS(:lat))))
AND (:category IS NULL OR s.store_category = :category)
AND (:keyword IS NULL OR s.store_name LIKE CONCAT('%', :keyword, '%') OR s.road_address LIKE CONCAT('%', :keyword, '%'))
HAVING distance <= :radius
ORDER BY distance
""", nativeQuery = true)
List<Object[]> findNearbyStoresRaw(
@Param("userId") Long userId,
@Param("lat") double lat,
@Param("lng") double lng,
@Param("radius") double radius,
@Param("category") String category,
@Param("keyword") String keyword
);


Optional<Store> findByIdAndMerchantId(Long storeId, Long merchantId);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.example.Tokkit_server.store.service.query;
import com.example.Tokkit_server.store.dto.response.StoreBasicInfoResponseDto;
import com.example.Tokkit_server.store.dto.response.VoucherPageResponseDto;

import org.springframework.data.domain.Pageable;
public interface StoreQueryService {
StoreBasicInfoResponseDto getStoreInfo(Long storeId);
VoucherPageResponseDto getAvailableVouchers(Long storeId, Long userId, Pageable pageable);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.example.Tokkit_server.store.service.query;

import com.example.Tokkit_server.global.apiPayload.code.status.ErrorStatus;
import com.example.Tokkit_server.global.apiPayload.exception.GeneralException;
import com.example.Tokkit_server.store.dto.response.StoreBasicInfoResponseDto;
import com.example.Tokkit_server.store.dto.response.VoucherPageResponseDto;
import com.example.Tokkit_server.store.entity.Store;
import com.example.Tokkit_server.store.repository.StoreRepository;
import com.example.Tokkit_server.user.repository.UserRepository;
import com.example.Tokkit_server.voucher_ownership.entity.VoucherOwnership;
import com.example.Tokkit_server.voucher_ownership.repository.VoucherOwnershipRepository;

import lombok.RequiredArgsConstructor;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class StoreQueryServiceImpl implements StoreQueryService {

private final StoreRepository storeRepository;
private final VoucherOwnershipRepository voucherOwnershipRepository;
private final UserRepository userRepository;

@Override
public StoreBasicInfoResponseDto getStoreInfo(Long storeId) {
Store store = storeRepository.findById(storeId)
.orElseThrow(() -> new GeneralException(ErrorStatus.STORE_NOT_FOUND));
return StoreBasicInfoResponseDto.from(store);
}

@Override
public VoucherPageResponseDto getAvailableVouchers(Long storeId, Long userId, Pageable pageable) {

getStoreInfo(storeId);

userRepository.findById(userId).orElseThrow(() -> new GeneralException(ErrorStatus.USER_NOT_FOUND));

Page<VoucherOwnership> page = voucherOwnershipRepository.findAvailableVouchersByUserAndStore(
userId, storeId, pageable
);

return VoucherPageResponseDto.from(page);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,21 @@ List<VoucherOwnership> findByStatusAndVoucherValidDateBeforeWithFetchJoin(
@Param("now") LocalDateTime now
);

@Query("""
SELECT vo FROM VoucherOwnership vo
JOIN vo.voucher v
JOIN v.voucherStores vs
WHERE vo.wallet.user.id = :userId
AND vs.store.id = :storeId
AND vo.status = 'AVAILABLE'
AND vo.remainingAmount > 0
AND v.validDate >= CURRENT_TIMESTAMP
ORDER BY vo.remainingAmount DESC,v.validDate ASC
""")
Page<VoucherOwnership> findAvailableVouchersByUserAndStore(
@Param("userId") Long userId,

@Param("storeId") Long storeId,
Pageable pageable
);
}
Loading