Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
2 changes: 2 additions & 0 deletions .github/workflows/cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,7 @@ jobs:
-e EATSSU_SLACK_TOKEN="${{ secrets.EATSSU_SLACK_TOKEN }}" \
-e EATSSU_PROD_DOMAIN="${{ secrets.EATSSU_PROD_DOMAIN }}" \
-e EATSSU_DEEPL_API_KEY="${{ secrets.EATSSU_DEEPL_API_KEY }}" \
-e EATSSU_FIREBASE_CREDENTIALS="${{ secrets.EATSSU_FIREBASE_CREDENTIALS }}" \
-e GRAFANA_BASE_URL="${{ secrets.EATSSU_GRAFANA_BASE_URL }}" \
-e GRAFANA_LOKI_DATASOURCE_UID="${{ secrets.EATSSU_GRAFANA_LOKI_DATASOURCE_UID }}" \
-e SWAGGER_USER="${{ secrets.SWAGGER_PROD_USER }}" \
Expand Down Expand Up @@ -321,6 +322,7 @@ jobs:
-e EATSSU_SLACK_TOKEN="${{ secrets.EATSSU_SLACK_TOKEN }}" \
-e EATSSU_DEV_DOMAIN="${{ secrets.EATSSU_DEV_DOMAIN }}" \
-e EATSSU_DEEPL_API_KEY="${{ secrets.EATSSU_DEEPL_API_KEY }}" \
-e EATSSU_FIREBASE_CREDENTIALS="${{ secrets.EATSSU_FIREBASE_CREDENTIALS }}" \
-e SWAGGER_USER="${{ secrets.SWAGGER_DEV_USER }}" \
-e SWAGGER_PASSWORD="${{ secrets.SWAGGER_DEV_PASSWORD }}" \
${{ secrets.DOCKER_REPO }}/eatssu-dev
Expand Down
8 changes: 8 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,15 @@ dependencies {

runtimeOnly 'io.micrometer:micrometer-registry-prometheus'

implementation 'net.logstash.logback:logstash-logback-encoder:7.4'

implementation 'org.apache.commons:commons-csv:1.10.0'

implementation('com.google.firebase:firebase-admin:9.11.0') {
exclude group: 'com.google.cloud', module: 'google-cloud-storage'
exclude group: 'com.google.cloud', module: 'google-cloud-firestore'
}
implementation 'com.google.http-client:google-http-client-jackson2:2.2.0'
}

tasks.named('test') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
import io.micrometer.core.instrument.MeterRegistry;

import org.springframework.security.core.Authentication;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package ssu.eatssu.domain.favorite.dto.response;

import io.swagger.v3.oas.annotations.media.Schema;
import ssu.eatssu.domain.menu.entity.Menu;
import ssu.eatssu.domain.restaurant.entity.Restaurant;
import ssu.eatssu.domain.user.entity.Language;

@Schema(title = "찜한 메뉴")
public record FavoriteMenuResponse(
@Schema(description = "메뉴 id", example = "1")
Long menuId,
@Schema(description = "메뉴명(사용자 언어, 번역이 없으면 한국어)", example = "돈까스")
String menuName,
@Schema(description = "식당", example = "HAKSIK")
Restaurant restaurant,
@Schema(description = "단종 여부", example = "false")
boolean isDiscontinued
) {

public static FavoriteMenuResponse of(Menu menu, Language language) {
return new FavoriteMenuResponse(menu.getId(), menu.getNameByLanguage(language), menu.getRestaurant(),
menu.isDiscontinued());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package ssu.eatssu.domain.favorite.dto.response;

import io.swagger.v3.oas.annotations.media.Schema;
import ssu.eatssu.domain.menu.entity.Menu;
import ssu.eatssu.domain.restaurant.entity.Restaurant;
import ssu.eatssu.domain.user.entity.Language;

@Schema(title = "찜 메뉴 검색 결과")
public record MenuSearchResponse(
@Schema(description = "메뉴 id", example = "1")
Long menuId,
@Schema(description = "메뉴명(사용자 언어, 번역이 없으면 한국어)", example = "돈까스")
String menuName,
@Schema(description = "식당", example = "HAKSIK")
Restaurant restaurant,
@Schema(description = "찜 여부", example = "true")
boolean isFavorite
) {

public static MenuSearchResponse of(Menu menu, Language language, boolean isFavorite) {
return new MenuSearchResponse(menu.getId(), menu.getNameByLanguage(language), menu.getRestaurant(),
isFavorite);
}
}
51 changes: 51 additions & 0 deletions src/main/java/ssu/eatssu/domain/favorite/entity/MenuFavorite.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package ssu.eatssu.domain.favorite.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
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.persistence.UniqueConstraint;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.OnDelete;
import org.hibernate.annotations.OnDeleteAction;
import ssu.eatssu.domain.menu.entity.Menu;
import ssu.eatssu.domain.user.entity.BaseTimeEntity;
import ssu.eatssu.domain.user.entity.User;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(uniqueConstraints = @UniqueConstraint(name = "uk_menu_favorite_user_menu", columnNames = {"user_id", "menu_id"}))
public class MenuFavorite extends BaseTimeEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "menu_favorite_id")
private Long id;

@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "user_id", nullable = false)
@OnDelete(action = OnDeleteAction.CASCADE)
private User user;

