Conversation
…d use it in UserRestaurantVoteService
There was a problem hiding this comment.
Hello @hangyeoli, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!
Summary of Changes
This pull request focuses on enhancing the vote page functionality. It introduces features such as restaurant voting (like/dislike), restaurant scraping, sorting options (by distance, likes, dislikes), and pagination for efficient data loading. The backend changes involve creating new APIs for voting and modifying existing restaurant data queries to include vote counts and scrap status. The frontend changes implement the UI components for voting, scraping, and displaying restaurant information, along with state management using Riverpod.
Highlights
- Vote Functionality: Implements the core voting feature, allowing users to like or dislike restaurants. Includes backend API endpoints and frontend UI components for voting.
- Scraping: Adds the ability for users to scrap restaurants, providing a way to save restaurants for later viewing. Includes backend API and frontend UI elements.
- Sorting and Pagination: Introduces sorting options for restaurants (by distance, likes, dislikes) and pagination to handle large datasets efficiently.
- State Management: Utilizes Riverpod for state management, ensuring a reactive and maintainable frontend architecture.
- Rate Limiting: Implements rate limiting to prevent abuse of the voting system, ensuring fair usage.
Changelog
Click here to see the changelog
- be/src/main/java/com/yumst/be/global/exception/ErrorResponse.java
- Added ErrorResponse class to handle exceptions and provide structured error responses.
- be/src/main/java/com/yumst/be/restaurant/repository/NaverReviewFeatureCountRepository.java
- Added a method to find top 2 features for a list of restaurant IDs, optimizing feature retrieval for multiple restaurants.
- be/src/main/java/com/yumst/be/restaurant/repository/RestaurantRepository.java
- Added queries to find nearby restaurants, sort them by distance, likes, or dislikes, and include scrap status and vote counts.
- be/src/main/java/com/yumst/be/restaurant/service/RestaurantService.java
- Added methods to find nearby restaurants with sorting and pagination, validate restaurant existence, and retrieve restaurant features efficiently.
- be/src/main/java/com/yumst/be/restaurant/vo/ResponseRestaurant.java
- Added a static method to create ResponseRestaurant objects from search results, simplifying data transformation.
- be/src/main/java/com/yumst/be/user/repository/UserRestaurantScrapRepository.java
- Added a method to find scrapped restaurant IDs for a user, improving scrap status retrieval.
- be/src/main/java/com/yumst/be/user/service/UserService.java
- Added a method to validate user existence, ensuring user-related operations are performed on valid users.
- be/src/main/java/com/yumst/be/vote/controller/UserRestaurantVoteController.java
- Created a new controller to handle voting-related API endpoints, including fetching votable restaurants, voting, and batch voting.
- be/src/main/java/com/yumst/be/vote/domain/UserRestaurantVote.java
- Created a UserRestaurantVote entity to represent user votes on restaurants, including vote type and versioning for optimistic locking.
- be/src/main/java/com/yumst/be/vote/dto/BatchVoteRequest.java
- Created DTOs for batch voting requests, including validation constraints to ensure data integrity.
- be/src/main/java/com/yumst/be/vote/dto/RestaurantRequest.java
- Created DTOs for restaurant requests, including validation constraints to ensure data integrity.
- be/src/main/java/com/yumst/be/vote/dto/VoteRequest.java
- Created DTOs for vote requests, including validation constraints to ensure data integrity.
- be/src/main/java/com/yumst/be/vote/dto/VoteResponse.java
- Created DTOs for vote responses, including validation constraints to ensure data integrity.
- be/src/main/java/com/yumst/be/vote/dto/VoteType.java
- Created an enum to represent vote types (LIKE, DISLIKE).
- be/src/main/java/com/yumst/be/vote/exception/VoteErrorCode.java
- Created an enum to represent vote error codes.
- be/src/main/java/com/yumst/be/vote/exception/VoteException.java
- Created a custom exception class for vote-related errors.
- be/src/main/java/com/yumst/be/vote/exception/VoteExceptionHandler.java
- Created a handler for vote-related exceptions.
- be/src/main/java/com/yumst/be/vote/repository/UserRestaurantVoteRepository.java
- Created a repository to manage user restaurant votes, including methods to find votes, count votes, and retrieve vote statistics.
- be/src/main/java/com/yumst/be/vote/service/UserRestaurantVoteService.java
- Created a service to handle user restaurant votes, including methods to fetch votable restaurants, vote, and batch vote.
- be/src/main/java/com/yumst/be/vote/util/VoteRateLimiter.java
- Created a utility class to limit the rate of vote requests.
- fe/lib/model/restaurant.dart
- Modified the Restaurant model to handle null values for top2Features.
- fe/lib/model/vote_restaurant.dart
- Created a VoteRestaurant model to represent restaurant data on the vote page.
- fe/lib/model/vote_restaurant.g.dart
- Generated code for VoteRestaurant model.
- fe/lib/provider/vote_state_provider.dart
- Created a state provider to manage the state of the vote page, including restaurant data, loading status, and error handling. Implemented VoteQueue for batching vote requests and rate limiting.
- fe/lib/repository/auth_repository.dart
- Added agreeToTerms method to auth repository.
- fe/lib/repository/vote_repository.dart
- Created a repository to handle vote-related API calls, including fetching votable restaurants, voting, batch voting, and scraping.
- fe/lib/repository/vote_repository.g.dart
- Generated code for VoteRepository.
- fe/lib/view/screen/auth_page/user_terms_page.dart
- Modified user terms page to submit agreement and navigate to survey or main screen.
- fe/lib/view/screen/main_page.dart
- Modified main page to include vote page.
- fe/lib/view/screen/vote_page.dart
- Created a vote page to display votable restaurants and handle user interactions.
- fe/lib/view/widget/vote_restaurant_card.dart
- Created a widget to display restaurant information on the vote page, including voting buttons, scrap button, and restaurant details.
Using Gemini Code Assist
The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.
Invoking Gemini
You can request assistance from Gemini at any point in your pull request via creating an issue comment (i.e. comment on the pull request page) using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands.
| Feature | Command | Description |
|---|---|---|
| Code Review | /gemini review |
Performs a code review for the current pull request in its current state. |
| Pull Request Summary | /gemini summary |
Provides a summary of the current pull request in its current state. |
| Comment | @gemini-code-assist | Responds in comments when explicitly tagged, both in issue comments and review comments. |
| Help | /gemini help |
Displays a list of available commands. |
Customization
To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.
Limitations & Feedback
Gemini Code Assist is currently in preview and may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments to provide feedback.
You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.
A vote cast, a choice made,
Restaurants rise, or gently fade.
Like or dislike,
Shape the night's hike,
In foodie quests, a path is laid.
Footnotes
-
Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution. ↩
There was a problem hiding this comment.
Code Review
This pull request introduces a new voting feature, including UI components and backend API endpoints. The code appears well-structured, incorporating pagination, rate limiting, and optimistic updates. However, there are several areas that could benefit from closer scrutiny and potential improvements, particularly around error handling, data consistency, and code clarity.
Summary of Findings
- Inconsistent Data Handling: The frontend and backend handle restaurant data differently, leading to potential inconsistencies. The backend uses
Object[]for nearby restaurant queries, while the frontend expectsVoteRestaurantobjects. This discrepancy requires careful mapping and could lead to errors or unexpected behavior. - Potential Race Condition in Vote Queue: The
VoteQueueclass in the frontend might be susceptible to race conditions if multiple votes for the same restaurant are added in quick succession. This could lead to incorrect vote counts or data inconsistencies. - Missing Error Handling in Scrap Toggle: The
toggleScrapfunction inVotePageStateNotifierlacks proper error handling for API failures, potentially leading to a desynchronized UI state. - Hardcoded Values: Several hardcoded values, such as the default location in
VotePageStateNotifierand the radius inVoteRepository, should be configurable to improve flexibility and maintainability.
Merge Readiness
While the pull request introduces valuable functionality, the identified issues regarding data consistency, potential race conditions, and error handling should be addressed before merging. I am unable to directly approve this pull request, and strongly recommend that these issues be addressed and the code be reviewed by other engineers before merging.
| Future<void> _toggleScrap() async { | ||
| try { | ||
| final restaurant = widget.restaurant; | ||
| final restaurantData = Restaurant( | ||
| restaurantId: restaurant.restaurantId, | ||
| name: restaurant.name, | ||
| category: restaurant.category ?? '', | ||
| latitude: '', | ||
| longitude: '', | ||
| thumbnailUrl: restaurant.thumbnailUrl, | ||
| fullAddress: '', | ||
| roadNameFullAddress: '', | ||
| phoneNumber: '', | ||
| todayOpening: restaurant.businessHours, | ||
| top2Features: restaurant.topFeatures, | ||
| isScrapped: !(restaurant.isScrapped ?? false), | ||
| likeCount: restaurant.likeCount, | ||
| dislikeCount: restaurant.dislikeCount, | ||
| distance: restaurant.distance, | ||
| ); | ||
| await ref | ||
| .read(restaurantRepositoryProvider) | ||
| .scrapRestaurant(restaurantData); | ||
|
|
||
| // provider를 통해 스크랩 상태 업데이트 | ||
| ref.read(voteRestaurantProvider(restaurant.restaurantId).notifier) | ||
| ..toggleScrap() // 먼저 스크랩 상태 토글 | ||
| ..updateRestaurant(restaurant.copyWith( // 그 다음 업데이트된 상태로 restaurant 객체 업데이트 | ||
| isScrapped: !(restaurant.isScrapped ?? false), | ||
| )); | ||
| } catch (e) { | ||
| if (mounted) { | ||
| ScaffoldMessenger.of(context).showSnackBar( | ||
| SnackBar(content: Text("스크랩 실패: $e")), | ||
| ); | ||
| } | ||
| } | ||
| } |
| // 거리순 정렬 | ||
| @Query(value = | ||
| "WITH RankedRestaurants AS (" + | ||
| " SELECT " + | ||
| " r.restaurant_id, r.name, r.category, r.thumbnail_url, " + | ||
| " ST_Distance(" + | ||
| " geography(ST_SetSRID(ST_MakePoint(CAST(r.longitude AS float), CAST(r.latitude AS float)), 4326)), " + | ||
| " geography(ST_SetSRID(ST_MakePoint(:longitude, :latitude), 4326))" + | ||
| " ) as dist, " + | ||
| " COALESCE(bool_or(us.restaurant_id IS NOT NULL), false) as is_scrapped, " + | ||
| " COALESCE(SUM(CASE WHEN v.vote_type = 'LIKE' THEN 1 ELSE 0 END), 0) as like_count, " + | ||
| " COALESCE(SUM(CASE WHEN v.vote_type = 'DISLIKE' THEN 1 ELSE 0 END), 0) as dislike_count, " + | ||
| " ROW_NUMBER() OVER (PARTITION BY r.name ORDER BY ST_Distance(" + | ||
| " geography(ST_SetSRID(ST_MakePoint(CAST(r.longitude AS float), CAST(r.latitude AS float)), 4326)), " + | ||
| " geography(ST_SetSRID(ST_MakePoint(:longitude, :latitude), 4326)))" + | ||
| " ) as row_num " + | ||
| " FROM " + | ||
| " restaurant r " + | ||
| " LEFT JOIN user_restaurant_scrap us ON r.restaurant_id = us.restaurant_id AND us.user_id = :userId " + | ||
| " LEFT JOIN user_restaurant_vote v ON r.restaurant_id = v.restaurant_id " + | ||
| " WHERE " + | ||
| " r.crawl_complete = true AND " + | ||
| " ST_DWithin(" + | ||
| " geography(ST_SetSRID(ST_MakePoint(CAST(r.longitude AS float), CAST(r.latitude AS float)), 4326)), " + | ||
| " geography(ST_SetSRID(ST_MakePoint(:longitude, :latitude), 4326)), " + | ||
| " :radius * 1000" + | ||
| " ) " + | ||
| " GROUP BY r.restaurant_id, r.name, r.category, r.thumbnail_url, r.longitude, r.latitude" + | ||
| ") " + | ||
| "SELECT restaurant_id, name, category, thumbnail_url, dist, is_scrapped, like_count, dislike_count " + | ||
| "FROM RankedRestaurants " + | ||
| "WHERE row_num = 1 " + | ||
| "ORDER BY dist ASC", | ||
| countQuery = "SELECT COUNT(DISTINCT r.name) " + | ||
| "FROM restaurant r " + | ||
| "WHERE r.crawl_complete = true AND " + | ||
| "ST_DWithin(" + | ||
| " geography(ST_SetSRID(ST_MakePoint(CAST(r.longitude AS float), CAST(r.latitude AS float)), 4326)), " + | ||
| " geography(ST_SetSRID(ST_MakePoint(:longitude, :latitude), 4326)), " + | ||
| " :radius * 1000" + | ||
| ")", | ||
| nativeQuery = true) | ||
| Page<Object[]> findNearbyRestaurantsOrderByDistance( | ||
| @Param("userId") String userId, | ||
| @Param("latitude") Double latitude, | ||
| @Param("longitude") Double longitude, | ||
| @Param("radius") Double radius, | ||
| Pageable pageable | ||
| ); | ||
|
|
||
| // 좋아요순 정렬 | ||
| @Query(value = | ||
| "WITH RankedRestaurants AS (" + | ||
| " SELECT " + | ||
| " r.restaurant_id, r.name, r.category, r.thumbnail_url, " + | ||
| " ST_Distance(" + | ||
| " geography(ST_SetSRID(ST_MakePoint(CAST(r.longitude AS float), CAST(r.latitude AS float)), 4326)), " + | ||
| " geography(ST_SetSRID(ST_MakePoint(:longitude, :latitude), 4326))" + | ||
| " ) as dist, " + | ||
| " COALESCE(bool_or(us.restaurant_id IS NOT NULL), false) as is_scrapped, " + | ||
| " COALESCE(SUM(CASE WHEN v.vote_type = 'LIKE' THEN 1 ELSE 0 END), 0) as like_count, " + | ||
| " COALESCE(SUM(CASE WHEN v.vote_type = 'DISLIKE' THEN 1 ELSE 0 END), 0) as dislike_count, " + | ||
| " ROW_NUMBER() OVER (PARTITION BY r.name ORDER BY ST_Distance(" + | ||
| " geography(ST_SetSRID(ST_MakePoint(CAST(r.longitude AS float), CAST(r.latitude AS float)), 4326)), " + | ||
| " geography(ST_SetSRID(ST_MakePoint(:longitude, :latitude), 4326)))" + | ||
| " ) as row_num " + | ||
| " FROM " + | ||
| " restaurant r " + | ||
| " LEFT JOIN user_restaurant_scrap us ON r.restaurant_id = us.restaurant_id AND us.user_id = :userId " + | ||
| " LEFT JOIN user_restaurant_vote v ON r.restaurant_id = v.restaurant_id " + | ||
| " WHERE " + | ||
| " r.crawl_complete = true AND " + | ||
| " ST_DWithin(" + | ||
| " geography(ST_SetSRID(ST_MakePoint(CAST(r.longitude AS float), CAST(r.latitude AS float)), 4326)), " + | ||
| " geography(ST_SetSRID(ST_MakePoint(:longitude, :latitude), 4326)), " + | ||
| " :radius * 1000" + | ||
| " ) " + | ||
| " GROUP BY r.restaurant_id, r.name, r.category, r.thumbnail_url, r.longitude, r.latitude" + | ||
| ") " + | ||
| "SELECT restaurant_id, name, category, thumbnail_url, dist, is_scrapped, like_count, dislike_count " + | ||
| "FROM RankedRestaurants " + | ||
| "WHERE row_num = 1 " + | ||
| "ORDER BY like_count DESC, dist ASC", | ||
| countQuery = "SELECT COUNT(DISTINCT r.name) " + | ||
| "FROM restaurant r " + | ||
| "WHERE r.crawl_complete = true AND " + | ||
| "ST_DWithin(" + | ||
| " geography(ST_SetSRID(ST_MakePoint(CAST(r.longitude AS float), CAST(r.latitude AS float)), 4326)), " + | ||
| " geography(ST_SetSRID(ST_MakePoint(:longitude, :latitude), 4326)), " + | ||
| " :radius * 1000" + | ||
| ")", | ||
| nativeQuery = true) | ||
| Page<Object[]> findNearbyRestaurantsOrderByLikes( | ||
| @Param("userId") String userId, | ||
| @Param("latitude") Double latitude, | ||
| @Param("longitude") Double longitude, | ||
| @Param("radius") Double radius, | ||
| Pageable pageable | ||
| ); | ||
|
|
||
| // 싫어요순 정렬 | ||
| @Query(value = | ||
| "WITH RankedRestaurants AS (" + | ||
| " SELECT " + | ||
| " r.restaurant_id, r.name, r.category, r.thumbnail_url, " + | ||
| " ST_Distance(" + | ||
| " geography(ST_SetSRID(ST_MakePoint(CAST(r.longitude AS float), CAST(r.latitude AS float)), 4326)), " + | ||
| " geography(ST_SetSRID(ST_MakePoint(:longitude, :latitude), 4326))" + | ||
| " ) as dist, " + | ||
| " COALESCE(bool_or(us.restaurant_id IS NOT NULL), false) as is_scrapped, " + | ||
| " COALESCE(SUM(CASE WHEN v.vote_type = 'LIKE' THEN 1 ELSE 0 END), 0) as like_count, " + | ||
| " COALESCE(SUM(CASE WHEN v.vote_type = 'DISLIKE' THEN 1 ELSE 0 END), 0) as dislike_count, " + | ||
| " ROW_NUMBER() OVER (PARTITION BY r.name ORDER BY ST_Distance(" + | ||
| " geography(ST_SetSRID(ST_MakePoint(CAST(r.longitude AS float), CAST(r.latitude AS float)), 4326)), " + | ||
| " geography(ST_SetSRID(ST_MakePoint(:longitude, :latitude), 4326)))" + | ||
| " ) as row_num " + | ||
| " FROM " + | ||
| " restaurant r " + | ||
| " LEFT JOIN user_restaurant_scrap us ON r.restaurant_id = us.restaurant_id AND us.user_id = :userId " + | ||
| " LEFT JOIN user_restaurant_vote v ON r.restaurant_id = v.restaurant_id " + | ||
| " WHERE " + | ||
| " r.crawl_complete = true AND " + | ||
| " ST_DWithin(" + | ||
| " geography(ST_SetSRID(ST_MakePoint(CAST(r.longitude AS float), CAST(r.latitude AS float)), 4326)), " + | ||
| " geography(ST_SetSRID(ST_MakePoint(:longitude, :latitude), 4326)), " + | ||
| " :radius * 1000" + | ||
| " ) " + | ||
| " GROUP BY r.restaurant_id, r.name, r.category, r.thumbnail_url, r.longitude, r.latitude" + | ||
| ") " + | ||
| "SELECT restaurant_id, name, category, thumbnail_url, dist, is_scrapped, like_count, dislike_count " + | ||
| "FROM RankedRestaurants " + | ||
| "WHERE row_num = 1 " + | ||
| "ORDER BY dislike_count DESC, dist ASC", | ||
| countQuery = "SELECT COUNT(DISTINCT r.name) " + | ||
| "FROM restaurant r " + | ||
| "WHERE r.crawl_complete = true AND " + | ||
| "ST_DWithin(" + | ||
| " geography(ST_SetSRID(ST_MakePoint(CAST(r.longitude AS float), CAST(r.latitude AS float)), 4326)), " + | ||
| " geography(ST_SetSRID(ST_MakePoint(:longitude, :latitude), 4326)), " + | ||
| " :radius * 1000" + | ||
| ")", | ||
| nativeQuery = true) | ||
| Page<Object[]> findNearbyRestaurantsOrderByDislikes( |
| public List<ResponseRestaurant> findNearbyRestaurants(String userId, Double latitude, Double longitude, Double radius, String sort, Pageable pageable) { | ||
| Page<Object[]> results; | ||
|
|
||
| switch (sort) { | ||
| case "likes": | ||
| results = restaurantRepository.findNearbyRestaurantsOrderByLikes(userId, latitude, longitude, radius, pageable); | ||
| break; | ||
| case "dislikes": | ||
| results = restaurantRepository.findNearbyRestaurantsOrderByDislikes(userId, latitude, longitude, radius, pageable); | ||
| break; | ||
| case "distance": | ||
| default: | ||
| results = restaurantRepository.findNearbyRestaurantsOrderByDistance(userId, latitude, longitude, radius, pageable); | ||
| break; | ||
| } | ||
|
|
||
| List<Object[]> content = results.getContent(); | ||
|
|
||
| if (content.isEmpty()) { | ||
| return new ArrayList<>(); | ||
| } | ||
|
|
||
| List<String> restaurantIds = content.stream() | ||
| .map(result -> (String) result[0]) | ||
| .toList(); | ||
|
|
||
|
|
||
| List<RestaurantNaverReviewFeatureCount> allFeatures = naverReviewFeatureCountRepository.findTop2FeaturesForRestaurantIds(restaurantIds); | ||
|
|
||
| Map<String, List<String>> featureMap = allFeatures.stream() | ||
| .collect(Collectors.groupingBy( | ||
| RestaurantNaverReviewFeatureCount::getRestaurantId, | ||
| Collectors.mapping(rc -> rc.getNaverReviewFeature().getFeature(), Collectors.toList()) | ||
| )) | ||
| .entrySet().stream() | ||
| .collect(Collectors.toMap( | ||
| Map.Entry::getKey, | ||
| entry -> entry.getValue().stream().limit(2).toList() | ||
| )); | ||
|
|
||
| return content.stream() | ||
| .map(result -> ResponseRestaurant.fromSearchResult( | ||
| (String) result[0], // restaurantId | ||
| (String) result[1], // name | ||
| (String) result[2], // category | ||
| (String) result[3], // thumbnailUrl | ||
| ((Number) result[4]).doubleValue(), // distance | ||
| (Boolean) result[5], // isScrapped | ||
| ((Number) result[6]).longValue(), // likeCount | ||
| ((Number) result[7]).longValue(), // dislikeCount | ||
| featureMap.getOrDefault((String) result[0], new ArrayList<>()) // features | ||
| )) | ||
| .toList(); |
There was a problem hiding this comment.
The method findNearbyRestaurants mixes data retrieval and DTO creation logic. It would be cleaner to separate these concerns. Consider creating a dedicated data transfer object (DTO) for the query results and mapping the results to ResponseRestaurant objects in a separate step. This improves separation of concerns and makes the code easier to test.
| // 위치 정보가 없을 때 기본값 사용 (서울 시청 좌표) | ||
| _lastPosition = Position( | ||
| latitude: 37.5665, | ||
| longitude: 126.9780, | ||
| timestamp: DateTime.now(), | ||
| accuracy: 0, | ||
| altitude: 0, | ||
| heading: 0, | ||
| speed: 0, | ||
| speedAccuracy: 0, | ||
| altitudeAccuracy: 0, | ||
| headingAccuracy: 0, | ||
| ); | ||
| } |
| void addVote(String restaurantId, VoteType voteType) { | ||
| final now = DateTime.now(); | ||
| if (now.difference(_lastRequestTime) < _rateLimitWindow) { | ||
| if (_requestCount >= 20) { | ||
| throw Exception('Too many requests'); | ||
| } | ||
| _requestCount++; | ||
| } else { | ||
| _requestCount = 1; | ||
| _lastRequestTime = now; | ||
| } | ||
|
|
||
| _queue.add({ | ||
| 'restaurantId': restaurantId, | ||
| 'voteType': voteType.name, | ||
| }); | ||
|
|
||
| _startBatchTimer(); | ||
| } |
…fe/votepage # Conflicts: # be/src/main/java/com/yumst/be/restaurant/repository/RestaurantRepository.java
|




PULL REQUEST
🎋 작업 브랜치
🔑 주요 변경사항
🏞 스크린샷 (선택)