diff --git a/backend-java/build.gradle b/backend-java/build.gradle index 4733de6..4fb2312 100644 --- a/backend-java/build.gradle +++ b/backend-java/build.gradle @@ -22,6 +22,9 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-jdbc' implementation 'org.springframework.boot:spring-boot-starter-security' + + // MyBatis + implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.4' implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'org.springframework.boot:spring-boot-starter-validation' diff --git a/backend-java/src/main/java/com/tasteby/config/ClobTypeHandler.java b/backend-java/src/main/java/com/tasteby/config/ClobTypeHandler.java new file mode 100644 index 0000000..66e0956 --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/config/ClobTypeHandler.java @@ -0,0 +1,50 @@ +package com.tasteby.config; + +import org.apache.ibatis.type.BaseTypeHandler; +import org.apache.ibatis.type.JdbcType; +import org.apache.ibatis.type.MappedJdbcTypes; +import org.apache.ibatis.type.MappedTypes; + +import java.io.Reader; +import java.sql.*; + +@MappedTypes(String.class) +@MappedJdbcTypes(JdbcType.CLOB) +public class ClobTypeHandler extends BaseTypeHandler { + + @Override + public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) + throws SQLException { + ps.setString(i, parameter); + } + + @Override + public String getNullableResult(ResultSet rs, String columnName) throws SQLException { + return clobToString(rs.getClob(columnName)); + } + + @Override + public String getNullableResult(ResultSet rs, int columnIndex) throws SQLException { + return clobToString(rs.getClob(columnIndex)); + } + + @Override + public String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { + return clobToString(cs.getClob(columnIndex)); + } + + private String clobToString(Clob clob) { + if (clob == null) return null; + try (Reader reader = clob.getCharacterStream()) { + StringBuilder sb = new StringBuilder(); + char[] buf = new char[4096]; + int len; + while ((len = reader.read(buf)) != -1) { + sb.append(buf, 0, len); + } + return sb.toString(); + } catch (Exception e) { + return null; + } + } +} diff --git a/backend-java/src/main/java/com/tasteby/config/JdbcConfig.java b/backend-java/src/main/java/com/tasteby/config/JdbcConfig.java deleted file mode 100644 index 7b365ec..0000000 --- a/backend-java/src/main/java/com/tasteby/config/JdbcConfig.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.tasteby.config; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Primary; -import org.springframework.jdbc.core.ColumnMapRowMapper; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.jdbc.core.RowMapper; -import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; - -import javax.sql.DataSource; -import java.util.Map; - -/** - * Custom JdbcTemplate that returns lowercase column names. - * Oracle returns UPPERCASE column names by default; this normalizes them - * so the JSON API returns snake_case keys matching the frontend expectations. - */ -@Configuration -public class JdbcConfig { - - @Bean - @Primary - public NamedParameterJdbcTemplate namedParameterJdbcTemplate(DataSource dataSource) { - JdbcTemplate jdbc = new LowerCaseJdbcTemplate(dataSource); - return new NamedParameterJdbcTemplate(jdbc); - } - - @Bean - @Primary - public JdbcTemplate jdbcTemplate(DataSource dataSource) { - return new LowerCaseJdbcTemplate(dataSource); - } - - /** - * JdbcTemplate subclass that uses a lowercase ColumnMapRowMapper. - */ - static class LowerCaseJdbcTemplate extends JdbcTemplate { - LowerCaseJdbcTemplate(DataSource dataSource) { - super(dataSource); - } - - @Override - protected RowMapper> getColumnMapRowMapper() { - return new ColumnMapRowMapper() { - @Override - protected String getColumnKey(String columnName) { - return columnName.toLowerCase(); - } - }; - } - } -} diff --git a/backend-java/src/main/java/com/tasteby/config/LowerCaseKeyAdvice.java b/backend-java/src/main/java/com/tasteby/config/LowerCaseKeyAdvice.java deleted file mode 100644 index 1a81260..0000000 --- a/backend-java/src/main/java/com/tasteby/config/LowerCaseKeyAdvice.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.tasteby.config; - -import org.springframework.core.MethodParameter; -import org.springframework.http.MediaType; -import org.springframework.http.converter.HttpMessageConverter; -import org.springframework.http.server.ServerHttpRequest; -import org.springframework.http.server.ServerHttpResponse; -import org.springframework.web.bind.annotation.RestControllerAdvice; -import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice; - -import java.util.*; - -/** - * Automatically converts Oracle UPPERCASE map keys to lowercase in all API responses. - */ -@RestControllerAdvice(basePackages = "com.tasteby") -public class LowerCaseKeyAdvice implements ResponseBodyAdvice { - - @Override - public boolean supports(MethodParameter returnType, Class> converterType) { - return true; - } - - @Override - public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, - Class> selectedConverterType, - ServerHttpRequest request, ServerHttpResponse response) { - return convertKeys(body); - } - - @SuppressWarnings("unchecked") - private Object convertKeys(Object obj) { - if (obj instanceof Map map) { - var result = new LinkedHashMap(); - for (var entry : map.entrySet()) { - String key = entry.getKey() instanceof String s ? s.toLowerCase() : String.valueOf(entry.getKey()); - result.put(key, convertKeys(entry.getValue())); - } - return result; - } - if (obj instanceof List list) { - return list.stream().map(this::convertKeys).toList(); - } - return obj; - } -} diff --git a/backend-java/src/main/java/com/tasteby/controller/AdminUserController.java b/backend-java/src/main/java/com/tasteby/controller/AdminUserController.java index 450db12..ea31a63 100644 --- a/backend-java/src/main/java/com/tasteby/controller/AdminUserController.java +++ b/backend-java/src/main/java/com/tasteby/controller/AdminUserController.java @@ -1,10 +1,9 @@ package com.tasteby.controller; -import com.tasteby.repository.UserRepository; -import com.tasteby.repository.ReviewRepository; -import com.tasteby.util.JsonUtil; -import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; -import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import com.tasteby.domain.Restaurant; +import com.tasteby.domain.Review; +import com.tasteby.service.ReviewService; +import com.tasteby.service.UserService; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -14,49 +13,30 @@ import java.util.Map; @RequestMapping("/api/admin/users") public class AdminUserController { - private final UserRepository userRepo; - private final NamedParameterJdbcTemplate jdbc; + private final UserService userService; + private final ReviewService reviewService; - public AdminUserController(UserRepository userRepo, NamedParameterJdbcTemplate jdbc) { - this.userRepo = userRepo; - this.jdbc = jdbc; + public AdminUserController(UserService userService, ReviewService reviewService) { + this.userService = userService; + this.reviewService = reviewService; } @GetMapping public Map listUsers( @RequestParam(defaultValue = "50") int limit, @RequestParam(defaultValue = "0") int offset) { - var users = userRepo.findAllWithCounts(limit, offset); - int total = userRepo.countAll(); + var users = userService.findAllWithCounts(limit, offset); + int total = userService.countAll(); return Map.of("users", users, "total", total); } @GetMapping("/{userId}/favorites") - public List> userFavorites(@PathVariable String userId) { - String sql = """ - SELECT r.id, r.name, r.address, r.region, r.cuisine_type, - r.rating, r.business_status, f.created_at - FROM user_favorites f - JOIN restaurants r ON r.id = f.restaurant_id - WHERE f.user_id = :u ORDER BY f.created_at DESC - """; - return jdbc.queryForList(sql, new MapSqlParameterSource("u", userId)); + public List userFavorites(@PathVariable String userId) { + return reviewService.getUserFavorites(userId); } @GetMapping("/{userId}/reviews") - public List> userReviews(@PathVariable String userId) { - String sql = """ - SELECT r.id, r.restaurant_id, r.rating, r.review_text, - r.visited_at, r.created_at, rest.name AS restaurant_name - FROM user_reviews r - LEFT JOIN restaurants rest ON rest.id = r.restaurant_id - WHERE r.user_id = :u ORDER BY r.created_at DESC - """; - var rows = jdbc.queryForList(sql, new MapSqlParameterSource("u", userId)); - rows.forEach(r -> { - Object text = r.get("review_text"); - r.put("review_text", JsonUtil.readClob(text)); - }); - return rows; + public List userReviews(@PathVariable String userId) { + return reviewService.findByUser(userId, 100, 0); } } diff --git a/backend-java/src/main/java/com/tasteby/controller/AuthController.java b/backend-java/src/main/java/com/tasteby/controller/AuthController.java index 947d1ef..dc9b1c6 100644 --- a/backend-java/src/main/java/com/tasteby/controller/AuthController.java +++ b/backend-java/src/main/java/com/tasteby/controller/AuthController.java @@ -1,5 +1,6 @@ package com.tasteby.controller; +import com.tasteby.domain.UserInfo; import com.tasteby.security.AuthUtil; import com.tasteby.service.AuthService; import org.springframework.web.bind.annotation.*; @@ -23,7 +24,7 @@ public class AuthController { } @GetMapping("/me") - public Map me() { + public UserInfo me() { String userId = AuthUtil.getUserId(); return authService.getCurrentUser(userId); } diff --git a/backend-java/src/main/java/com/tasteby/controller/ChannelController.java b/backend-java/src/main/java/com/tasteby/controller/ChannelController.java index b1285eb..7b08465 100644 --- a/backend-java/src/main/java/com/tasteby/controller/ChannelController.java +++ b/backend-java/src/main/java/com/tasteby/controller/ChannelController.java @@ -1,8 +1,11 @@ package com.tasteby.controller; -import com.tasteby.repository.ChannelRepository; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.tasteby.domain.Channel; import com.tasteby.security.AuthUtil; import com.tasteby.service.CacheService; +import com.tasteby.service.ChannelService; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; @@ -14,26 +17,26 @@ import java.util.Map; @RequestMapping("/api/channels") public class ChannelController { - private final ChannelRepository repo; + private final ChannelService channelService; private final CacheService cache; + private final ObjectMapper objectMapper; - public ChannelController(ChannelRepository repo, CacheService cache) { - this.repo = repo; + public ChannelController(ChannelService channelService, CacheService cache, ObjectMapper objectMapper) { + this.channelService = channelService; this.cache = cache; + this.objectMapper = objectMapper; } @GetMapping - public List> list() { + public List list() { String key = cache.makeKey("channels"); String cached = cache.getRaw(key); if (cached != null) { try { - var mapper = new com.fasterxml.jackson.databind.ObjectMapper(); - return mapper.readValue(cached, - new com.fasterxml.jackson.core.type.TypeReference<>() {}); + return objectMapper.readValue(cached, new TypeReference>() {}); } catch (Exception ignored) {} } - var result = repo.findAllActive(); + var result = channelService.findAllActive(); cache.set(key, result); return result; } @@ -46,7 +49,7 @@ public class ChannelController { String channelName = body.get("channel_name"); String titleFilter = body.get("title_filter"); try { - String id = repo.create(channelId, channelName, titleFilter); + String id = channelService.create(channelId, channelName, titleFilter); cache.flush(); return Map.of("id", id, "channel_id", channelId); } catch (Exception e) { @@ -60,7 +63,7 @@ public class ChannelController { @DeleteMapping("/{channelId}") public Map delete(@PathVariable String channelId) { AuthUtil.requireAdmin(); - if (!repo.deactivate(channelId)) { + if (!channelService.deactivate(channelId)) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Channel not found"); } cache.flush(); diff --git a/backend-java/src/main/java/com/tasteby/controller/DaemonController.java b/backend-java/src/main/java/com/tasteby/controller/DaemonController.java index e818adc..e0af561 100644 --- a/backend-java/src/main/java/com/tasteby/controller/DaemonController.java +++ b/backend-java/src/main/java/com/tasteby/controller/DaemonController.java @@ -1,82 +1,32 @@ package com.tasteby.controller; +import com.tasteby.domain.DaemonConfig; import com.tasteby.security.AuthUtil; -import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; -import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import com.tasteby.service.DaemonConfigService; import org.springframework.web.bind.annotation.*; -import java.util.ArrayList; -import java.util.LinkedHashMap; import java.util.Map; @RestController @RequestMapping("/api/daemon") public class DaemonController { - private final NamedParameterJdbcTemplate jdbc; + private final DaemonConfigService daemonConfigService; - public DaemonController(NamedParameterJdbcTemplate jdbc) { - this.jdbc = jdbc; + public DaemonController(DaemonConfigService daemonConfigService) { + this.daemonConfigService = daemonConfigService; } @GetMapping("/config") - public Map getConfig() { - String sql = """ - SELECT scan_enabled, scan_interval_min, process_enabled, process_interval_min, - process_limit, last_scan_at, last_process_at, updated_at - FROM daemon_config WHERE id = 1 - """; - var rows = jdbc.queryForList(sql, new MapSqlParameterSource()); - if (rows.isEmpty()) return Map.of(); - var row = rows.getFirst(); - var result = new LinkedHashMap(); - result.put("scan_enabled", toInt(row.get("scan_enabled")) == 1); - result.put("scan_interval_min", row.get("scan_interval_min")); - result.put("process_enabled", toInt(row.get("process_enabled")) == 1); - result.put("process_interval_min", row.get("process_interval_min")); - result.put("process_limit", row.get("process_limit")); - result.put("last_scan_at", row.get("last_scan_at") != null ? row.get("last_scan_at").toString() : null); - result.put("last_process_at", row.get("last_process_at") != null ? row.get("last_process_at").toString() : null); - result.put("updated_at", row.get("updated_at") != null ? row.get("updated_at").toString() : null); - return result; + public DaemonConfig getConfig() { + DaemonConfig config = daemonConfigService.getConfig(); + return config != null ? config : DaemonConfig.builder().build(); } @PutMapping("/config") public Map updateConfig(@RequestBody Map body) { AuthUtil.requireAdmin(); - var sets = new ArrayList(); - var params = new MapSqlParameterSource(); - - if (body.containsKey("scan_enabled")) { - sets.add("scan_enabled = :se"); - params.addValue("se", Boolean.TRUE.equals(body.get("scan_enabled")) ? 1 : 0); - } - if (body.containsKey("scan_interval_min")) { - sets.add("scan_interval_min = :si"); - params.addValue("si", ((Number) body.get("scan_interval_min")).intValue()); - } - if (body.containsKey("process_enabled")) { - sets.add("process_enabled = :pe"); - params.addValue("pe", Boolean.TRUE.equals(body.get("process_enabled")) ? 1 : 0); - } - if (body.containsKey("process_interval_min")) { - sets.add("process_interval_min = :pi"); - params.addValue("pi", ((Number) body.get("process_interval_min")).intValue()); - } - if (body.containsKey("process_limit")) { - sets.add("process_limit = :pl"); - params.addValue("pl", ((Number) body.get("process_limit")).intValue()); - } - if (!sets.isEmpty()) { - sets.add("updated_at = SYSTIMESTAMP"); - String sql = "UPDATE daemon_config SET " + String.join(", ", sets) + " WHERE id = 1"; - jdbc.update(sql, params); - } + daemonConfigService.updateConfig(body); return Map.of("ok", true); } - - private int toInt(Object val) { - if (val == null) return 0; - return ((Number) val).intValue(); - } } diff --git a/backend-java/src/main/java/com/tasteby/controller/RestaurantController.java b/backend-java/src/main/java/com/tasteby/controller/RestaurantController.java index df9b0ea..bb6a1d1 100644 --- a/backend-java/src/main/java/com/tasteby/controller/RestaurantController.java +++ b/backend-java/src/main/java/com/tasteby/controller/RestaurantController.java @@ -1,8 +1,11 @@ package com.tasteby.controller; -import com.tasteby.repository.RestaurantRepository; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.tasteby.domain.Restaurant; import com.tasteby.security.AuthUtil; import com.tasteby.service.CacheService; +import com.tasteby.service.RestaurantService; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; @@ -14,16 +17,18 @@ import java.util.Map; @RequestMapping("/api/restaurants") public class RestaurantController { - private final RestaurantRepository repo; + private final RestaurantService restaurantService; private final CacheService cache; + private final ObjectMapper objectMapper; - public RestaurantController(RestaurantRepository repo, CacheService cache) { - this.repo = repo; + public RestaurantController(RestaurantService restaurantService, CacheService cache, ObjectMapper objectMapper) { + this.restaurantService = restaurantService; this.cache = cache; + this.objectMapper = objectMapper; } @GetMapping - public List> list( + public List list( @RequestParam(defaultValue = "100") int limit, @RequestParam(defaultValue = "0") int offset, @RequestParam(required = false) String cuisine, @@ -35,28 +40,24 @@ public class RestaurantController { String cached = cache.getRaw(key); if (cached != null) { try { - var mapper = new com.fasterxml.jackson.databind.ObjectMapper(); - return mapper.readValue(cached, - new com.fasterxml.jackson.core.type.TypeReference<>() {}); + return objectMapper.readValue(cached, new TypeReference>() {}); } catch (Exception ignored) {} } - var result = repo.findAll(limit, offset, cuisine, region, channel); + var result = restaurantService.findAll(limit, offset, cuisine, region, channel); cache.set(key, result); return result; } @GetMapping("/{id}") - public Map get(@PathVariable String id) { + public Restaurant get(@PathVariable String id) { String key = cache.makeKey("restaurant", id); String cached = cache.getRaw(key); if (cached != null) { try { - var mapper = new com.fasterxml.jackson.databind.ObjectMapper(); - return mapper.readValue(cached, - new com.fasterxml.jackson.core.type.TypeReference<>() {}); + return objectMapper.readValue(cached, Restaurant.class); } catch (Exception ignored) {} } - var r = repo.findById(id); + var r = restaurantService.findById(id); if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found"); cache.set(key, r); return r; @@ -65,9 +66,9 @@ public class RestaurantController { @PutMapping("/{id}") public Map update(@PathVariable String id, @RequestBody Map body) { AuthUtil.requireAdmin(); - var r = repo.findById(id); + var r = restaurantService.findById(id); if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found"); - repo.update(id, body); + restaurantService.update(id, body); cache.flush(); return Map.of("ok", true); } @@ -75,9 +76,9 @@ public class RestaurantController { @DeleteMapping("/{id}") public Map delete(@PathVariable String id) { AuthUtil.requireAdmin(); - var r = repo.findById(id); + var r = restaurantService.findById(id); if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found"); - repo.delete(id); + restaurantService.delete(id); cache.flush(); return Map.of("ok", true); } @@ -88,14 +89,12 @@ public class RestaurantController { String cached = cache.getRaw(key); if (cached != null) { try { - var mapper = new com.fasterxml.jackson.databind.ObjectMapper(); - return mapper.readValue(cached, - new com.fasterxml.jackson.core.type.TypeReference<>() {}); + return objectMapper.readValue(cached, new TypeReference>>() {}); } catch (Exception ignored) {} } - var r = repo.findById(id); + var r = restaurantService.findById(id); if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found"); - var result = repo.findVideoLinks(id); + var result = restaurantService.findVideoLinks(id); cache.set(key, result); return result; } diff --git a/backend-java/src/main/java/com/tasteby/controller/ReviewController.java b/backend-java/src/main/java/com/tasteby/controller/ReviewController.java index 0abfaab..a905912 100644 --- a/backend-java/src/main/java/com/tasteby/controller/ReviewController.java +++ b/backend-java/src/main/java/com/tasteby/controller/ReviewController.java @@ -1,7 +1,9 @@ package com.tasteby.controller; -import com.tasteby.repository.ReviewRepository; +import com.tasteby.domain.Restaurant; +import com.tasteby.domain.Review; import com.tasteby.security.AuthUtil; +import com.tasteby.service.ReviewService; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; @@ -14,10 +16,10 @@ import java.util.Map; @RequestMapping("/api") public class ReviewController { - private final ReviewRepository repo; + private final ReviewService reviewService; - public ReviewController(ReviewRepository repo) { - this.repo = repo; + public ReviewController(ReviewService reviewService) { + this.reviewService = reviewService; } @GetMapping("/restaurants/{restaurantId}/reviews") @@ -25,15 +27,15 @@ public class ReviewController { @PathVariable String restaurantId, @RequestParam(defaultValue = "20") int limit, @RequestParam(defaultValue = "0") int offset) { - var reviews = repo.findByRestaurant(restaurantId, limit, offset); - var stats = repo.getAvgRating(restaurantId); + var reviews = reviewService.findByRestaurant(restaurantId, limit, offset); + var stats = reviewService.getAvgRating(restaurantId); return Map.of("reviews", reviews, "avg_rating", stats.get("avg_rating"), "review_count", stats.get("review_count")); } @PostMapping("/restaurants/{restaurantId}/reviews") @ResponseStatus(HttpStatus.CREATED) - public Map createReview( + public Review createReview( @PathVariable String restaurantId, @RequestBody Map body) { String userId = AuthUtil.getUserId(); @@ -41,7 +43,7 @@ public class ReviewController { String text = (String) body.get("review_text"); LocalDate visitedAt = body.get("visited_at") != null ? LocalDate.parse((String) body.get("visited_at")) : null; - return repo.create(userId, restaurantId, rating, text, visitedAt); + return reviewService.create(userId, restaurantId, rating, text, visitedAt); } @PutMapping("/reviews/{reviewId}") @@ -54,43 +56,42 @@ public class ReviewController { String text = (String) body.get("review_text"); LocalDate visitedAt = body.get("visited_at") != null ? LocalDate.parse((String) body.get("visited_at")) : null; - var result = repo.update(reviewId, userId, rating, text, visitedAt); - if (result == null) { + if (!reviewService.update(reviewId, userId, rating, text, visitedAt)) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Review not found or not yours"); } - return result; + return Map.of("ok", true); } @DeleteMapping("/reviews/{reviewId}") @ResponseStatus(HttpStatus.NO_CONTENT) public void deleteReview(@PathVariable String reviewId) { String userId = AuthUtil.getUserId(); - if (!repo.delete(reviewId, userId)) { + if (!reviewService.delete(reviewId, userId)) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Review not found or not yours"); } } @GetMapping("/users/me/reviews") - public List> myReviews( + public List myReviews( @RequestParam(defaultValue = "20") int limit, @RequestParam(defaultValue = "0") int offset) { - return repo.findByUser(AuthUtil.getUserId(), limit, offset); + return reviewService.findByUser(AuthUtil.getUserId(), limit, offset); } // Favorites @GetMapping("/restaurants/{restaurantId}/favorite") public Map favoriteStatus(@PathVariable String restaurantId) { - return Map.of("favorited", repo.isFavorited(AuthUtil.getUserId(), restaurantId)); + return Map.of("favorited", reviewService.isFavorited(AuthUtil.getUserId(), restaurantId)); } @PostMapping("/restaurants/{restaurantId}/favorite") public Map toggleFavorite(@PathVariable String restaurantId) { - boolean result = repo.toggleFavorite(AuthUtil.getUserId(), restaurantId); + boolean result = reviewService.toggleFavorite(AuthUtil.getUserId(), restaurantId); return Map.of("favorited", result); } @GetMapping("/users/me/favorites") - public List> myFavorites() { - return repo.getUserFavorites(AuthUtil.getUserId()); + public List myFavorites() { + return reviewService.getUserFavorites(AuthUtil.getUserId()); } } diff --git a/backend-java/src/main/java/com/tasteby/controller/SearchController.java b/backend-java/src/main/java/com/tasteby/controller/SearchController.java index 036b5d6..e8fd48a 100644 --- a/backend-java/src/main/java/com/tasteby/controller/SearchController.java +++ b/backend-java/src/main/java/com/tasteby/controller/SearchController.java @@ -1,10 +1,10 @@ package com.tasteby.controller; +import com.tasteby.domain.Restaurant; import com.tasteby.service.SearchService; import org.springframework.web.bind.annotation.*; import java.util.List; -import java.util.Map; @RestController @RequestMapping("/api/search") @@ -17,7 +17,7 @@ public class SearchController { } @GetMapping - public List> search( + public List search( @RequestParam String q, @RequestParam(defaultValue = "keyword") String mode, @RequestParam(defaultValue = "20") int limit) { diff --git a/backend-java/src/main/java/com/tasteby/controller/StatsController.java b/backend-java/src/main/java/com/tasteby/controller/StatsController.java index 3e59292..588b32a 100644 --- a/backend-java/src/main/java/com/tasteby/controller/StatsController.java +++ b/backend-java/src/main/java/com/tasteby/controller/StatsController.java @@ -1,6 +1,7 @@ package com.tasteby.controller; -import com.tasteby.repository.StatsRepository; +import com.tasteby.domain.SiteVisitStats; +import com.tasteby.service.StatsService; import org.springframework.web.bind.annotation.*; import java.util.Map; @@ -9,20 +10,20 @@ import java.util.Map; @RequestMapping("/api/stats") public class StatsController { - private final StatsRepository repo; + private final StatsService statsService; - public StatsController(StatsRepository repo) { - this.repo = repo; + public StatsController(StatsService statsService) { + this.statsService = statsService; } @PostMapping("/visit") public Map recordVisit() { - repo.recordVisit(); + statsService.recordVisit(); return Map.of("ok", true); } @GetMapping("/visits") - public Map getVisits() { - return repo.getVisits(); + public SiteVisitStats getVisits() { + return statsService.getVisits(); } } diff --git a/backend-java/src/main/java/com/tasteby/controller/VideoController.java b/backend-java/src/main/java/com/tasteby/controller/VideoController.java index 88ef587..a36c5da 100644 --- a/backend-java/src/main/java/com/tasteby/controller/VideoController.java +++ b/backend-java/src/main/java/com/tasteby/controller/VideoController.java @@ -1,8 +1,10 @@ package com.tasteby.controller; -import com.tasteby.repository.VideoRepository; +import com.tasteby.domain.VideoDetail; +import com.tasteby.domain.VideoSummary; import com.tasteby.security.AuthUtil; import com.tasteby.service.CacheService; +import com.tasteby.service.VideoService; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; @@ -14,22 +16,22 @@ import java.util.Map; @RequestMapping("/api/videos") public class VideoController { - private final VideoRepository repo; + private final VideoService videoService; private final CacheService cache; - public VideoController(VideoRepository repo, CacheService cache) { - this.repo = repo; + public VideoController(VideoService videoService, CacheService cache) { + this.videoService = videoService; this.cache = cache; } @GetMapping - public List> list(@RequestParam(required = false) String status) { - return repo.findAll(status); + public List list(@RequestParam(required = false) String status) { + return videoService.findAll(status); } @GetMapping("/{id}") - public Map detail(@PathVariable String id) { - var video = repo.findDetail(id); + public VideoDetail detail(@PathVariable String id) { + var video = videoService.findDetail(id); if (video == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Video not found"); return video; } @@ -41,7 +43,7 @@ public class VideoController { if (title == null || title.isBlank()) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "title is required"); } - repo.updateTitle(id, title); + videoService.updateTitle(id, title); cache.flush(); return Map.of("ok", true); } @@ -49,7 +51,7 @@ public class VideoController { @PostMapping("/{id}/skip") public Map skip(@PathVariable String id) { AuthUtil.requireAdmin(); - repo.updateStatus(id, "skip"); + videoService.updateStatus(id, "skip"); cache.flush(); return Map.of("ok", true); } @@ -57,7 +59,7 @@ public class VideoController { @DeleteMapping("/{id}") public Map delete(@PathVariable String id) { AuthUtil.requireAdmin(); - repo.delete(id); + videoService.delete(id); cache.flush(); return Map.of("ok", true); } @@ -66,7 +68,7 @@ public class VideoController { public Map deleteVideoRestaurant( @PathVariable String videoId, @PathVariable String restaurantId) { AuthUtil.requireAdmin(); - repo.deleteVideoRestaurant(videoId, restaurantId); + videoService.deleteVideoRestaurant(videoId, restaurantId); cache.flush(); return Map.of("ok", true); } diff --git a/backend-java/src/main/java/com/tasteby/controller/VideoSseController.java b/backend-java/src/main/java/com/tasteby/controller/VideoSseController.java index 2c564c5..26af4c8 100644 --- a/backend-java/src/main/java/com/tasteby/controller/VideoSseController.java +++ b/backend-java/src/main/java/com/tasteby/controller/VideoSseController.java @@ -7,8 +7,6 @@ import com.tasteby.util.CuisineTypes; import com.tasteby.util.JsonUtil; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; -import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; @@ -25,19 +23,22 @@ public class VideoSseController { private static final Logger log = LoggerFactory.getLogger(VideoSseController.class); - private final NamedParameterJdbcTemplate jdbc; + private final VideoService videoService; + private final RestaurantService restaurantService; private final PipelineService pipelineService; private final OciGenAiService genAi; private final CacheService cache; private final ObjectMapper mapper; private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); - public VideoSseController(NamedParameterJdbcTemplate jdbc, + public VideoSseController(VideoService videoService, + RestaurantService restaurantService, PipelineService pipelineService, OciGenAiService genAi, CacheService cache, ObjectMapper mapper) { - this.jdbc = jdbc; + this.videoService = videoService; + this.restaurantService = restaurantService; this.pipelineService = pipelineService; this.genAi = genAi; this.cache = cache; @@ -70,24 +71,7 @@ public class VideoSseController { executor.execute(() -> { try { - String sql = """ - SELECT v.id, v.video_id, v.title, v.url, v.transcript_text - FROM videos v - WHERE v.transcript_text IS NOT NULL - AND dbms_lob.getlength(v.transcript_text) > 0 - AND (v.llm_raw_response IS NULL OR dbms_lob.getlength(v.llm_raw_response) = 0) - AND v.status != 'skip' - ORDER BY v.published_at DESC - """; - var rows = jdbc.query(sql, new MapSqlParameterSource(), (rs, rowNum) -> { - Map m = new LinkedHashMap<>(); - m.put("id", rs.getString("ID")); - m.put("video_id", rs.getString("VIDEO_ID")); - m.put("title", rs.getString("TITLE")); - m.put("url", rs.getString("URL")); - m.put("transcript", JsonUtil.readClob(rs.getObject("TRANSCRIPT_TEXT"))); - return m; - }); + var rows = videoService.findVideosForBulkExtract(); int total = rows.size(); int totalRestaurants = 0; @@ -131,22 +115,8 @@ public class VideoSseController { executor.execute(() -> { try { - String sql = """ - SELECT r.id, r.name, r.cuisine_type, - (SELECT LISTAGG(vr.foods_mentioned, '|') WITHIN GROUP (ORDER BY vr.id) - FROM video_restaurants vr WHERE vr.restaurant_id = r.id) AS foods - FROM restaurants r - WHERE EXISTS (SELECT 1 FROM video_restaurants vr2 WHERE vr2.restaurant_id = r.id) - ORDER BY r.name - """; - var rows = jdbc.query(sql, new MapSqlParameterSource(), (rs, rowNum) -> { - Map m = new LinkedHashMap<>(); - m.put("id", rs.getString("ID")); - m.put("name", rs.getString("NAME")); - m.put("cuisine_type", rs.getString("CUISINE_TYPE")); - m.put("foods_mentioned", JsonUtil.readClob(rs.getObject("FOODS"))); - return m; - }); + var rows = restaurantService.findForRemapCuisine(); + rows = rows.stream().map(JsonUtil::lowerKeys).toList(); int total = rows.size(); emit(emitter, Map.of("type", "start", "total", total)); @@ -199,21 +169,12 @@ public class VideoSseController { executor.execute(() -> { try { - String sql = """ - SELECT vr.id, r.name, r.cuisine_type, vr.foods_mentioned, v.title - FROM video_restaurants vr - JOIN restaurants r ON r.id = vr.restaurant_id - JOIN videos v ON v.id = vr.video_id ORDER BY r.name - """; - var rows = jdbc.query(sql, new MapSqlParameterSource(), (rs, rowNum) -> { - Map m = new LinkedHashMap<>(); - m.put("id", rs.getString("ID")); - m.put("name", rs.getString("NAME")); - m.put("cuisine_type", rs.getString("CUISINE_TYPE")); - m.put("foods", JsonUtil.parseStringList(rs.getObject("FOODS_MENTIONED"))); - m.put("video_title", rs.getString("TITLE")); + var rows = restaurantService.findForRemapFoods(); + rows = rows.stream().map(r -> { + var m = JsonUtil.lowerKeys(r); + m.put("foods", JsonUtil.parseStringList(m.get("foods_mentioned"))); return m; - }); + }).toList(); int total = rows.size(); emit(emitter, Map.of("type", "start", "total", total)); @@ -330,8 +291,7 @@ public class VideoSseController { missed.add(b); continue; } - jdbc.update("UPDATE restaurants SET cuisine_type = :ct WHERE id = :id", - new MapSqlParameterSource().addValue("ct", newType).addValue("id", id)); + restaurantService.updateCuisineType(id, newType); updated++; } return new BatchResult(updated, missed); @@ -383,10 +343,7 @@ public class VideoSseController { missed.add(b); continue; } - jdbc.update("UPDATE video_restaurants SET foods_mentioned = :foods WHERE id = :id", - new MapSqlParameterSource() - .addValue("foods", mapper.writeValueAsString(newFoods)) - .addValue("id", id)); + restaurantService.updateFoodsMentioned(id, mapper.writeValueAsString(newFoods)); updated++; } return new BatchResult(updated, missed); diff --git a/backend-java/src/main/java/com/tasteby/domain/Channel.java b/backend-java/src/main/java/com/tasteby/domain/Channel.java new file mode 100644 index 0000000..cb84f50 --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/domain/Channel.java @@ -0,0 +1,19 @@ +package com.tasteby.domain; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Channel { + private String id; + private String channelId; + private String channelName; + private String titleFilter; + private int videoCount; + private String lastVideoAt; +} diff --git a/backend-java/src/main/java/com/tasteby/domain/DaemonConfig.java b/backend-java/src/main/java/com/tasteby/domain/DaemonConfig.java new file mode 100644 index 0000000..702251d --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/domain/DaemonConfig.java @@ -0,0 +1,24 @@ +package com.tasteby.domain; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Date; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class DaemonConfig { + private int id; + private boolean scanEnabled; + private int scanIntervalMin; + private boolean processEnabled; + private int processIntervalMin; + private int processLimit; + private Date lastScanAt; + private Date lastProcessAt; + private Date updatedAt; +} diff --git a/backend-java/src/main/java/com/tasteby/domain/Restaurant.java b/backend-java/src/main/java/com/tasteby/domain/Restaurant.java new file mode 100644 index 0000000..5811c67 --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/domain/Restaurant.java @@ -0,0 +1,35 @@ +package com.tasteby.domain; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Date; +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Restaurant { + private String id; + private String name; + private String address; + private String region; + private Double latitude; + private Double longitude; + private String cuisineType; + private String priceRange; + private String phone; + private String website; + private String googlePlaceId; + private String businessStatus; + private Double rating; + private Integer ratingCount; + private Date updatedAt; + + // Transient enrichment fields + private List channels; + private List foodsMentioned; +} diff --git a/backend-java/src/main/java/com/tasteby/domain/Review.java b/backend-java/src/main/java/com/tasteby/domain/Review.java new file mode 100644 index 0000000..1600517 --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/domain/Review.java @@ -0,0 +1,24 @@ +package com.tasteby.domain; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Review { + private String id; + private String userId; + private String restaurantId; + private double rating; + private String reviewText; + private String visitedAt; + private String createdAt; + private String updatedAt; + private String userNickname; + private String userAvatarUrl; + private String restaurantName; +} diff --git a/backend-java/src/main/java/com/tasteby/domain/SiteVisitStats.java b/backend-java/src/main/java/com/tasteby/domain/SiteVisitStats.java new file mode 100644 index 0000000..10b7593 --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/domain/SiteVisitStats.java @@ -0,0 +1,15 @@ +package com.tasteby.domain; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SiteVisitStats { + private int today; + private int total; +} diff --git a/backend-java/src/main/java/com/tasteby/domain/UserInfo.java b/backend-java/src/main/java/com/tasteby/domain/UserInfo.java new file mode 100644 index 0000000..0656d89 --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/domain/UserInfo.java @@ -0,0 +1,25 @@ +package com.tasteby.domain; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UserInfo { + private String id; + private String email; + private String nickname; + private String avatarUrl; + @JsonProperty("is_admin") + private boolean isAdmin; + private String provider; + private String providerId; + private String createdAt; + private int favoriteCount; + private int reviewCount; +} diff --git a/backend-java/src/main/java/com/tasteby/domain/VectorSearchResult.java b/backend-java/src/main/java/com/tasteby/domain/VectorSearchResult.java new file mode 100644 index 0000000..721be26 --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/domain/VectorSearchResult.java @@ -0,0 +1,16 @@ +package com.tasteby.domain; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class VectorSearchResult { + private String restaurantId; + private String chunkText; + private double distance; +} diff --git a/backend-java/src/main/java/com/tasteby/domain/VideoDetail.java b/backend-java/src/main/java/com/tasteby/domain/VideoDetail.java new file mode 100644 index 0000000..b27a25d --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/domain/VideoDetail.java @@ -0,0 +1,28 @@ +package com.tasteby.domain; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class VideoDetail { + private String id; + private String videoId; + private String title; + private String url; + private String status; + private String publishedAt; + private String channelName; + private boolean hasTranscript; + private boolean hasLlm; + private int restaurantCount; + private int matchedCount; + private String transcriptText; + private List restaurants; +} diff --git a/backend-java/src/main/java/com/tasteby/domain/VideoRestaurantLink.java b/backend-java/src/main/java/com/tasteby/domain/VideoRestaurantLink.java new file mode 100644 index 0000000..a83d634 --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/domain/VideoRestaurantLink.java @@ -0,0 +1,33 @@ +package com.tasteby.domain; + +import com.fasterxml.jackson.annotation.JsonRawValue; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class VideoRestaurantLink { + private String restaurantId; + private String name; + private String address; + private String cuisineType; + private String priceRange; + private String region; + @JsonRawValue + private String foodsMentioned; + @JsonRawValue + private String evaluation; + @JsonRawValue + private String guests; + private String googlePlaceId; + private Double latitude; + private Double longitude; + + public boolean isHasLocation() { + return latitude != null && longitude != null; + } +} diff --git a/backend-java/src/main/java/com/tasteby/domain/VideoSummary.java b/backend-java/src/main/java/com/tasteby/domain/VideoSummary.java new file mode 100644 index 0000000..a1e3882 --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/domain/VideoSummary.java @@ -0,0 +1,24 @@ +package com.tasteby.domain; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class VideoSummary { + private String id; + private String videoId; + private String title; + private String url; + private String status; + private String publishedAt; + private String channelName; + private boolean hasTranscript; + private boolean hasLlm; + private int restaurantCount; + private int matchedCount; +} diff --git a/backend-java/src/main/java/com/tasteby/mapper/ChannelMapper.java b/backend-java/src/main/java/com/tasteby/mapper/ChannelMapper.java new file mode 100644 index 0000000..1d67a35 --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/mapper/ChannelMapper.java @@ -0,0 +1,24 @@ +package com.tasteby.mapper; + +import com.tasteby.domain.Channel; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface ChannelMapper { + + List findAllActive(); + + void insert(@Param("id") String id, + @Param("channelId") String channelId, + @Param("channelName") String channelName, + @Param("titleFilter") String titleFilter); + + int deactivateByChannelId(@Param("channelId") String channelId); + + int deactivateById(@Param("id") String id); + + Channel findByChannelId(@Param("channelId") String channelId); +} diff --git a/backend-java/src/main/java/com/tasteby/mapper/DaemonConfigMapper.java b/backend-java/src/main/java/com/tasteby/mapper/DaemonConfigMapper.java new file mode 100644 index 0000000..b092af2 --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/mapper/DaemonConfigMapper.java @@ -0,0 +1,16 @@ +package com.tasteby.mapper; + +import com.tasteby.domain.DaemonConfig; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface DaemonConfigMapper { + + DaemonConfig getConfig(); + + void updateConfig(DaemonConfig config); + + void updateLastScan(); + + void updateLastProcess(); +} diff --git a/backend-java/src/main/java/com/tasteby/mapper/RestaurantMapper.java b/backend-java/src/main/java/com/tasteby/mapper/RestaurantMapper.java new file mode 100644 index 0000000..9b2010f --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/mapper/RestaurantMapper.java @@ -0,0 +1,61 @@ +package com.tasteby.mapper; + +import com.tasteby.domain.Restaurant; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; +import java.util.Map; + +@Mapper +public interface RestaurantMapper { + + List findAll(@Param("limit") int limit, + @Param("offset") int offset, + @Param("cuisine") String cuisine, + @Param("region") String region, + @Param("channel") String channel); + + Restaurant findById(@Param("id") String id); + + List> findVideoLinks(@Param("restaurantId") String restaurantId); + + void insertRestaurant(Restaurant r); + + void updateRestaurant(Restaurant r); + + void updateFields(@Param("id") String id, @Param("fields") Map fields); + + void deleteVectors(@Param("id") String id); + + void deleteReviews(@Param("id") String id); + + void deleteFavorites(@Param("id") String id); + + void deleteVideoRestaurants(@Param("id") String id); + + void deleteRestaurant(@Param("id") String id); + + void linkVideoRestaurant(@Param("id") String id, + @Param("videoId") String videoId, + @Param("restaurantId") String restaurantId, + @Param("foods") String foods, + @Param("evaluation") String evaluation, + @Param("guests") String guests); + + String findIdByPlaceId(@Param("placeId") String placeId); + + String findIdByName(@Param("name") String name); + + List> findChannelsByRestaurantIds(@Param("ids") List ids); + + List> findFoodsByRestaurantIds(@Param("ids") List ids); + + void updateCuisineType(@Param("id") String id, @Param("cuisineType") String cuisineType); + + void updateFoodsMentioned(@Param("id") String id, @Param("foods") String foods); + + List> findForRemapCuisine(); + + List> findForRemapFoods(); +} diff --git a/backend-java/src/main/java/com/tasteby/mapper/ReviewMapper.java b/backend-java/src/main/java/com/tasteby/mapper/ReviewMapper.java new file mode 100644 index 0000000..6ce6f9c --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/mapper/ReviewMapper.java @@ -0,0 +1,52 @@ +package com.tasteby.mapper; + +import com.tasteby.domain.Restaurant; +import com.tasteby.domain.Review; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; +import java.util.Map; + +@Mapper +public interface ReviewMapper { + + void insertReview(@Param("id") String id, + @Param("userId") String userId, + @Param("restaurantId") String restaurantId, + @Param("rating") double rating, + @Param("reviewText") String reviewText, + @Param("visitedAt") String visitedAt); + + int updateReview(@Param("id") String id, + @Param("userId") String userId, + @Param("rating") Double rating, + @Param("reviewText") String reviewText, + @Param("visitedAt") String visitedAt); + + int deleteReview(@Param("id") String id, @Param("userId") String userId); + + Review findById(@Param("id") String id); + + List findByRestaurant(@Param("restaurantId") String restaurantId, + @Param("limit") int limit, + @Param("offset") int offset); + + Map getAvgRating(@Param("restaurantId") String restaurantId); + + List findByUser(@Param("userId") String userId, + @Param("limit") int limit, + @Param("offset") int offset); + + int countFavorite(@Param("userId") String userId, @Param("restaurantId") String restaurantId); + + void insertFavorite(@Param("id") String id, + @Param("userId") String userId, + @Param("restaurantId") String restaurantId); + + int deleteFavorite(@Param("userId") String userId, @Param("restaurantId") String restaurantId); + + String findFavoriteId(@Param("userId") String userId, @Param("restaurantId") String restaurantId); + + List getUserFavorites(@Param("userId") String userId); +} diff --git a/backend-java/src/main/java/com/tasteby/mapper/SearchMapper.java b/backend-java/src/main/java/com/tasteby/mapper/SearchMapper.java new file mode 100644 index 0000000..cd7a088 --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/mapper/SearchMapper.java @@ -0,0 +1,16 @@ +package com.tasteby.mapper; + +import com.tasteby.domain.Restaurant; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; +import java.util.Map; + +@Mapper +public interface SearchMapper { + + List keywordSearch(@Param("query") String query, @Param("limit") int limit); + + List> findChannelsByRestaurantIds(@Param("ids") List ids); +} diff --git a/backend-java/src/main/java/com/tasteby/mapper/StatsMapper.java b/backend-java/src/main/java/com/tasteby/mapper/StatsMapper.java new file mode 100644 index 0000000..8bf2fb4 --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/mapper/StatsMapper.java @@ -0,0 +1,13 @@ +package com.tasteby.mapper; + +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface StatsMapper { + + void recordVisit(); + + int getTodayVisits(); + + int getTotalVisits(); +} diff --git a/backend-java/src/main/java/com/tasteby/mapper/UserMapper.java b/backend-java/src/main/java/com/tasteby/mapper/UserMapper.java new file mode 100644 index 0000000..81bb90a --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/mapper/UserMapper.java @@ -0,0 +1,24 @@ +package com.tasteby.mapper; + +import com.tasteby.domain.UserInfo; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface UserMapper { + + UserInfo findByProviderAndProviderId(@Param("provider") String provider, + @Param("providerId") String providerId); + + void updateLastLogin(@Param("id") String id); + + void insert(UserInfo user); + + UserInfo findById(@Param("id") String id); + + List findAllWithCounts(@Param("limit") int limit, @Param("offset") int offset); + + int countAll(); +} diff --git a/backend-java/src/main/java/com/tasteby/mapper/VectorMapper.java b/backend-java/src/main/java/com/tasteby/mapper/VectorMapper.java new file mode 100644 index 0000000..aad612c --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/mapper/VectorMapper.java @@ -0,0 +1,20 @@ +package com.tasteby.mapper; + +import com.tasteby.domain.VectorSearchResult; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface VectorMapper { + + List searchSimilar(@Param("queryVec") String queryVec, + @Param("topK") int topK, + @Param("maxDistance") double maxDistance); + + void insertVector(@Param("id") String id, + @Param("restaurantId") String restaurantId, + @Param("chunkText") String chunkText, + @Param("embedding") String embedding); +} diff --git a/backend-java/src/main/java/com/tasteby/mapper/VideoMapper.java b/backend-java/src/main/java/com/tasteby/mapper/VideoMapper.java new file mode 100644 index 0000000..b411706 --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/mapper/VideoMapper.java @@ -0,0 +1,68 @@ +package com.tasteby.mapper; + +import com.tasteby.domain.VideoDetail; +import com.tasteby.domain.VideoRestaurantLink; +import com.tasteby.domain.VideoSummary; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; +import java.util.Map; + +@Mapper +public interface VideoMapper { + + List findAll(@Param("status") String status); + + VideoDetail findDetail(@Param("id") String id); + + List findVideoRestaurants(@Param("videoId") String videoId); + + void updateStatus(@Param("id") String id, @Param("status") String status); + + void updateTitle(@Param("id") String id, @Param("title") String title); + + void updateTranscript(@Param("id") String id, @Param("transcript") String transcript); + + void deleteVectorsByVideoOnly(@Param("videoId") String videoId); + + void deleteReviewsByVideoOnly(@Param("videoId") String videoId); + + void deleteFavoritesByVideoOnly(@Param("videoId") String videoId); + + void deleteRestaurantsByVideoOnly(@Param("videoId") String videoId); + + void deleteVideoRestaurants(@Param("videoId") String videoId); + + void deleteVideo(@Param("videoId") String videoId); + + void deleteOneVideoRestaurant(@Param("videoId") String videoId, @Param("restaurantId") String restaurantId); + + void cleanupOrphanVectors(@Param("restaurantId") String restaurantId); + + void cleanupOrphanReviews(@Param("restaurantId") String restaurantId); + + void cleanupOrphanFavorites(@Param("restaurantId") String restaurantId); + + void cleanupOrphanRestaurant(@Param("restaurantId") String restaurantId); + + void insertVideo(@Param("id") String id, + @Param("channelId") String channelId, + @Param("videoId") String videoId, + @Param("title") String title, + @Param("url") String url, + @Param("publishedAt") String publishedAt); + + List getExistingVideoIds(@Param("channelId") String channelId); + + String getLatestVideoDate(@Param("channelId") String channelId); + + List> findPendingVideos(@Param("limit") int limit); + + void updateVideoFields(@Param("id") String id, + @Param("status") String status, + @Param("transcript") String transcript, + @Param("llmResponse") String llmResponse); + + List> findVideosForBulkExtract(); +} diff --git a/backend-java/src/main/java/com/tasteby/repository/ChannelRepository.java b/backend-java/src/main/java/com/tasteby/repository/ChannelRepository.java deleted file mode 100644 index fb214d8..0000000 --- a/backend-java/src/main/java/com/tasteby/repository/ChannelRepository.java +++ /dev/null @@ -1,74 +0,0 @@ -package com.tasteby.repository; - -import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; -import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; -import org.springframework.stereotype.Repository; - -import java.sql.Timestamp; -import java.util.*; - -@Repository -public class ChannelRepository { - - private final NamedParameterJdbcTemplate jdbc; - - public ChannelRepository(NamedParameterJdbcTemplate jdbc) { - this.jdbc = jdbc; - } - - public List> findAllActive() { - String sql = """ - SELECT c.id, c.channel_id, c.channel_name, c.title_filter, c.created_at, - (SELECT COUNT(*) FROM videos v WHERE v.channel_id = c.id) AS video_count, - (SELECT MAX(v.published_at) FROM videos v WHERE v.channel_id = c.id) AS last_video_at - FROM channels c - WHERE c.is_active = 1 - ORDER BY c.channel_name - """; - return jdbc.query(sql, new MapSqlParameterSource(), (rs, rowNum) -> { - Map m = new LinkedHashMap<>(); - m.put("id", rs.getString("ID")); - m.put("channel_id", rs.getString("CHANNEL_ID")); - m.put("channel_name", rs.getString("CHANNEL_NAME")); - m.put("title_filter", rs.getString("TITLE_FILTER")); - Timestamp ts = rs.getTimestamp("CREATED_AT"); - m.put("created_at", ts != null ? ts.toInstant().toString() : null); - m.put("video_count", rs.getInt("VIDEO_COUNT")); - Timestamp lastVideo = rs.getTimestamp("LAST_VIDEO_AT"); - m.put("last_video_at", lastVideo != null ? lastVideo.toInstant().toString() : null); - return m; - }); - } - - public String create(String channelId, String channelName, String titleFilter) { - String id = UUID.randomUUID().toString().replace("-", "").substring(0, 32).toUpperCase(); - String sql = """ - INSERT INTO channels (id, channel_id, channel_name, title_filter) - VALUES (:id, :cid, :cname, :tf) - """; - var params = new MapSqlParameterSource(); - params.addValue("id", id); - params.addValue("cid", channelId); - params.addValue("cname", channelName); - params.addValue("tf", titleFilter); - jdbc.update(sql, params); - return id; - } - - public boolean deactivate(String channelId) { - String sql = "UPDATE channels SET is_active = 0 WHERE channel_id = :cid AND is_active = 1"; - int count = jdbc.update(sql, new MapSqlParameterSource("cid", channelId)); - if (count == 0) { - // Try by DB id - sql = "UPDATE channels SET is_active = 0 WHERE id = :cid AND is_active = 1"; - count = jdbc.update(sql, new MapSqlParameterSource("cid", channelId)); - } - return count > 0; - } - - public Map findByChannelId(String channelId) { - String sql = "SELECT id, channel_id, channel_name, title_filter FROM channels WHERE channel_id = :cid AND is_active = 1"; - var rows = jdbc.queryForList(sql, new MapSqlParameterSource("cid", channelId)); - return rows.isEmpty() ? null : rows.getFirst(); - } -} diff --git a/backend-java/src/main/java/com/tasteby/repository/RestaurantRepository.java b/backend-java/src/main/java/com/tasteby/repository/RestaurantRepository.java deleted file mode 100644 index 5b8aeac..0000000 --- a/backend-java/src/main/java/com/tasteby/repository/RestaurantRepository.java +++ /dev/null @@ -1,340 +0,0 @@ -package com.tasteby.repository; - -import com.tasteby.util.JsonUtil; -import com.tasteby.util.RegionParser; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; -import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; -import org.springframework.stereotype.Repository; - -import java.math.BigDecimal; -import java.sql.Timestamp; -import java.util.*; - -@Repository -public class RestaurantRepository { - - private final JdbcTemplate jdbc; - private final NamedParameterJdbcTemplate namedJdbc; - - public RestaurantRepository(JdbcTemplate jdbc, NamedParameterJdbcTemplate namedJdbc) { - this.jdbc = jdbc; - this.namedJdbc = namedJdbc; - } - - public List> findAll(int limit, int offset, - String cuisine, String region, String channel) { - var conditions = new ArrayList(); - conditions.add("r.latitude IS NOT NULL"); - conditions.add("EXISTS (SELECT 1 FROM video_restaurants vr0 WHERE vr0.restaurant_id = r.id)"); - - var params = new MapSqlParameterSource(); - params.addValue("lim", limit); - params.addValue("off", offset); - - String joinClause = ""; - if (cuisine != null && !cuisine.isBlank()) { - conditions.add("r.cuisine_type = :cuisine"); - params.addValue("cuisine", cuisine); - } - if (region != null && !region.isBlank()) { - conditions.add("r.region LIKE :region"); - params.addValue("region", "%" + region + "%"); - } - if (channel != null && !channel.isBlank()) { - joinClause = """ - JOIN video_restaurants vr_f ON vr_f.restaurant_id = r.id - JOIN videos v_f ON v_f.id = vr_f.video_id - JOIN channels c_f ON c_f.id = v_f.channel_id - """; - conditions.add("c_f.channel_name = :channel"); - params.addValue("channel", channel); - } - - String where = String.join(" AND ", conditions); - String sql = """ - SELECT DISTINCT r.id, r.name, r.address, r.region, r.latitude, r.longitude, - r.cuisine_type, r.price_range, r.google_place_id, - r.business_status, r.rating, r.rating_count, r.updated_at - FROM restaurants r - %s - WHERE %s - ORDER BY r.updated_at DESC - OFFSET :off ROWS FETCH NEXT :lim ROWS ONLY - """.formatted(joinClause, where); - - List> rows = namedJdbc.queryForList(sql, params); - - if (!rows.isEmpty()) { - attachChannels(rows); - attachFoodsMentioned(rows); - } - return rows; - } - - public Map findById(String id) { - String sql = """ - SELECT r.id, r.name, r.address, r.region, r.latitude, r.longitude, - r.cuisine_type, r.price_range, r.phone, r.website, r.google_place_id, - r.business_status, r.rating, r.rating_count - FROM restaurants r WHERE r.id = :id - """; - var params = new MapSqlParameterSource("id", id); - List> rows = namedJdbc.queryForList(sql, params); - return rows.isEmpty() ? null : normalizeRow(rows.getFirst()); - } - - public List> findVideoLinks(String restaurantId) { - String sql = """ - SELECT v.video_id, v.title, v.url, v.published_at, - vr.foods_mentioned, vr.evaluation, vr.guests, - c.channel_name, c.channel_id - FROM video_restaurants vr - JOIN videos v ON v.id = vr.video_id - JOIN channels c ON c.id = v.channel_id - WHERE vr.restaurant_id = :rid - ORDER BY v.published_at DESC - """; - var params = new MapSqlParameterSource("rid", restaurantId); - return namedJdbc.query(sql, params, (rs, rowNum) -> { - Map m = new LinkedHashMap<>(); - m.put("video_id", rs.getString("video_id")); - m.put("title", rs.getString("title")); - m.put("url", rs.getString("url")); - Timestamp ts = rs.getTimestamp("published_at"); - m.put("published_at", ts != null ? ts.toInstant().toString() : null); - m.put("foods_mentioned", JsonUtil.parseStringList(rs.getObject("foods_mentioned"))); - m.put("evaluation", JsonUtil.parseMap(rs.getObject("evaluation"))); - m.put("guests", JsonUtil.parseStringList(rs.getObject("guests"))); - m.put("channel_name", rs.getString("channel_name")); - m.put("channel_id", rs.getString("channel_id")); - return m; - }); - } - - public String upsert(Map data) { - String name = (String) data.get("name"); - String address = (String) data.get("address"); - String region = (String) data.get("region"); - - if (region == null && address != null) { - region = RegionParser.parse(address); - } - - // Try find by google_place_id then by name - String existing = null; - String gid = (String) data.get("google_place_id"); - if (gid != null) { - existing = findIdByPlaceId(gid); - } - if (existing == null) { - existing = findIdByName(name); - } - - if (existing != null) { - String sql = """ - UPDATE restaurants SET - name = :name, - address = COALESCE(:addr, address), - region = COALESCE(:reg, region), - latitude = COALESCE(:lat, latitude), - longitude = COALESCE(:lng, longitude), - cuisine_type = COALESCE(:cuisine, cuisine_type), - price_range = COALESCE(:price, price_range), - google_place_id = COALESCE(:gid, google_place_id), - phone = COALESCE(:phone, phone), - website = COALESCE(:web, website), - business_status = COALESCE(:bstatus, business_status), - rating = COALESCE(:rating, rating), - rating_count = COALESCE(:rcnt, rating_count), - updated_at = SYSTIMESTAMP - WHERE id = :id - """; - var params = buildUpsertParams(data, name, address, region); - params.addValue("id", existing); - namedJdbc.update(sql, params); - return existing; - } - - // Insert - String newId = UUID.randomUUID().toString().replace("-", "").substring(0, 32).toUpperCase(); - String sql = """ - INSERT INTO restaurants (id, name, address, region, latitude, longitude, - cuisine_type, price_range, google_place_id, - phone, website, business_status, rating, rating_count) - VALUES (:id, :name, :addr, :reg, :lat, :lng, :cuisine, :price, :gid, - :phone, :web, :bstatus, :rating, :rcnt) - """; - var params = buildUpsertParams(data, name, address, region); - params.addValue("id", newId); - namedJdbc.update(sql, params); - return newId; - } - - public void update(String id, Map fields) { - var sets = new ArrayList(); - var params = new MapSqlParameterSource("rid", id); - - List allowed = List.of("name", "address", "region", "cuisine_type", - "price_range", "phone", "website", "latitude", "longitude"); - for (String field : allowed) { - if (fields.containsKey(field)) { - sets.add(field + " = :" + field); - params.addValue(field, fields.get(field)); - } - } - if (sets.isEmpty()) return; - sets.add("updated_at = SYSTIMESTAMP"); - - String sql = "UPDATE restaurants SET " + String.join(", ", sets) + " WHERE id = :rid"; - namedJdbc.update(sql, params); - } - - public void delete(String id) { - var params = new MapSqlParameterSource("rid", id); - namedJdbc.update("DELETE FROM restaurant_vectors WHERE restaurant_id = :rid", params); - namedJdbc.update("DELETE FROM user_reviews WHERE restaurant_id = :rid", params); - namedJdbc.update("DELETE FROM user_favorites WHERE restaurant_id = :rid", params); - namedJdbc.update("DELETE FROM video_restaurants WHERE restaurant_id = :rid", params); - namedJdbc.update("DELETE FROM restaurants WHERE id = :rid", params); - } - - public String linkVideoRestaurant(String videoDbId, String restaurantId, - List foods, String evaluation, List guests) { - String linkId = UUID.randomUUID().toString().replace("-", "").substring(0, 32).toUpperCase(); - String sql = """ - INSERT INTO video_restaurants (id, video_id, restaurant_id, foods_mentioned, evaluation, guests) - VALUES (:id, :vid, :rid, :foods, :eval, :guests) - """; - var params = new MapSqlParameterSource(); - params.addValue("id", linkId); - params.addValue("vid", videoDbId); - params.addValue("rid", restaurantId); - params.addValue("foods", JsonUtil.toJson(foods != null ? foods : List.of())); - params.addValue("eval", JsonUtil.toJson(evaluation != null ? Map.of("text", evaluation) : Map.of())); - params.addValue("guests", JsonUtil.toJson(guests != null ? guests : List.of())); - try { - namedJdbc.update(sql, params); - return linkId; - } catch (Exception e) { - if (e.getMessage() != null && e.getMessage().toUpperCase().contains("UQ_VR_VIDEO_REST")) { - return null; // duplicate - } - throw e; - } - } - - // --- private helpers --- - - private String findIdByPlaceId(String placeId) { - var rows = namedJdbc.queryForList( - "SELECT id FROM restaurants WHERE google_place_id = :gid", - new MapSqlParameterSource("gid", placeId)); - return rows.isEmpty() ? null : (String) rows.getFirst().get("id"); - } - - private String findIdByName(String name) { - var rows = namedJdbc.queryForList( - "SELECT id FROM restaurants WHERE name = :n", - new MapSqlParameterSource("n", name)); - return rows.isEmpty() ? null : (String) rows.getFirst().get("id"); - } - - private MapSqlParameterSource buildUpsertParams(Map data, - String name, String address, String region) { - var params = new MapSqlParameterSource(); - params.addValue("name", name); - params.addValue("addr", address); - params.addValue("reg", truncateBytes(region, 100)); - params.addValue("lat", data.get("latitude")); - params.addValue("lng", data.get("longitude")); - params.addValue("cuisine", truncateBytes((String) data.get("cuisine_type"), 100)); - params.addValue("price", truncateBytes((String) data.get("price_range"), 50)); - params.addValue("gid", data.get("google_place_id")); - params.addValue("phone", data.get("phone")); - params.addValue("web", truncateBytes((String) data.get("website"), 500)); - params.addValue("bstatus", data.get("business_status")); - params.addValue("rating", data.get("rating")); - params.addValue("rcnt", data.get("rating_count")); - return params; - } - - private String truncateBytes(String val, int maxBytes) { - if (val == null) return null; - byte[] bytes = val.getBytes(java.nio.charset.StandardCharsets.UTF_8); - if (bytes.length <= maxBytes) return val; - return new String(bytes, 0, maxBytes, java.nio.charset.StandardCharsets.UTF_8).trim(); - } - - private void attachChannels(List> rows) { - List ids = rows.stream().map(r -> (String) r.get("id")).filter(Objects::nonNull).toList(); - if (ids.isEmpty()) return; - - var params = new MapSqlParameterSource(); - var placeholders = new ArrayList(); - for (int i = 0; i < ids.size(); i++) { - placeholders.add(":id" + i); - params.addValue("id" + i, ids.get(i)); - } - String sql = """ - SELECT DISTINCT vr.restaurant_id, c.channel_name - FROM video_restaurants vr - JOIN videos v ON v.id = vr.video_id - JOIN channels c ON c.id = v.channel_id - WHERE vr.restaurant_id IN (%s) - """.formatted(String.join(", ", placeholders)); - - Map> chMap = new HashMap<>(); - namedJdbc.query(sql, params, (rs) -> { - chMap.computeIfAbsent(rs.getString("RESTAURANT_ID"), k -> new ArrayList<>()) - .add(rs.getString("CHANNEL_NAME")); - }); - for (var r : rows) { - String id = (String) r.get("id"); - r.put("channels", chMap.getOrDefault(id, List.of())); - } - } - - private void attachFoodsMentioned(List> rows) { - List ids = rows.stream().map(r -> (String) r.get("id")).filter(Objects::nonNull).toList(); - if (ids.isEmpty()) return; - - var params = new MapSqlParameterSource(); - var placeholders = new ArrayList(); - for (int i = 0; i < ids.size(); i++) { - placeholders.add(":id" + i); - params.addValue("id" + i, ids.get(i)); - } - String sql = """ - SELECT vr.restaurant_id, vr.foods_mentioned - FROM video_restaurants vr - WHERE vr.restaurant_id IN (%s) - """.formatted(String.join(", ", placeholders)); - - Map> foodsMap = new HashMap<>(); - namedJdbc.query(sql, params, (rs) -> { - String rid = rs.getString("RESTAURANT_ID"); - List foods = JsonUtil.parseStringList(rs.getObject("FOODS_MENTIONED")); - for (String f : foods) { - foodsMap.computeIfAbsent(rid, k -> new ArrayList<>()); - if (!foodsMap.get(rid).contains(f)) { - foodsMap.get(rid).add(f); - } - } - }); - for (var r : rows) { - String id = (String) r.get("id"); - List all = foodsMap.getOrDefault(id, List.of()); - r.put("foods_mentioned", all.size() > 10 ? all.subList(0, 10) : all); - } - } - - private Map normalizeRow(Map row) { - // Oracle returns uppercase keys; normalize to lowercase - var result = new LinkedHashMap(); - for (var entry : row.entrySet()) { - result.put(entry.getKey().toLowerCase(), entry.getValue()); - } - return result; - } -} diff --git a/backend-java/src/main/java/com/tasteby/repository/ReviewRepository.java b/backend-java/src/main/java/com/tasteby/repository/ReviewRepository.java deleted file mode 100644 index aa28bfb..0000000 --- a/backend-java/src/main/java/com/tasteby/repository/ReviewRepository.java +++ /dev/null @@ -1,182 +0,0 @@ -package com.tasteby.repository; - -import com.tasteby.util.JsonUtil; -import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; -import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; -import org.springframework.stereotype.Repository; - -import java.sql.Date; -import java.sql.Timestamp; -import java.time.LocalDate; -import java.util.*; - -@Repository -public class ReviewRepository { - - private final NamedParameterJdbcTemplate jdbc; - - public ReviewRepository(NamedParameterJdbcTemplate jdbc) { - this.jdbc = jdbc; - } - - public Map create(String userId, String restaurantId, - double rating, String reviewText, LocalDate visitedAt) { - String id = UUID.randomUUID().toString().replace("-", "").substring(0, 32).toUpperCase(); - String sql = """ - INSERT INTO user_reviews (id, user_id, restaurant_id, rating, review_text, visited_at) - VALUES (:id, :uid, :rid, :rating, :text, :visited) - """; - var params = new MapSqlParameterSource(); - params.addValue("id", id); - params.addValue("uid", userId); - params.addValue("rid", restaurantId); - params.addValue("rating", rating); - params.addValue("text", reviewText); - params.addValue("visited", visitedAt != null ? Date.valueOf(visitedAt) : null); - jdbc.update(sql, params); - return findById(id); - } - - public Map update(String reviewId, String userId, - Double rating, String reviewText, LocalDate visitedAt) { - String sql = """ - UPDATE user_reviews SET - rating = COALESCE(:rating, rating), - review_text = COALESCE(:text, review_text), - visited_at = COALESCE(:visited, visited_at), - updated_at = SYSTIMESTAMP - WHERE id = :id AND user_id = :uid - """; - var params = new MapSqlParameterSource(); - params.addValue("rating", rating); - params.addValue("text", reviewText); - params.addValue("visited", visitedAt != null ? Date.valueOf(visitedAt) : null); - params.addValue("id", reviewId); - params.addValue("uid", userId); - int count = jdbc.update(sql, params); - return count > 0 ? findById(reviewId) : null; - } - - public boolean delete(String reviewId, String userId) { - String sql = "DELETE FROM user_reviews WHERE id = :id AND user_id = :uid"; - int count = jdbc.update(sql, new MapSqlParameterSource() - .addValue("id", reviewId).addValue("uid", userId)); - return count > 0; - } - - public Map findById(String reviewId) { - String sql = """ - SELECT r.id, r.user_id, r.restaurant_id, r.rating, r.review_text, - r.visited_at, r.created_at, r.updated_at, - u.nickname, u.avatar_url - FROM user_reviews r - JOIN tasteby_users u ON u.id = r.user_id - WHERE r.id = :id - """; - var rows = jdbc.query(sql, new MapSqlParameterSource("id", reviewId), this::mapReviewRow); - return rows.isEmpty() ? null : rows.getFirst(); - } - - public List> findByRestaurant(String restaurantId, int limit, int offset) { - String sql = """ - SELECT r.id, r.user_id, r.restaurant_id, r.rating, r.review_text, - r.visited_at, r.created_at, r.updated_at, - u.nickname, u.avatar_url - FROM user_reviews r - JOIN tasteby_users u ON u.id = r.user_id - WHERE r.restaurant_id = :rid - ORDER BY r.created_at DESC - OFFSET :off ROWS FETCH NEXT :lim ROWS ONLY - """; - var params = new MapSqlParameterSource() - .addValue("rid", restaurantId) - .addValue("off", offset).addValue("lim", limit); - return jdbc.query(sql, params, this::mapReviewRow); - } - - public Map getAvgRating(String restaurantId) { - String sql = """ - SELECT ROUND(AVG(rating), 1) AS avg_rating, COUNT(*) AS review_count - FROM user_reviews WHERE restaurant_id = :rid - """; - var row = jdbc.queryForMap(sql, new MapSqlParameterSource("rid", restaurantId)); - return Map.of( - "avg_rating", row.get("avg_rating") != null ? ((Number) row.get("avg_rating")).doubleValue() : null, - "review_count", ((Number) row.get("review_count")).intValue() - ); - } - - public List> findByUser(String userId, int limit, int offset) { - String sql = """ - SELECT r.id, r.user_id, r.restaurant_id, r.rating, r.review_text, - r.visited_at, r.created_at, r.updated_at, - u.nickname, u.avatar_url, - rest.name AS restaurant_name - FROM user_reviews r - JOIN tasteby_users u ON u.id = r.user_id - LEFT JOIN restaurants rest ON rest.id = r.restaurant_id - WHERE r.user_id = :uid - ORDER BY r.created_at DESC - OFFSET :off ROWS FETCH NEXT :lim ROWS ONLY - """; - var params = new MapSqlParameterSource() - .addValue("uid", userId).addValue("off", offset).addValue("lim", limit); - return jdbc.query(sql, params, (rs, rowNum) -> { - var m = mapReviewRow(rs, rowNum); - m.put("restaurant_name", rs.getString("RESTAURANT_NAME")); - return m; - }); - } - - // Favorites - public boolean isFavorited(String userId, String restaurantId) { - String sql = "SELECT COUNT(*) FROM user_favorites WHERE user_id = :u AND restaurant_id = :r"; - Integer cnt = jdbc.queryForObject(sql, new MapSqlParameterSource() - .addValue("u", userId).addValue("r", restaurantId), Integer.class); - return cnt != null && cnt > 0; - } - - public boolean toggleFavorite(String userId, String restaurantId) { - var params = new MapSqlParameterSource().addValue("u", userId).addValue("r", restaurantId); - String check = "SELECT id FROM user_favorites WHERE user_id = :u AND restaurant_id = :r"; - var rows = jdbc.queryForList(check, params); - if (!rows.isEmpty()) { - jdbc.update("DELETE FROM user_favorites WHERE user_id = :u AND restaurant_id = :r", params); - return false; // unfavorited - } - String id = UUID.randomUUID().toString().replace("-", "").substring(0, 32).toUpperCase(); - params.addValue("id", id); - jdbc.update("INSERT INTO user_favorites (id, user_id, restaurant_id) VALUES (:id, :u, :r)", params); - return true; // favorited - } - - public List> getUserFavorites(String userId) { - String sql = """ - SELECT r.id, r.name, r.address, r.region, r.latitude, r.longitude, - r.cuisine_type, r.price_range, r.google_place_id, - r.business_status, r.rating, r.rating_count, f.created_at - FROM user_favorites f - JOIN restaurants r ON r.id = f.restaurant_id - WHERE f.user_id = :u ORDER BY f.created_at DESC - """; - return jdbc.queryForList(sql, new MapSqlParameterSource("u", userId)); - } - - private Map mapReviewRow(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException { - var m = new LinkedHashMap(); - m.put("id", rs.getString("ID")); - m.put("user_id", rs.getString("USER_ID")); - m.put("restaurant_id", rs.getString("RESTAURANT_ID")); - m.put("rating", rs.getDouble("RATING")); - m.put("review_text", JsonUtil.readClob(rs.getObject("REVIEW_TEXT"))); - Date visited = rs.getDate("VISITED_AT"); - m.put("visited_at", visited != null ? visited.toLocalDate().toString() : null); - Timestamp created = rs.getTimestamp("CREATED_AT"); - m.put("created_at", created != null ? created.toInstant().toString() : null); - Timestamp updated = rs.getTimestamp("UPDATED_AT"); - m.put("updated_at", updated != null ? updated.toInstant().toString() : null); - m.put("user_nickname", rs.getString("NICKNAME")); - m.put("user_avatar_url", rs.getString("AVATAR_URL")); - return m; - } -} diff --git a/backend-java/src/main/java/com/tasteby/repository/StatsRepository.java b/backend-java/src/main/java/com/tasteby/repository/StatsRepository.java deleted file mode 100644 index 3d4ecb2..0000000 --- a/backend-java/src/main/java/com/tasteby/repository/StatsRepository.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.tasteby.repository; - -import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; -import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; -import org.springframework.stereotype.Repository; - -import java.util.Map; - -@Repository -public class StatsRepository { - - private final NamedParameterJdbcTemplate jdbc; - - public StatsRepository(NamedParameterJdbcTemplate jdbc) { - this.jdbc = jdbc; - } - - public void recordVisit() { - String sql = """ - MERGE INTO site_visits sv - USING (SELECT TRUNC(SYSDATE) AS d FROM dual) src - ON (sv.visit_date = src.d) - WHEN MATCHED THEN UPDATE SET sv.visit_count = sv.visit_count + 1 - WHEN NOT MATCHED THEN INSERT (visit_date, visit_count) VALUES (src.d, 1) - """; - jdbc.update(sql, new MapSqlParameterSource()); - } - - public Map getVisits() { - var empty = new MapSqlParameterSource(); - Integer today = jdbc.queryForObject( - "SELECT NVL(visit_count, 0) FROM site_visits WHERE visit_date = TRUNC(SYSDATE)", - empty, Integer.class); - if (today == null) today = 0; - - Integer total = jdbc.queryForObject( - "SELECT NVL(SUM(visit_count), 0) FROM site_visits", - empty, Integer.class); - if (total == null) total = 0; - - return Map.of("today", today, "total", total); - } -} diff --git a/backend-java/src/main/java/com/tasteby/repository/UserRepository.java b/backend-java/src/main/java/com/tasteby/repository/UserRepository.java deleted file mode 100644 index c63e2ce..0000000 --- a/backend-java/src/main/java/com/tasteby/repository/UserRepository.java +++ /dev/null @@ -1,101 +0,0 @@ -package com.tasteby.repository; - -import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; -import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; -import org.springframework.stereotype.Repository; - -import java.util.*; - -@Repository -public class UserRepository { - - private final NamedParameterJdbcTemplate jdbc; - - public UserRepository(NamedParameterJdbcTemplate jdbc) { - this.jdbc = jdbc; - } - - public Map findOrCreate(String provider, String providerId, - String email, String nickname, String avatarUrl) { - // Try find existing - String findSql = """ - SELECT id, email, nickname, avatar_url, is_admin - FROM tasteby_users - WHERE provider = :provider AND provider_id = :pid - """; - var params = new MapSqlParameterSource() - .addValue("provider", provider) - .addValue("pid", providerId); - var rows = jdbc.queryForList(findSql, params); - - if (!rows.isEmpty()) { - // Update last_login_at - var row = rows.getFirst(); - String userId = (String) row.get("id"); - jdbc.update("UPDATE tasteby_users SET last_login_at = SYSTIMESTAMP WHERE id = :id", - new MapSqlParameterSource("id", userId)); - return Map.of( - "id", userId, - "email", row.getOrDefault("email", ""), - "nickname", row.getOrDefault("nickname", ""), - "avatar_url", row.getOrDefault("avatar_url", ""), - "is_admin", row.get("is_admin") != null && ((Number) row.get("is_admin")).intValue() == 1 - ); - } - - // Create new user - String id = UUID.randomUUID().toString().replace("-", "").substring(0, 32).toUpperCase(); - String insertSql = """ - INSERT INTO tasteby_users (id, provider, provider_id, email, nickname, avatar_url) - VALUES (:id, :provider, :pid, :email, :nick, :avatar) - """; - params.addValue("id", id); - params.addValue("email", email); - params.addValue("nick", nickname); - params.addValue("avatar", avatarUrl); - jdbc.update(insertSql, params); - - return Map.of( - "id", id, - "email", email != null ? email : "", - "nickname", nickname != null ? nickname : "", - "avatar_url", avatarUrl != null ? avatarUrl : "", - "is_admin", false - ); - } - - public Map findById(String userId) { - String sql = "SELECT id, email, nickname, avatar_url, is_admin FROM tasteby_users WHERE id = :id"; - var rows = jdbc.queryForList(sql, new MapSqlParameterSource("id", userId)); - if (rows.isEmpty()) return null; - var row = rows.getFirst(); - return Map.of( - "id", row.get("id"), - "email", row.getOrDefault("email", ""), - "nickname", row.getOrDefault("nickname", ""), - "avatar_url", row.getOrDefault("avatar_url", ""), - "is_admin", row.get("is_admin") != null && ((Number) row.get("is_admin")).intValue() == 1 - ); - } - - public List> findAllWithCounts(int limit, int offset) { - String sql = """ - SELECT u.id, u.email, u.nickname, u.avatar_url, u.provider, u.created_at, - NVL(fav.cnt, 0) AS favorite_count, - NVL(rev.cnt, 0) AS review_count - FROM tasteby_users u - LEFT JOIN (SELECT user_id, COUNT(*) AS cnt FROM user_favorites GROUP BY user_id) fav ON fav.user_id = u.id - LEFT JOIN (SELECT user_id, COUNT(*) AS cnt FROM user_reviews GROUP BY user_id) rev ON rev.user_id = u.id - ORDER BY u.created_at DESC - OFFSET :off ROWS FETCH NEXT :lim ROWS ONLY - """; - var params = new MapSqlParameterSource().addValue("off", offset).addValue("lim", limit); - return jdbc.queryForList(sql, params); - } - - public int countAll() { - var result = jdbc.queryForObject("SELECT COUNT(*) FROM tasteby_users", - new MapSqlParameterSource(), Integer.class); - return result != null ? result : 0; - } -} diff --git a/backend-java/src/main/java/com/tasteby/repository/VideoRepository.java b/backend-java/src/main/java/com/tasteby/repository/VideoRepository.java deleted file mode 100644 index 3d5999a..0000000 --- a/backend-java/src/main/java/com/tasteby/repository/VideoRepository.java +++ /dev/null @@ -1,220 +0,0 @@ -package com.tasteby.repository; - -import com.tasteby.util.JsonUtil; -import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; -import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; -import org.springframework.stereotype.Repository; - -import java.sql.Timestamp; -import java.util.*; - -@Repository -public class VideoRepository { - - private final NamedParameterJdbcTemplate jdbc; - - public VideoRepository(NamedParameterJdbcTemplate jdbc) { - this.jdbc = jdbc; - } - - public List> findAll(String status) { - var params = new MapSqlParameterSource(); - String where = ""; - if (status != null && !status.isBlank()) { - where = "WHERE v.status = :st"; - params.addValue("st", status); - } - - String sql = """ - SELECT v.id, v.video_id, v.title, v.url, v.status, - v.published_at, c.channel_name, - CASE WHEN v.transcript_text IS NOT NULL AND dbms_lob.getlength(v.transcript_text) > 0 THEN 1 ELSE 0 END as has_transcript, - CASE WHEN v.llm_raw_response IS NOT NULL AND dbms_lob.getlength(v.llm_raw_response) > 0 THEN 1 ELSE 0 END as has_llm, - (SELECT COUNT(*) FROM video_restaurants vr WHERE vr.video_id = v.id) as restaurant_count, - (SELECT COUNT(*) FROM video_restaurants vr JOIN restaurants r ON r.id = vr.restaurant_id - WHERE vr.video_id = v.id AND r.google_place_id IS NOT NULL) as matched_count - FROM videos v - JOIN channels c ON c.id = v.channel_id - %s - ORDER BY v.published_at DESC NULLS LAST - """.formatted(where); - - return jdbc.query(sql, params, (rs, rowNum) -> { - Map m = new LinkedHashMap<>(); - m.put("id", rs.getString("ID")); - m.put("video_id", rs.getString("VIDEO_ID")); - m.put("title", rs.getString("TITLE")); - m.put("url", rs.getString("URL")); - m.put("status", rs.getString("STATUS")); - Timestamp ts = rs.getTimestamp("PUBLISHED_AT"); - m.put("published_at", ts != null ? ts.toInstant().toString() : null); - m.put("channel_name", rs.getString("CHANNEL_NAME")); - m.put("has_transcript", rs.getInt("HAS_TRANSCRIPT") == 1); - m.put("has_llm", rs.getInt("HAS_LLM") == 1); - m.put("restaurant_count", rs.getInt("RESTAURANT_COUNT")); - m.put("matched_count", rs.getInt("MATCHED_COUNT")); - return m; - }); - } - - public Map findDetail(String videoDbId) { - String sql = """ - SELECT v.id, v.video_id, v.title, v.url, v.status, - v.published_at, v.transcript_text, c.channel_name - FROM videos v - JOIN channels c ON c.id = v.channel_id - WHERE v.id = :vid - """; - var rows = jdbc.query(sql, new MapSqlParameterSource("vid", videoDbId), (rs, rowNum) -> { - Map m = new LinkedHashMap<>(); - m.put("id", rs.getString("ID")); - m.put("video_id", rs.getString("VIDEO_ID")); - m.put("title", rs.getString("TITLE")); - m.put("url", rs.getString("URL")); - m.put("status", rs.getString("STATUS")); - Timestamp ts = rs.getTimestamp("PUBLISHED_AT"); - m.put("published_at", ts != null ? ts.toInstant().toString() : null); - m.put("transcript", JsonUtil.readClob(rs.getObject("TRANSCRIPT_TEXT"))); - m.put("channel_name", rs.getString("CHANNEL_NAME")); - return m; - }); - if (rows.isEmpty()) return null; - - Map video = rows.getFirst(); - - // Attach extracted restaurants - String restSql = """ - SELECT r.id, r.name, r.address, r.cuisine_type, r.price_range, r.region, - vr.foods_mentioned, vr.evaluation, vr.guests, - r.google_place_id, r.latitude, r.longitude - FROM video_restaurants vr - JOIN restaurants r ON r.id = vr.restaurant_id - WHERE vr.video_id = :vid - """; - List> restaurants = jdbc.query(restSql, - new MapSqlParameterSource("vid", videoDbId), (rs, rowNum) -> { - Map m = new LinkedHashMap<>(); - m.put("restaurant_id", rs.getString("ID")); - m.put("name", rs.getString("NAME")); - m.put("address", rs.getString("ADDRESS")); - m.put("cuisine_type", rs.getString("CUISINE_TYPE")); - m.put("price_range", rs.getString("PRICE_RANGE")); - m.put("region", rs.getString("REGION")); - m.put("foods_mentioned", JsonUtil.parseStringList(rs.getObject("FOODS_MENTIONED"))); - m.put("evaluation", JsonUtil.parseMap(rs.getObject("EVALUATION"))); - m.put("guests", JsonUtil.parseStringList(rs.getObject("GUESTS"))); - m.put("google_place_id", rs.getString("GOOGLE_PLACE_ID")); - m.put("has_location", rs.getObject("LATITUDE") != null && rs.getObject("LONGITUDE") != null); - return m; - }); - video.put("restaurants", restaurants); - return video; - } - - public void updateStatus(String videoDbId, String status) { - jdbc.update("UPDATE videos SET status = :st WHERE id = :vid", - new MapSqlParameterSource().addValue("st", status).addValue("vid", videoDbId)); - } - - public void updateTitle(String videoDbId, String title) { - jdbc.update("UPDATE videos SET title = :title WHERE id = :vid", - new MapSqlParameterSource().addValue("title", title).addValue("vid", videoDbId)); - } - - public void updateTranscript(String videoDbId, String transcript) { - jdbc.update("UPDATE videos SET transcript_text = :txt WHERE id = :vid", - new MapSqlParameterSource().addValue("txt", transcript).addValue("vid", videoDbId)); - } - - public void delete(String videoDbId) { - var params = new MapSqlParameterSource("vid", videoDbId); - // Delete orphaned vectors/reviews/restaurants - jdbc.update(""" - DELETE FROM restaurant_vectors WHERE restaurant_id IN ( - SELECT vr.restaurant_id FROM video_restaurants vr - WHERE vr.video_id = :vid - AND NOT EXISTS (SELECT 1 FROM video_restaurants vr2 - WHERE vr2.restaurant_id = vr.restaurant_id AND vr2.video_id != :vid) - )""", params); - jdbc.update(""" - DELETE FROM user_reviews WHERE restaurant_id IN ( - SELECT vr.restaurant_id FROM video_restaurants vr - WHERE vr.video_id = :vid - AND NOT EXISTS (SELECT 1 FROM video_restaurants vr2 - WHERE vr2.restaurant_id = vr.restaurant_id AND vr2.video_id != :vid) - )""", params); - jdbc.update(""" - DELETE FROM restaurants WHERE id IN ( - SELECT vr.restaurant_id FROM video_restaurants vr - WHERE vr.video_id = :vid - AND NOT EXISTS (SELECT 1 FROM video_restaurants vr2 - WHERE vr2.restaurant_id = vr.restaurant_id AND vr2.video_id != :vid) - )""", params); - jdbc.update("DELETE FROM video_restaurants WHERE video_id = :vid", params); - jdbc.update("DELETE FROM videos WHERE id = :vid", params); - } - - public void deleteVideoRestaurant(String videoDbId, String restaurantId) { - var params = new MapSqlParameterSource().addValue("vid", videoDbId).addValue("rid", restaurantId); - jdbc.update("DELETE FROM video_restaurants WHERE video_id = :vid AND restaurant_id = :rid", params); - // Clean up orphan - var ridParams = new MapSqlParameterSource("rid", restaurantId); - jdbc.update(""" - DELETE FROM restaurant_vectors WHERE restaurant_id = :rid - AND NOT EXISTS (SELECT 1 FROM video_restaurants WHERE restaurant_id = :rid) - """, ridParams); - jdbc.update(""" - DELETE FROM user_reviews WHERE restaurant_id = :rid - AND NOT EXISTS (SELECT 1 FROM video_restaurants WHERE restaurant_id = :rid) - """, ridParams); - jdbc.update(""" - DELETE FROM restaurants WHERE id = :rid - AND NOT EXISTS (SELECT 1 FROM video_restaurants WHERE restaurant_id = :rid) - """, ridParams); - } - - public int saveVideosBatch(String dbChannelId, List> videos) { - if (videos.isEmpty()) return 0; - int count = 0; - for (var v : videos) { - String id = UUID.randomUUID().toString().replace("-", "").substring(0, 32).toUpperCase(); - String sql = """ - INSERT INTO videos (id, channel_id, video_id, title, url, published_at) - VALUES (:id, :cid, :vid, :title, :url, :pub) - """; - var params = new MapSqlParameterSource(); - params.addValue("id", id); - params.addValue("cid", dbChannelId); - params.addValue("vid", v.get("video_id")); - params.addValue("title", v.get("title")); - params.addValue("url", v.get("url")); - params.addValue("pub", v.get("published_at")); - try { - jdbc.update(sql, params); - count++; - } catch (Exception e) { - // duplicate video_id — skip - if (e.getMessage() == null || !e.getMessage().toUpperCase().contains("UQ_VIDEOS_VID")) { - throw e; - } - } - } - return count; - } - - public Set getExistingVideoIds(String dbChannelId) { - String sql = "SELECT video_id FROM videos WHERE channel_id = :cid"; - List ids = jdbc.queryForList(sql, - new MapSqlParameterSource("cid", dbChannelId), String.class); - return new HashSet<>(ids); - } - - public String getLatestVideoDate(String dbChannelId) { - String sql = "SELECT MAX(published_at) FROM videos WHERE channel_id = :cid"; - var rows = jdbc.queryForList(sql, new MapSqlParameterSource("cid", dbChannelId)); - if (rows.isEmpty() || rows.getFirst().values().iterator().next() == null) return null; - Object val = rows.getFirst().values().iterator().next(); - if (val instanceof Timestamp ts) return ts.toInstant().toString(); - return val.toString(); - } -} diff --git a/backend-java/src/main/java/com/tasteby/service/AuthService.java b/backend-java/src/main/java/com/tasteby/service/AuthService.java index 17a6344..89d0cc6 100644 --- a/backend-java/src/main/java/com/tasteby/service/AuthService.java +++ b/backend-java/src/main/java/com/tasteby/service/AuthService.java @@ -4,7 +4,7 @@ import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken; import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier; import com.google.api.client.http.javanet.NetHttpTransport; import com.google.api.client.json.gson.GsonFactory; -import com.tasteby.repository.UserRepository; +import com.tasteby.domain.UserInfo; import com.tasteby.security.JwtTokenProvider; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; @@ -16,12 +16,12 @@ import java.util.Map; @Service public class AuthService { - private final UserRepository userRepo; + private final UserService userService; private final JwtTokenProvider jwtProvider; private final GoogleIdTokenVerifier verifier; - public AuthService(UserRepository userRepo, JwtTokenProvider jwtProvider) { - this.userRepo = userRepo; + public AuthService(UserService userService, JwtTokenProvider jwtProvider) { + this.userService = userService; this.jwtProvider = jwtProvider; this.verifier = new GoogleIdTokenVerifier.Builder( new NetHttpTransport(), GsonFactory.getDefaultInstance()) @@ -37,15 +37,21 @@ public class AuthService { } GoogleIdToken.Payload payload = idToken.getPayload(); - Map user = userRepo.findOrCreate( + UserInfo user = userService.findOrCreate( "google", payload.getSubject(), payload.getEmail(), (String) payload.get("name"), - (String) payload.get("picture") - ); + (String) payload.get("picture")); - String accessToken = jwtProvider.createToken(user); + // Convert to Map for JWT + Map userMap = Map.of( + "id", user.getId(), + "email", user.getEmail() != null ? user.getEmail() : "", + "nickname", user.getNickname() != null ? user.getNickname() : "", + "is_admin", user.isAdmin() + ); + String accessToken = jwtProvider.createToken(userMap); return Map.of("access_token", accessToken, "user", user); } catch (ResponseStatusException e) { throw e; @@ -54,8 +60,8 @@ public class AuthService { } } - public Map getCurrentUser(String userId) { - Map user = userRepo.findById(userId); + public UserInfo getCurrentUser(String userId) { + UserInfo user = userService.findById(userId); if (user == null) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found"); } diff --git a/backend-java/src/main/java/com/tasteby/service/ChannelService.java b/backend-java/src/main/java/com/tasteby/service/ChannelService.java new file mode 100644 index 0000000..599239c --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/service/ChannelService.java @@ -0,0 +1,41 @@ +package com.tasteby.service; + +import com.tasteby.domain.Channel; +import com.tasteby.mapper.ChannelMapper; +import com.tasteby.util.IdGenerator; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class ChannelService { + + private final ChannelMapper mapper; + + public ChannelService(ChannelMapper mapper) { + this.mapper = mapper; + } + + public List findAllActive() { + return mapper.findAllActive(); + } + + public String create(String channelId, String channelName, String titleFilter) { + String id = IdGenerator.newId(); + mapper.insert(id, channelId, channelName, titleFilter); + return id; + } + + public boolean deactivate(String channelId) { + // Try deactivate by channel_id first, then by DB id + int rows = mapper.deactivateByChannelId(channelId); + if (rows == 0) { + rows = mapper.deactivateById(channelId); + } + return rows > 0; + } + + public Channel findByChannelId(String channelId) { + return mapper.findByChannelId(channelId); + } +} diff --git a/backend-java/src/main/java/com/tasteby/service/DaemonConfigService.java b/backend-java/src/main/java/com/tasteby/service/DaemonConfigService.java new file mode 100644 index 0000000..4c5c31a --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/service/DaemonConfigService.java @@ -0,0 +1,51 @@ +package com.tasteby.service; + +import com.tasteby.domain.DaemonConfig; +import com.tasteby.mapper.DaemonConfigMapper; +import org.springframework.stereotype.Service; + +import java.util.Map; + +@Service +public class DaemonConfigService { + + private final DaemonConfigMapper mapper; + + public DaemonConfigService(DaemonConfigMapper mapper) { + this.mapper = mapper; + } + + public DaemonConfig getConfig() { + return mapper.getConfig(); + } + + public void updateConfig(Map body) { + DaemonConfig current = mapper.getConfig(); + if (current == null) return; + + if (body.containsKey("scan_enabled")) { + current.setScanEnabled(Boolean.TRUE.equals(body.get("scan_enabled"))); + } + if (body.containsKey("scan_interval_min")) { + current.setScanIntervalMin(((Number) body.get("scan_interval_min")).intValue()); + } + if (body.containsKey("process_enabled")) { + current.setProcessEnabled(Boolean.TRUE.equals(body.get("process_enabled"))); + } + if (body.containsKey("process_interval_min")) { + current.setProcessIntervalMin(((Number) body.get("process_interval_min")).intValue()); + } + if (body.containsKey("process_limit")) { + current.setProcessLimit(((Number) body.get("process_limit")).intValue()); + } + mapper.updateConfig(current); + } + + public void updateLastScan() { + mapper.updateLastScan(); + } + + public void updateLastProcess() { + mapper.updateLastProcess(); + } +} diff --git a/backend-java/src/main/java/com/tasteby/service/DaemonScheduler.java b/backend-java/src/main/java/com/tasteby/service/DaemonScheduler.java index 8890e8a..d8a9ac3 100644 --- a/backend-java/src/main/java/com/tasteby/service/DaemonScheduler.java +++ b/backend-java/src/main/java/com/tasteby/service/DaemonScheduler.java @@ -1,9 +1,8 @@ package com.tasteby.service; +import com.tasteby.domain.DaemonConfig; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; -import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; @@ -18,16 +17,16 @@ public class DaemonScheduler { private static final Logger log = LoggerFactory.getLogger(DaemonScheduler.class); - private final NamedParameterJdbcTemplate jdbc; + private final DaemonConfigService daemonConfigService; private final YouTubeService youTubeService; private final PipelineService pipelineService; private final CacheService cacheService; - public DaemonScheduler(NamedParameterJdbcTemplate jdbc, + public DaemonScheduler(DaemonConfigService daemonConfigService, YouTubeService youTubeService, PipelineService pipelineService, CacheService cacheService) { - this.jdbc = jdbc; + this.daemonConfigService = daemonConfigService; this.youTubeService = youTubeService; this.pipelineService = pipelineService; this.cacheService = cacheService; @@ -39,13 +38,12 @@ public class DaemonScheduler { var config = getConfig(); if (config == null) return; - // Channel scanning - if (config.scanEnabled) { - Instant lastScan = config.lastScanAt; - if (lastScan == null || Instant.now().isAfter(lastScan.plus(config.scanIntervalMin, ChronoUnit.MINUTES))) { + if (config.isScanEnabled()) { + Instant lastScan = config.getLastScanAt() != null ? config.getLastScanAt().toInstant() : null; + if (lastScan == null || Instant.now().isAfter(lastScan.plus(config.getScanIntervalMin(), ChronoUnit.MINUTES))) { log.info("Running scheduled channel scan..."); int newVideos = youTubeService.scanAllChannels(); - updateLastScan(); + daemonConfigService.updateLastScan(); if (newVideos > 0) { cacheService.flush(); log.info("Scan completed: {} new videos", newVideos); @@ -53,13 +51,12 @@ public class DaemonScheduler { } } - // Video processing - if (config.processEnabled) { - Instant lastProcess = config.lastProcessAt; - if (lastProcess == null || Instant.now().isAfter(lastProcess.plus(config.processIntervalMin, ChronoUnit.MINUTES))) { - log.info("Running scheduled video processing (limit={})...", config.processLimit); - int restaurants = pipelineService.processPending(config.processLimit); - updateLastProcess(); + if (config.isProcessEnabled()) { + Instant lastProcess = config.getLastProcessAt() != null ? config.getLastProcessAt().toInstant() : null; + if (lastProcess == null || Instant.now().isAfter(lastProcess.plus(config.getProcessIntervalMin(), ChronoUnit.MINUTES))) { + log.info("Running scheduled video processing (limit={})...", config.getProcessLimit()); + int restaurants = pipelineService.processPending(config.getProcessLimit()); + daemonConfigService.updateLastProcess(); if (restaurants > 0) { cacheService.flush(); log.info("Processing completed: {} restaurants extracted", restaurants); @@ -71,45 +68,12 @@ public class DaemonScheduler { } } - private record DaemonConfig( - boolean scanEnabled, int scanIntervalMin, - boolean processEnabled, int processIntervalMin, int processLimit, - Instant lastScanAt, Instant lastProcessAt) {} - private DaemonConfig getConfig() { try { - var rows = jdbc.queryForList( - "SELECT * FROM daemon_config WHERE id = 1", - new MapSqlParameterSource()); - if (rows.isEmpty()) return null; - var row = rows.getFirst(); - return new DaemonConfig( - toInt(row.get("scan_enabled")) == 1, - toInt(row.get("scan_interval_min")), - toInt(row.get("process_enabled")) == 1, - toInt(row.get("process_interval_min")), - toInt(row.get("process_limit")), - row.get("last_scan_at") instanceof java.sql.Timestamp ts ? ts.toInstant() : null, - row.get("last_process_at") instanceof java.sql.Timestamp ts ? ts.toInstant() : null - ); + return daemonConfigService.getConfig(); } catch (Exception e) { log.debug("Cannot read daemon config: {}", e.getMessage()); return null; } } - - private void updateLastScan() { - jdbc.update("UPDATE daemon_config SET last_scan_at = SYSTIMESTAMP WHERE id = 1", - new MapSqlParameterSource()); - } - - private void updateLastProcess() { - jdbc.update("UPDATE daemon_config SET last_process_at = SYSTIMESTAMP WHERE id = 1", - new MapSqlParameterSource()); - } - - private int toInt(Object val) { - if (val == null) return 0; - return ((Number) val).intValue(); - } } diff --git a/backend-java/src/main/java/com/tasteby/service/PipelineService.java b/backend-java/src/main/java/com/tasteby/service/PipelineService.java index 937eb0e..cb9ee84 100644 --- a/backend-java/src/main/java/com/tasteby/service/PipelineService.java +++ b/backend-java/src/main/java/com/tasteby/service/PipelineService.java @@ -1,10 +1,7 @@ package com.tasteby.service; -import com.tasteby.repository.RestaurantRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; -import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.stereotype.Service; import java.util.HashMap; @@ -23,26 +20,26 @@ public class PipelineService { private static final Logger log = LoggerFactory.getLogger(PipelineService.class); - private final NamedParameterJdbcTemplate jdbc; private final YouTubeService youTubeService; private final ExtractorService extractorService; private final GeocodingService geocodingService; - private final RestaurantRepository restaurantRepo; + private final RestaurantService restaurantService; + private final VideoService videoService; private final VectorService vectorService; private final CacheService cacheService; - public PipelineService(NamedParameterJdbcTemplate jdbc, - YouTubeService youTubeService, + public PipelineService(YouTubeService youTubeService, ExtractorService extractorService, GeocodingService geocodingService, - RestaurantRepository restaurantRepo, + RestaurantService restaurantService, + VideoService videoService, VectorService vectorService, CacheService cacheService) { - this.jdbc = jdbc; this.youTubeService = youTubeService; this.extractorService = extractorService; this.geocodingService = geocodingService; - this.restaurantRepo = restaurantRepo; + this.restaurantService = restaurantService; + this.videoService = videoService; this.vectorService = vectorService; this.cacheService = cacheService; } @@ -117,13 +114,13 @@ public class PipelineService { data.put("rating", geo != null ? geo.get("rating") : null); data.put("rating_count", geo != null ? geo.get("rating_count") : null); - String restId = restaurantRepo.upsert(data); + String restId = restaurantService.upsert(data); // Link video <-> restaurant var foods = restData.get("foods_mentioned"); var evaluation = restData.get("evaluation"); var guests = restData.get("guests"); - restaurantRepo.linkVideoRestaurant( + restaurantService.linkVideoRestaurant( videoDbId, restId, foods instanceof List ? (List) foods : null, evaluation instanceof String ? (String) evaluation : null, @@ -153,46 +150,20 @@ public class PipelineService { * Process up to `limit` pending videos. */ public int processPending(int limit) { - String sql = """ - SELECT id, video_id, title, url FROM videos - WHERE status = 'pending' ORDER BY created_at - FETCH FIRST :n ROWS ONLY - """; - var videos = jdbc.queryForList(sql, new MapSqlParameterSource("n", limit)); + var videos = videoService.findPendingVideos(limit); if (videos.isEmpty()) { log.info("No pending videos"); return 0; } int total = 0; for (var v : videos) { - // Normalize Oracle uppercase keys - var normalized = normalizeKeys(v); - total += processVideo(normalized); + total += processVideo(v); } if (total > 0) cacheService.flush(); return total; } private void updateVideoStatus(String videoDbId, String status, String transcript, String llmRaw) { - var sets = new java.util.ArrayList<>(List.of("status = :st", "processed_at = SYSTIMESTAMP")); - var params = new MapSqlParameterSource().addValue("st", status).addValue("vid", videoDbId); - if (transcript != null) { - sets.add("transcript_text = :txt"); - params.addValue("txt", transcript); - } - if (llmRaw != null) { - sets.add("llm_raw_response = :llm"); - params.addValue("llm", llmRaw); - } - String sql = "UPDATE videos SET " + String.join(", ", sets) + " WHERE id = :vid"; - jdbc.update(sql, params); - } - - private Map normalizeKeys(Map row) { - var result = new HashMap(); - for (var entry : row.entrySet()) { - result.put(entry.getKey().toLowerCase(), entry.getValue()); - } - return result; + videoService.updateVideoFields(videoDbId, status, transcript, llmRaw); } } diff --git a/backend-java/src/main/java/com/tasteby/service/RestaurantService.java b/backend-java/src/main/java/com/tasteby/service/RestaurantService.java new file mode 100644 index 0000000..8ac2aca --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/service/RestaurantService.java @@ -0,0 +1,156 @@ +package com.tasteby.service; + +import com.tasteby.domain.Restaurant; +import com.tasteby.mapper.RestaurantMapper; +import com.tasteby.util.IdGenerator; +import com.tasteby.util.JsonUtil; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.stream.Collectors; + +@Service +public class RestaurantService { + + private final RestaurantMapper mapper; + + public RestaurantService(RestaurantMapper mapper) { + this.mapper = mapper; + } + + public List findAll(int limit, int offset, String cuisine, String region, String channel) { + List restaurants = mapper.findAll(limit, offset, cuisine, region, channel); + enrichRestaurants(restaurants); + return restaurants; + } + + public Restaurant findById(String id) { + Restaurant restaurant = mapper.findById(id); + if (restaurant == null) return null; + enrichRestaurants(List.of(restaurant)); + return restaurant; + } + + public List> findVideoLinks(String restaurantId) { + var rows = mapper.findVideoLinks(restaurantId); + return rows.stream().map(JsonUtil::lowerKeys).toList(); + } + + public void update(String id, Map fields) { + mapper.updateFields(id, fields); + } + + @Transactional + public void delete(String id) { + mapper.deleteVectors(id); + mapper.deleteReviews(id); + mapper.deleteFavorites(id); + mapper.deleteVideoRestaurants(id); + mapper.deleteRestaurant(id); + } + + public String upsert(Map data) { + String placeId = (String) data.get("google_place_id"); + String existingId = null; + if (placeId != null && !placeId.isBlank()) { + existingId = mapper.findIdByPlaceId(placeId); + } + if (existingId == null) { + existingId = mapper.findIdByName((String) data.get("name")); + } + + Restaurant r = Restaurant.builder() + .name(truncateBytes((String) data.get("name"), 200)) + .address(truncateBytes((String) data.get("address"), 500)) + .region((String) data.get("region")) + .latitude(data.get("latitude") instanceof Number n ? n.doubleValue() : null) + .longitude(data.get("longitude") instanceof Number n ? n.doubleValue() : null) + .cuisineType((String) data.get("cuisine_type")) + .priceRange((String) data.get("price_range")) + .googlePlaceId(placeId) + .phone((String) data.get("phone")) + .website((String) data.get("website")) + .businessStatus((String) data.get("business_status")) + .rating(data.get("rating") instanceof Number n ? n.doubleValue() : null) + .ratingCount(data.get("rating_count") instanceof Number n ? n.intValue() : null) + .build(); + + if (existingId != null) { + r.setId(existingId); + mapper.updateRestaurant(r); + return existingId; + } else { + String newId = IdGenerator.newId(); + r.setId(newId); + mapper.insertRestaurant(r); + return newId; + } + } + + public void linkVideoRestaurant(String videoId, String restaurantId, List foods, String evaluation, List guests) { + String id = IdGenerator.newId(); + String foodsJson = foods != null ? JsonUtil.toJson(foods) : null; + String guestsJson = guests != null ? JsonUtil.toJson(guests) : null; + mapper.linkVideoRestaurant(id, videoId, restaurantId, foodsJson, evaluation, guestsJson); + } + + public void updateCuisineType(String id, String cuisineType) { + mapper.updateCuisineType(id, cuisineType); + } + + public void updateFoodsMentioned(String id, String foods) { + mapper.updateFoodsMentioned(id, foods); + } + + public List> findForRemapCuisine() { + return mapper.findForRemapCuisine(); + } + + public List> findForRemapFoods() { + return mapper.findForRemapFoods(); + } + + private void enrichRestaurants(List restaurants) { + if (restaurants.isEmpty()) return; + List ids = restaurants.stream().map(Restaurant::getId).filter(Objects::nonNull).toList(); + if (ids.isEmpty()) return; + + // Channels + List> channelRows = mapper.findChannelsByRestaurantIds(ids); + Map> channelMap = new HashMap<>(); + for (var row : channelRows) { + String rid = (String) row.getOrDefault("restaurant_id", row.get("RESTAURANT_ID")); + String ch = (String) row.getOrDefault("channel_name", row.get("CHANNEL_NAME")); + if (rid != null && ch != null) { + channelMap.computeIfAbsent(rid, k -> new ArrayList<>()).add(ch); + } + } + + // Foods + List> foodRows = mapper.findFoodsByRestaurantIds(ids); + Map> foodMap = new HashMap<>(); + for (var row : foodRows) { + String rid = (String) row.getOrDefault("restaurant_id", row.get("RESTAURANT_ID")); + Object foods = row.getOrDefault("foods_mentioned", row.get("FOODS_MENTIONED")); + if (rid != null && foods != null) { + List parsed = JsonUtil.parseStringList(foods); + foodMap.computeIfAbsent(rid, k -> new LinkedHashSet<>()).addAll(parsed); + } + } + + for (var r : restaurants) { + r.setChannels(channelMap.getOrDefault(r.getId(), List.of())); + Set foods = foodMap.get(r.getId()); + r.setFoodsMentioned(foods != null ? new ArrayList<>(foods) : List.of()); + } + } + + private String truncateBytes(String s, int maxBytes) { + if (s == null) return null; + byte[] bytes = s.getBytes(StandardCharsets.UTF_8); + if (bytes.length <= maxBytes) return s; + return new String(bytes, 0, maxBytes, StandardCharsets.UTF_8); + } +} diff --git a/backend-java/src/main/java/com/tasteby/service/ReviewService.java b/backend-java/src/main/java/com/tasteby/service/ReviewService.java new file mode 100644 index 0000000..a95754b --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/service/ReviewService.java @@ -0,0 +1,72 @@ +package com.tasteby.service; + +import com.tasteby.domain.Restaurant; +import com.tasteby.domain.Review; +import com.tasteby.mapper.ReviewMapper; +import com.tasteby.util.IdGenerator; +import com.tasteby.util.JsonUtil; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.*; + +@Service +public class ReviewService { + + private final ReviewMapper mapper; + + public ReviewService(ReviewMapper mapper) { + this.mapper = mapper; + } + + public List findByRestaurant(String restaurantId, int limit, int offset) { + return mapper.findByRestaurant(restaurantId, limit, offset); + } + + public Map getAvgRating(String restaurantId) { + Map result = mapper.getAvgRating(restaurantId); + return result != null ? JsonUtil.lowerKeys(result) : Map.of("avg_rating", 0.0, "review_count", 0); + } + + @Transactional + public Review create(String userId, String restaurantId, double rating, String reviewText, LocalDate visitedAt) { + String id = IdGenerator.newId(); + String visitedStr = visitedAt != null ? visitedAt.toString() : null; + mapper.insertReview(id, userId, restaurantId, rating, reviewText, visitedStr); + return mapper.findById(id); + } + + public boolean update(String reviewId, String userId, Double rating, String reviewText, LocalDate visitedAt) { + String visitedStr = visitedAt != null ? visitedAt.toString() : null; + return mapper.updateReview(reviewId, userId, rating, reviewText, visitedStr) > 0; + } + + public boolean delete(String reviewId, String userId) { + return mapper.deleteReview(reviewId, userId) > 0; + } + + public List findByUser(String userId, int limit, int offset) { + return mapper.findByUser(userId, limit, offset); + } + + public boolean isFavorited(String userId, String restaurantId) { + return mapper.countFavorite(userId, restaurantId) > 0; + } + + @Transactional + public boolean toggleFavorite(String userId, String restaurantId) { + String existingId = mapper.findFavoriteId(userId, restaurantId); + if (existingId != null) { + mapper.deleteFavorite(userId, restaurantId); + return false; + } else { + mapper.insertFavorite(IdGenerator.newId(), userId, restaurantId); + return true; + } + } + + public List getUserFavorites(String userId) { + return mapper.getUserFavorites(userId); + } +} diff --git a/backend-java/src/main/java/com/tasteby/service/SearchService.java b/backend-java/src/main/java/com/tasteby/service/SearchService.java index b246f37..93777eb 100644 --- a/backend-java/src/main/java/com/tasteby/service/SearchService.java +++ b/backend-java/src/main/java/com/tasteby/service/SearchService.java @@ -1,10 +1,9 @@ package com.tasteby.service; -import com.tasteby.repository.RestaurantRepository; +import com.tasteby.domain.Restaurant; +import com.tasteby.mapper.SearchMapper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; -import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.stereotype.Service; import java.util.*; @@ -14,48 +13,41 @@ public class SearchService { private static final Logger log = LoggerFactory.getLogger(SearchService.class); - private final NamedParameterJdbcTemplate jdbc; - private final RestaurantRepository restaurantRepo; + private final SearchMapper searchMapper; + private final RestaurantService restaurantService; private final VectorService vectorService; private final CacheService cache; - public SearchService(NamedParameterJdbcTemplate jdbc, - RestaurantRepository restaurantRepo, + public SearchService(SearchMapper searchMapper, + RestaurantService restaurantService, VectorService vectorService, CacheService cache) { - this.jdbc = jdbc; - this.restaurantRepo = restaurantRepo; + this.searchMapper = searchMapper; + this.restaurantService = restaurantService; this.vectorService = vectorService; this.cache = cache; } - public List> search(String q, String mode, int limit) { + public List search(String q, String mode, int limit) { String key = cache.makeKey("search", "q=" + q, "m=" + mode, "l=" + limit); String cached = cache.getRaw(key); if (cached != null) { - // Deserialize from cache try { var mapper = new com.fasterxml.jackson.databind.ObjectMapper(); - return mapper.readValue(cached, new com.fasterxml.jackson.core.type.TypeReference<>() {}); + return mapper.readValue(cached, new com.fasterxml.jackson.core.type.TypeReference>() {}); } catch (Exception ignored) {} } - List> result; + List result; switch (mode) { case "semantic" -> result = semanticSearch(q, limit); case "hybrid" -> { var kw = keywordSearch(q, limit); var sem = semanticSearch(q, limit); Set seen = new HashSet<>(); - var merged = new ArrayList>(); - for (var r : kw) { - String id = (String) r.get("id"); - if (seen.add(id)) merged.add(r); - } - for (var r : sem) { - String id = (String) r.get("id"); - if (seen.add(id)) merged.add(r); - } + var merged = new ArrayList(); + for (var r : kw) { if (seen.add(r.getId())) merged.add(r); } + for (var r : sem) { if (seen.add(r.getId())) merged.add(r); } result = merged.size() > limit ? merged.subList(0, limit) : merged; } default -> result = keywordSearch(q, limit); @@ -65,53 +57,33 @@ public class SearchService { return result; } - private List> keywordSearch(String q, int limit) { + private List keywordSearch(String q, int limit) { String pattern = "%" + q + "%"; - String sql = """ - SELECT DISTINCT r.id, r.name, r.address, r.region, r.latitude, r.longitude, - r.cuisine_type, r.price_range, r.google_place_id, - r.business_status, r.rating, r.rating_count - FROM restaurants r - JOIN video_restaurants vr ON vr.restaurant_id = r.id - JOIN videos v ON v.id = vr.video_id - WHERE r.latitude IS NOT NULL - AND (UPPER(r.name) LIKE UPPER(:q) - OR UPPER(r.address) LIKE UPPER(:q) - OR UPPER(r.region) LIKE UPPER(:q) - OR UPPER(r.cuisine_type) LIKE UPPER(:q) - OR UPPER(vr.foods_mentioned) LIKE UPPER(:q) - OR UPPER(v.title) LIKE UPPER(:q)) - FETCH FIRST :lim ROWS ONLY - """; - var params = new MapSqlParameterSource().addValue("q", pattern).addValue("lim", limit); - List> rows = jdbc.queryForList(sql, params); - if (!rows.isEmpty()) { - attachChannels(rows); + List results = searchMapper.keywordSearch(pattern, limit); + if (!results.isEmpty()) { + attachChannels(results); } - return rows; + return results; } - private List> semanticSearch(String q, int limit) { + private List semanticSearch(String q, int limit) { try { var similar = vectorService.searchSimilar(q, Math.max(30, limit * 3), 0.57); if (similar.isEmpty()) return List.of(); - // Deduplicate by restaurant_id, preserving distance order Set seen = new LinkedHashSet<>(); for (var s : similar) { seen.add((String) s.get("restaurant_id")); } - List> results = new ArrayList<>(); + List results = new ArrayList<>(); for (String rid : seen) { if (results.size() >= limit) break; - var r = restaurantRepo.findById(rid); - if (r != null && r.get("latitude") != null) { + var r = restaurantService.findById(rid); + if (r != null && r.getLatitude() != null) { results.add(r); } } - - if (!results.isEmpty()) attachChannels(results); return results; } catch (Exception e) { log.warn("Semantic search failed, falling back to keyword: {}", e.getMessage()); @@ -119,34 +91,21 @@ public class SearchService { } } - private void attachChannels(List> rows) { - List ids = rows.stream() - .map(r -> (String) r.get("id")) - .filter(Objects::nonNull).toList(); + private void attachChannels(List restaurants) { + List ids = restaurants.stream().map(Restaurant::getId).filter(Objects::nonNull).toList(); if (ids.isEmpty()) return; - var params = new MapSqlParameterSource(); - var placeholders = new ArrayList(); - for (int i = 0; i < ids.size(); i++) { - placeholders.add(":id" + i); - params.addValue("id" + i, ids.get(i)); - } - String sql = """ - SELECT DISTINCT vr.restaurant_id, c.channel_name - FROM video_restaurants vr - JOIN videos v ON v.id = vr.video_id - JOIN channels c ON c.id = v.channel_id - WHERE vr.restaurant_id IN (%s) - """.formatted(String.join(", ", placeholders)); - + var channelRows = searchMapper.findChannelsByRestaurantIds(ids); Map> chMap = new HashMap<>(); - jdbc.query(sql, params, rs -> { - chMap.computeIfAbsent(rs.getString("RESTAURANT_ID"), k -> new ArrayList<>()) - .add(rs.getString("CHANNEL_NAME")); - }); - for (var r : rows) { - String id = (String) r.get("id"); - r.put("channels", chMap.getOrDefault(id, List.of())); + for (var row : channelRows) { + String rid = (String) row.getOrDefault("restaurant_id", row.get("RESTAURANT_ID")); + String ch = (String) row.getOrDefault("channel_name", row.get("CHANNEL_NAME")); + if (rid != null && ch != null) { + chMap.computeIfAbsent(rid, k -> new ArrayList<>()).add(ch); + } + } + for (var r : restaurants) { + r.setChannels(chMap.getOrDefault(r.getId(), List.of())); } } } diff --git a/backend-java/src/main/java/com/tasteby/service/StatsService.java b/backend-java/src/main/java/com/tasteby/service/StatsService.java new file mode 100644 index 0000000..172298a --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/service/StatsService.java @@ -0,0 +1,26 @@ +package com.tasteby.service; + +import com.tasteby.domain.SiteVisitStats; +import com.tasteby.mapper.StatsMapper; +import org.springframework.stereotype.Service; + +@Service +public class StatsService { + + private final StatsMapper mapper; + + public StatsService(StatsMapper mapper) { + this.mapper = mapper; + } + + public void recordVisit() { + mapper.recordVisit(); + } + + public SiteVisitStats getVisits() { + return SiteVisitStats.builder() + .today(mapper.getTodayVisits()) + .total(mapper.getTotalVisits()) + .build(); + } +} diff --git a/backend-java/src/main/java/com/tasteby/service/UserService.java b/backend-java/src/main/java/com/tasteby/service/UserService.java new file mode 100644 index 0000000..d3c5ebb --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/service/UserService.java @@ -0,0 +1,50 @@ +package com.tasteby.service; + +import com.tasteby.domain.UserInfo; +import com.tasteby.mapper.UserMapper; +import com.tasteby.util.IdGenerator; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +public class UserService { + + private final UserMapper mapper; + + public UserService(UserMapper mapper) { + this.mapper = mapper; + } + + @Transactional + public UserInfo findOrCreate(String provider, String providerId, String email, String nickname, String avatarUrl) { + UserInfo existing = mapper.findByProviderAndProviderId(provider, providerId); + if (existing != null) { + mapper.updateLastLogin(existing.getId()); + return mapper.findById(existing.getId()); + } + UserInfo user = UserInfo.builder() + .id(IdGenerator.newId()) + .provider(provider) + .providerId(providerId) + .email(email) + .nickname(nickname) + .avatarUrl(avatarUrl) + .build(); + mapper.insert(user); + return mapper.findById(user.getId()); + } + + public UserInfo findById(String userId) { + return mapper.findById(userId); + } + + public List findAllWithCounts(int limit, int offset) { + return mapper.findAllWithCounts(limit, offset); + } + + public int countAll() { + return mapper.countAll(); + } +} diff --git a/backend-java/src/main/java/com/tasteby/service/VideoService.java b/backend-java/src/main/java/com/tasteby/service/VideoService.java new file mode 100644 index 0000000..5f21d44 --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/service/VideoService.java @@ -0,0 +1,104 @@ +package com.tasteby.service; + +import com.tasteby.domain.VideoDetail; +import com.tasteby.domain.VideoRestaurantLink; +import com.tasteby.domain.VideoSummary; +import com.tasteby.mapper.VideoMapper; +import com.tasteby.util.IdGenerator; +import com.tasteby.util.JsonUtil; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; + +@Service +public class VideoService { + + private final VideoMapper mapper; + + public VideoService(VideoMapper mapper) { + this.mapper = mapper; + } + + public List findAll(String status) { + return mapper.findAll(status); + } + + public VideoDetail findDetail(String id) { + VideoDetail detail = mapper.findDetail(id); + if (detail == null) return null; + List restaurants = mapper.findVideoRestaurants(id); + detail.setRestaurants(restaurants != null ? restaurants : List.of()); + return detail; + } + + public void updateTitle(String id, String title) { + mapper.updateTitle(id, title); + } + + public void updateStatus(String id, String status) { + mapper.updateStatus(id, status); + } + + @Transactional + public void delete(String id) { + mapper.deleteVectorsByVideoOnly(id); + mapper.deleteReviewsByVideoOnly(id); + mapper.deleteFavoritesByVideoOnly(id); + mapper.deleteRestaurantsByVideoOnly(id); + mapper.deleteVideoRestaurants(id); + mapper.deleteVideo(id); + } + + @Transactional + public void deleteVideoRestaurant(String videoId, String restaurantId) { + mapper.deleteOneVideoRestaurant(videoId, restaurantId); + mapper.cleanupOrphanVectors(restaurantId); + mapper.cleanupOrphanReviews(restaurantId); + mapper.cleanupOrphanFavorites(restaurantId); + mapper.cleanupOrphanRestaurant(restaurantId); + } + + public int saveVideosBatch(String channelId, List> videos) { + Set existing = new HashSet<>(mapper.getExistingVideoIds(channelId)); + int saved = 0; + for (var v : videos) { + String videoId = (String) v.get("video_id"); + if (existing.contains(videoId)) continue; + String id = IdGenerator.newId(); + mapper.insertVideo(id, channelId, videoId, + (String) v.get("title"), (String) v.get("url"), (String) v.get("published_at")); + saved++; + } + return saved; + } + + public Set getExistingVideoIds(String channelId) { + return new HashSet<>(mapper.getExistingVideoIds(channelId)); + } + + public String getLatestVideoDate(String channelId) { + return mapper.getLatestVideoDate(channelId); + } + + public List> findPendingVideos(int limit) { + return mapper.findPendingVideos(limit).stream() + .map(JsonUtil::lowerKeys).toList(); + } + + public List> findVideosForBulkExtract() { + var rows = mapper.findVideosForBulkExtract(); + return rows.stream().map(row -> { + var r = JsonUtil.lowerKeys(row); + // Parse CLOB transcript + Object transcript = r.get("transcript_text"); + r.put("transcript", JsonUtil.readClob(transcript)); + r.remove("transcript_text"); + return r; + }).toList(); + } + + public void updateVideoFields(String id, String status, String transcript, String llmResponse) { + mapper.updateVideoFields(id, status, transcript, llmResponse); + } +} diff --git a/backend-java/src/main/java/com/tasteby/service/YouTubeService.java b/backend-java/src/main/java/com/tasteby/service/YouTubeService.java index 594ef28..fa31664 100644 --- a/backend-java/src/main/java/com/tasteby/service/YouTubeService.java +++ b/backend-java/src/main/java/com/tasteby/service/YouTubeService.java @@ -10,8 +10,7 @@ import io.github.thoroldvix.api.YoutubeTranscriptApi; import com.microsoft.playwright.*; import com.microsoft.playwright.options.Cookie; import com.microsoft.playwright.options.WaitUntilState; -import com.tasteby.repository.ChannelRepository; -import com.tasteby.repository.VideoRepository; +import com.tasteby.domain.Channel; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; @@ -33,20 +32,20 @@ public class YouTubeService { private final WebClient webClient; private final ObjectMapper mapper; - private final ChannelRepository channelRepo; - private final VideoRepository videoRepo; + private final ChannelService channelService; + private final VideoService videoService; private final String apiKey; public YouTubeService(ObjectMapper mapper, - ChannelRepository channelRepo, - VideoRepository videoRepo, + ChannelService channelService, + VideoService videoService, @Value("${app.google.youtube-api-key}") String apiKey) { this.webClient = WebClient.builder() .baseUrl("https://www.googleapis.com/youtube/v3") .build(); this.mapper = mapper; - this.channelRepo = channelRepo; - this.videoRepo = videoRepo; + this.channelService = channelService; + this.videoService = videoService; this.apiKey = apiKey; } @@ -151,13 +150,13 @@ public class YouTubeService { * Scan a single channel for new videos. Returns scan result map. */ public Map scanChannel(String channelId, boolean full) { - var ch = channelRepo.findByChannelId(channelId); + Channel ch = channelService.findByChannelId(channelId); if (ch == null) return null; - String dbId = (String) ch.get("id"); - String titleFilter = (String) ch.get("title_filter"); - String after = full ? null : videoRepo.getLatestVideoDate(dbId); - Set existing = videoRepo.getExistingVideoIds(dbId); + String dbId = ch.getId(); + String titleFilter = ch.getTitleFilter(); + String after = full ? null : videoService.getLatestVideoDate(dbId); + Set existing = videoService.getExistingVideoIds(dbId); List> allFetched = fetchChannelVideos(channelId, after, true); int totalFetched = allFetched.size(); @@ -169,7 +168,7 @@ public class YouTubeService { candidates.add(v); } - int newCount = videoRepo.saveVideosBatch(dbId, candidates); + int newCount = videoService.saveVideosBatch(dbId, candidates); return Map.of( "total_fetched", totalFetched, "new_videos", newCount, @@ -181,17 +180,16 @@ public class YouTubeService { * Scan all active channels. Returns total new video count. */ public int scanAllChannels() { - var channels = channelRepo.findAllActive(); + List channels = channelService.findAllActive(); int totalNew = 0; for (var ch : channels) { try { - String chId = (String) ch.get("channel_id"); - var result = scanChannel(chId, false); + var result = scanChannel(ch.getChannelId(), false); if (result != null) { totalNew += ((Number) result.get("new_videos")).intValue(); } } catch (Exception e) { - log.error("Failed to scan channel {}: {}", ch.get("channel_name"), e.getMessage()); + log.error("Failed to scan channel {}: {}", ch.getChannelName(), e.getMessage()); } } return totalNew; diff --git a/backend-java/src/main/java/com/tasteby/util/IdGenerator.java b/backend-java/src/main/java/com/tasteby/util/IdGenerator.java new file mode 100644 index 0000000..40e499d --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/util/IdGenerator.java @@ -0,0 +1,12 @@ +package com.tasteby.util; + +import java.util.UUID; + +public final class IdGenerator { + + private IdGenerator() {} + + public static String newId() { + return UUID.randomUUID().toString().replace("-", "").substring(0, 32).toUpperCase(); + } +} diff --git a/backend-java/src/main/resources/application.yml b/backend-java/src/main/resources/application.yml index eb5d291..625a79d 100644 --- a/backend-java/src/main/resources/application.yml +++ b/backend-java/src/main/resources/application.yml @@ -29,6 +29,7 @@ spring: jackson: default-property-inclusion: non_null + property-naming-strategy: SNAKE_CASE serialization: write-dates-as-timestamps: false @@ -57,7 +58,13 @@ app: cache: ttl-seconds: 600 +mybatis: + mapper-locations: classpath:mybatis/mapper/*.xml + config-location: classpath:mybatis/mybatis-config.xml + type-aliases-package: com.tasteby.domain + type-handlers-package: com.tasteby.config + logging: level: com.tasteby: DEBUG - org.springframework.jdbc: DEBUG + com.tasteby.mapper: DEBUG diff --git a/backend-java/src/main/resources/mybatis/mapper/ChannelMapper.xml b/backend-java/src/main/resources/mybatis/mapper/ChannelMapper.xml new file mode 100644 index 0000000..1d486fe --- /dev/null +++ b/backend-java/src/main/resources/mybatis/mapper/ChannelMapper.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + INSERT INTO channels (id, channel_id, channel_name, title_filter) + VALUES (#{id}, #{channelId}, #{channelName}, #{titleFilter}) + + + + UPDATE channels SET is_active = 0 + WHERE channel_id = #{channelId} AND is_active = 1 + + + + UPDATE channels SET is_active = 0 + WHERE id = #{id} AND is_active = 1 + + + + + diff --git a/backend-java/src/main/resources/mybatis/mapper/DaemonConfigMapper.xml b/backend-java/src/main/resources/mybatis/mapper/DaemonConfigMapper.xml new file mode 100644 index 0000000..b027c2c --- /dev/null +++ b/backend-java/src/main/resources/mybatis/mapper/DaemonConfigMapper.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + UPDATE daemon_config + + scan_enabled = #{scanEnabled, javaType=boolean, jdbcType=NUMERIC}, + scan_interval_min = #{scanIntervalMin}, + process_enabled = #{processEnabled, javaType=boolean, jdbcType=NUMERIC}, + process_interval_min = #{processIntervalMin}, + process_limit = #{processLimit}, + updated_at = SYSTIMESTAMP, + + WHERE id = 1 + + + + UPDATE daemon_config SET last_scan_at = SYSTIMESTAMP WHERE id = 1 + + + + UPDATE daemon_config SET last_process_at = SYSTIMESTAMP WHERE id = 1 + + + diff --git a/backend-java/src/main/resources/mybatis/mapper/RestaurantMapper.xml b/backend-java/src/main/resources/mybatis/mapper/RestaurantMapper.xml new file mode 100644 index 0000000..c5c1441 --- /dev/null +++ b/backend-java/src/main/resources/mybatis/mapper/RestaurantMapper.xml @@ -0,0 +1,230 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO restaurants (id, name, address, region, latitude, longitude, + cuisine_type, price_range, google_place_id, + phone, website, business_status, rating, rating_count) + VALUES (#{id}, #{name}, #{address}, #{region}, #{latitude}, #{longitude}, + #{cuisineType}, #{priceRange}, #{googlePlaceId}, + #{phone}, #{website}, #{businessStatus}, #{rating}, #{ratingCount}) + + + + + + UPDATE restaurants SET + name = #{name}, + address = COALESCE(#{address}, address), + region = COALESCE(#{region}, region), + latitude = COALESCE(#{latitude}, latitude), + longitude = COALESCE(#{longitude}, longitude), + cuisine_type = COALESCE(#{cuisineType}, cuisine_type), + price_range = COALESCE(#{priceRange}, price_range), + google_place_id = COALESCE(#{googlePlaceId}, google_place_id), + phone = COALESCE(#{phone}, phone), + website = COALESCE(#{website}, website), + business_status = COALESCE(#{businessStatus}, business_status), + rating = COALESCE(#{rating}, rating), + rating_count = COALESCE(#{ratingCount}, rating_count), + updated_at = SYSTIMESTAMP + WHERE id = #{id} + + + + + + UPDATE restaurants SET + + + name = #{fields.name}, + + + address = #{fields.address}, + + + region = #{fields.region}, + + + cuisine_type = #{fields.cuisine_type}, + + + price_range = #{fields.price_range}, + + + phone = #{fields.phone}, + + + website = #{fields.website}, + + + latitude = #{fields.latitude}, + + + longitude = #{fields.longitude}, + + updated_at = SYSTIMESTAMP, + + WHERE id = #{id} + + + + + + DELETE FROM restaurant_vectors WHERE restaurant_id = #{id} + + + + DELETE FROM user_reviews WHERE restaurant_id = #{id} + + + + DELETE FROM user_favorites WHERE restaurant_id = #{id} + + + + DELETE FROM video_restaurants WHERE restaurant_id = #{id} + + + + DELETE FROM restaurants WHERE id = #{id} + + + + + + INSERT INTO video_restaurants (id, video_id, restaurant_id, foods_mentioned, evaluation, guests) + VALUES (#{id}, #{videoId}, #{restaurantId}, #{foods}, #{evaluation}, #{guests}) + + + + + + + + + + + + + + + + + + UPDATE restaurants SET cuisine_type = #{cuisineType} WHERE id = #{id} + + + + UPDATE video_restaurants SET foods_mentioned = #{foods} WHERE id = #{id} + + + + + + + diff --git a/backend-java/src/main/resources/mybatis/mapper/ReviewMapper.xml b/backend-java/src/main/resources/mybatis/mapper/ReviewMapper.xml new file mode 100644 index 0000000..dcc0e7d --- /dev/null +++ b/backend-java/src/main/resources/mybatis/mapper/ReviewMapper.xml @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO user_reviews (id, user_id, restaurant_id, rating, review_text, visited_at) + VALUES (#{id}, #{userId}, #{restaurantId}, #{rating}, #{reviewText}, + + TO_DATE(#{visitedAt}, 'YYYY-MM-DD') + NULL + ) + + + + UPDATE user_reviews SET + rating = COALESCE(#{rating}, rating), + review_text = COALESCE(#{reviewText}, review_text), + visited_at = COALESCE( + + TO_DATE(#{visitedAt}, 'YYYY-MM-DD') + NULL + , visited_at), + updated_at = SYSTIMESTAMP + WHERE id = #{id} AND user_id = #{userId} + + + + DELETE FROM user_reviews WHERE id = #{id} AND user_id = #{userId} + + + + + + + + + + + + + + INSERT INTO user_favorites (id, user_id, restaurant_id) + VALUES (#{id}, #{userId}, #{restaurantId}) + + + + DELETE FROM user_favorites + WHERE user_id = #{userId} AND restaurant_id = #{restaurantId} + + + + + + + diff --git a/backend-java/src/main/resources/mybatis/mapper/SearchMapper.xml b/backend-java/src/main/resources/mybatis/mapper/SearchMapper.xml new file mode 100644 index 0000000..d61e265 --- /dev/null +++ b/backend-java/src/main/resources/mybatis/mapper/SearchMapper.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend-java/src/main/resources/mybatis/mapper/StatsMapper.xml b/backend-java/src/main/resources/mybatis/mapper/StatsMapper.xml new file mode 100644 index 0000000..25799a8 --- /dev/null +++ b/backend-java/src/main/resources/mybatis/mapper/StatsMapper.xml @@ -0,0 +1,24 @@ + + + + + + MERGE INTO site_visits sv + USING (SELECT TRUNC(SYSDATE) AS d FROM dual) src + ON (sv.visit_date = src.d) + WHEN MATCHED THEN UPDATE SET sv.visit_count = sv.visit_count + 1 + WHEN NOT MATCHED THEN INSERT (visit_date, visit_count) VALUES (src.d, 1) + + + + + + + diff --git a/backend-java/src/main/resources/mybatis/mapper/UserMapper.xml b/backend-java/src/main/resources/mybatis/mapper/UserMapper.xml new file mode 100644 index 0000000..887bf05 --- /dev/null +++ b/backend-java/src/main/resources/mybatis/mapper/UserMapper.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + UPDATE tasteby_users SET last_login_at = SYSTIMESTAMP WHERE id = #{id} + + + + INSERT INTO tasteby_users (id, provider, provider_id, email, nickname, avatar_url) + VALUES (#{id}, #{provider}, #{providerId}, #{email}, #{nickname}, #{avatarUrl}) + + + + + + + + + diff --git a/backend-java/src/main/resources/mybatis/mapper/VectorMapper.xml b/backend-java/src/main/resources/mybatis/mapper/VectorMapper.xml new file mode 100644 index 0000000..6dc8641 --- /dev/null +++ b/backend-java/src/main/resources/mybatis/mapper/VectorMapper.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + INSERT INTO restaurant_vectors (id, restaurant_id, chunk_text, embedding) + VALUES (#{id}, #{restaurantId}, #{chunkText}, TO_VECTOR(#{embedding})) + + + diff --git a/backend-java/src/main/resources/mybatis/mapper/VideoMapper.xml b/backend-java/src/main/resources/mybatis/mapper/VideoMapper.xml new file mode 100644 index 0000000..2b6a0de --- /dev/null +++ b/backend-java/src/main/resources/mybatis/mapper/VideoMapper.xml @@ -0,0 +1,219 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + UPDATE videos SET status = #{status} WHERE id = #{id} + + + + UPDATE videos SET title = #{title} WHERE id = #{id} + + + + UPDATE videos SET transcript_text = #{transcript} WHERE id = #{id} + + + + UPDATE videos SET + status = #{status}, + processed_at = SYSTIMESTAMP + + , transcript_text = #{transcript} + + + , llm_raw_response = #{llmResponse} + + WHERE id = #{id} + + + + + + DELETE FROM restaurant_vectors WHERE restaurant_id IN ( + SELECT vr.restaurant_id FROM video_restaurants vr + WHERE vr.video_id = #{videoId} + AND NOT EXISTS (SELECT 1 FROM video_restaurants vr2 + WHERE vr2.restaurant_id = vr.restaurant_id AND vr2.video_id != #{videoId}) + ) + + + + DELETE FROM user_reviews WHERE restaurant_id IN ( + SELECT vr.restaurant_id FROM video_restaurants vr + WHERE vr.video_id = #{videoId} + AND NOT EXISTS (SELECT 1 FROM video_restaurants vr2 + WHERE vr2.restaurant_id = vr.restaurant_id AND vr2.video_id != #{videoId}) + ) + + + + DELETE FROM user_favorites WHERE restaurant_id IN ( + SELECT vr.restaurant_id FROM video_restaurants vr + WHERE vr.video_id = #{videoId} + AND NOT EXISTS (SELECT 1 FROM video_restaurants vr2 + WHERE vr2.restaurant_id = vr.restaurant_id AND vr2.video_id != #{videoId}) + ) + + + + DELETE FROM restaurants WHERE id IN ( + SELECT vr.restaurant_id FROM video_restaurants vr + WHERE vr.video_id = #{videoId} + AND NOT EXISTS (SELECT 1 FROM video_restaurants vr2 + WHERE vr2.restaurant_id = vr.restaurant_id AND vr2.video_id != #{videoId}) + ) + + + + DELETE FROM video_restaurants WHERE video_id = #{videoId} + + + + DELETE FROM videos WHERE id = #{videoId} + + + + + + DELETE FROM video_restaurants WHERE video_id = #{videoId} AND restaurant_id = #{restaurantId} + + + + DELETE FROM restaurant_vectors WHERE restaurant_id = #{restaurantId} + AND NOT EXISTS (SELECT 1 FROM video_restaurants WHERE restaurant_id = #{restaurantId}) + + + + DELETE FROM user_reviews WHERE restaurant_id = #{restaurantId} + AND NOT EXISTS (SELECT 1 FROM video_restaurants WHERE restaurant_id = #{restaurantId}) + + + + DELETE FROM user_favorites WHERE restaurant_id = #{restaurantId} + AND NOT EXISTS (SELECT 1 FROM video_restaurants WHERE restaurant_id = #{restaurantId}) + + + + DELETE FROM restaurants WHERE id = #{restaurantId} + AND NOT EXISTS (SELECT 1 FROM video_restaurants WHERE restaurant_id = #{restaurantId}) + + + + + + INSERT INTO videos (id, channel_id, video_id, title, url, published_at) + VALUES (#{id}, #{channelId}, #{videoId}, #{title}, #{url}, #{publishedAt}) + + + + + + + + + + + + + diff --git a/backend-java/src/main/resources/mybatis/mybatis-config.xml b/backend-java/src/main/resources/mybatis/mybatis-config.xml new file mode 100644 index 0000000..58d67af --- /dev/null +++ b/backend-java/src/main/resources/mybatis/mybatis-config.xml @@ -0,0 +1,9 @@ + + + + + + + + +