@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "menu_id", nullable = false)
@OnDelete(action = OnDeleteAction.CASCADE)
private Menu menu;

private MenuFavorite(User user, Menu menu) {
this.user = user;
this.menu = menu;
}

public static MenuFavorite of(User user, Menu menu) {
return new MenuFavorite(user, menu);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package ssu.eatssu.domain.favorite.persistence;

import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.core.types.dsl.StringPath;
import com.querydsl.jpa.impl.JPAQueryFactory;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import ssu.eatssu.domain.menu.entity.Menu;
import ssu.eatssu.domain.menu.entity.QMenu;
import ssu.eatssu.domain.user.entity.Language;

import java.util.List;

@Component
@RequiredArgsConstructor
public class MenuFavoriteQueryRepository {

private final JPAQueryFactory queryFactory;
private final QMenu menu = QMenu.menu;

public List<Menu> searchMenus(String keyword, Language language) {
return queryFactory.selectFrom(menu)
.where(menu.isDiscontinued.isFalse(), nameContains(keyword, language))
.fetch();
}

private BooleanExpression nameContains(String keyword, Language language) {
BooleanExpression koreanNameContains = menu.name.containsIgnoreCase(keyword);
StringPath localizedName = localizedNamePath(language);
if (localizedName == null) {
return koreanNameContains;
}
return koreanNameContains.or(localizedName.containsIgnoreCase(keyword));
}

private StringPath localizedNamePath(Language language) {
return switch (language) {
case EN -> menu.nameEn;
case JA -> menu.nameJa;
case VI -> menu.nameVi;
case KO -> null;
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package ssu.eatssu.domain.favorite.persistence;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import ssu.eatssu.domain.favorite.entity.MenuFavorite;

import java.util.Collection;
import java.util.List;
import java.util.Set;

public interface MenuFavoriteRepository extends JpaRepository<MenuFavorite, Long> {

boolean existsByUserIdAndMenuId(Long userId, Long menuId);

@Query("SELECT f FROM MenuFavorite f JOIN FETCH f.menu WHERE f.user.id = :userId "
+ "ORDER BY f.createdDate DESC, f.id DESC")
List<MenuFavorite> findAllWithMenuByUserId(@Param("userId") Long userId);

@Query("SELECT f.menu.id FROM MenuFavorite f WHERE f.user.id = :userId AND f.menu.id IN :menuIds")
Set<Long> findFavoriteMenuIds(@Param("userId") Long userId, @Param("menuIds") Collection<Long> menuIds);

@Modifying
@Query("DELETE FROM MenuFavorite f WHERE f.user.id = :userId AND f.menu.id = :menuId")
int deleteByUserIdAndMenuId(@Param("userId") Long userId, @Param("menuId") Long menuId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package ssu.eatssu.domain.favorite.presentation;

import lombok.RequiredArgsConstructor;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import ssu.eatssu.domain.auth.security.CustomUserDetails;
import ssu.eatssu.domain.favorite.dto.response.FavoriteMenuResponse;
import ssu.eatssu.domain.favorite.dto.response.MenuSearchResponse;
import ssu.eatssu.domain.favorite.presentation.docs.MenuFavoriteControllerDocs;
import ssu.eatssu.domain.favorite.service.MenuFavoriteService;
import ssu.eatssu.global.handler.response.BaseResponse;

import java.util.List;

@RestController
@RequestMapping("/menu-favorites")
@RequiredArgsConstructor
public class MenuFavoriteController implements MenuFavoriteControllerDocs {

private final MenuFavoriteService menuFavoriteService;

@Override
@GetMapping("/search")
public BaseResponse<List<MenuSearchResponse>> searchMenus(@RequestParam String keyword,
@AuthenticationPrincipal CustomUserDetails userDetails) {
return BaseResponse.success(menuFavoriteService.searchMenus(userDetails, keyword));
}

@Override
@GetMapping
public BaseResponse<List<FavoriteMenuResponse>> getFavoriteMenus(
@AuthenticationPrincipal CustomUserDetails userDetails) {
return BaseResponse.success(menuFavoriteService.getFavoriteMenus(userDetails));
}

@Override
@PostMapping("/{menuId}")
public BaseResponse<?> addFavorite(@PathVariable Long menuId,
@AuthenticationPrincipal CustomUserDetails userDetails) {
menuFavoriteService.addFavorite(userDetails, menuId);
return BaseResponse.success();
}

@Override
@DeleteMapping("/{menuId}")
public BaseResponse<?> removeFavorite(@PathVariable Long menuId,
@AuthenticationPrincipal CustomUserDetails userDetails) {
menuFavoriteService.removeFavorite(userDetails, menuId);
return BaseResponse.success();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package ssu.eatssu.domain.favorite.presentation.docs;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
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 ssu.eatssu.domain.auth.security.CustomUserDetails;
import ssu.eatssu.domain.favorite.dto.response.FavoriteMenuResponse;
import ssu.eatssu.domain.favorite.dto.response.MenuSearchResponse;
import ssu.eatssu.global.handler.response.BaseResponse;

import java.util.List;

@Tag(name = "MenuFavorite", description = "메뉴 찜 API")
public interface MenuFavoriteControllerDocs {

@Operation(summary = "찜할 메뉴 검색", description = """
메뉴명으로 찜할 메뉴를 검색하는 API 입니다.<br><br>
검색어는 앞뒤 공백을 제거한 뒤 두 글자 이상이어야 하며, 대소문자를 구분하지 않습니다.<br>
단종된 메뉴는 제외되고, 완전히 일치 → 검색어로 시작 → 검색어 포함 순으로 정렬됩니다.<br>
같은 메뉴명이라도 식당이 다르면 따로 노출됩니다.<br>
사용자 언어의 번역이 있으면 번역된 이름으로도 검색되고, 없으면 한국어 이름을 반환합니다.
""")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "메뉴 검색 성공"),
@ApiResponse(responseCode = "400", description = "두 글자 미만 검색어", content = @Content(schema =
@Schema(implementation = BaseResponse.class))),
@ApiResponse(responseCode = "404", description = "존재하지 않는 유저", content = @Content(schema =
@Schema(implementation = BaseResponse.class))),
})
BaseResponse<List<MenuSearchResponse>> searchMenus(@Parameter(description = "검색어", example = "돈까스")
String keyword,
CustomUserDetails userDetails);

@Operation(summary = "찜한 메뉴 목록 조회", description = "내가 찜한 메뉴를 최근에 찜한 순으로 조회하는 API 입니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "찜한 메뉴 목록 조회 성공"),
@ApiResponse(responseCode = "404", description = "존재하지 않는 유저", content = @Content(schema =
@Schema(implementation = BaseResponse.class))),
})
BaseResponse<List<FavoriteMenuResponse>> getFavoriteMenus(CustomUserDetails userDetails);

@Operation(summary = "메뉴 찜하기", description = "메뉴를 찜 목록에 추가하는 API 입니다. 이미 찜한 메뉴면 아무 변화 없이 성공합니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "메뉴 찜 성공"),
@ApiResponse(responseCode = "400", description = "단종된 메뉴", content = @Content(schema =
@Schema(implementation = BaseResponse.class))),
@ApiResponse(responseCode = "404", description = "존재하지 않는 유저 또는 메뉴", content = @Content(schema =
@Schema(implementation = BaseResponse.class))),
})
BaseResponse<?> addFavorite(Long menuId, CustomUserDetails userDetails);

@Operation(summary = "메뉴 찜 취소", description = "메뉴를 찜 목록에서 삭제하는 API 입니다. 찜하지 않은 메뉴여도 성공합니다.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "메뉴 찜 취소 성공"),
})
BaseResponse<?> removeFavorite(Long menuId, CustomUserDetails userDetails);
}
Loading
Loading