Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -248,6 +248,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 @@ -314,6 +315,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
6 changes: 6 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,12 @@ dependencies {
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
@@ -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