Migrate to MyBatis with proper Controller→Service→Mapper layering

- Add MyBatis Spring Boot Starter with XML mappers and domain classes
- Create 9 mapper interfaces + XML: Restaurant, Video, Channel, Review,
  User, Stats, DaemonConfig, Search, Vector
- Create 10 domain classes with Lombok: Restaurant, VideoSummary,
  VideoDetail, VideoRestaurantLink, Channel, Review, UserInfo,
  DaemonConfig, SiteVisitStats, VectorSearchResult
- Create 7 new service classes: RestaurantService, VideoService,
  ChannelService, ReviewService, UserService, StatsService,
  DaemonConfigService
- Refactor all controllers to be thin (HTTP + auth only), delegating
  business logic to services
- Refactor SearchService, PipelineService, DaemonScheduler, AuthService,
  YouTubeService to use mappers/services instead of JDBC/repositories
- Add Jackson SNAKE_CASE property naming for consistent API responses
- Add ClobTypeHandler for Oracle CLOB→String in MyBatis
- Add IdGenerator utility for centralized UUID generation
- Delete old repository/ package (6 files), JdbcConfig, LowerCaseKeyAdvice
- VectorService retains JDBC for Oracle VECTOR type support

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
joungmin
2026-03-09 21:13:44 +09:00
parent 91d0ad4598
commit c16add08c3
63 changed files with 2155 additions and 1483 deletions

View File

@@ -22,6 +22,9 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-jdbc' implementation 'org.springframework.boot:spring-boot-starter-jdbc'
implementation 'org.springframework.boot:spring-boot-starter-security' 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-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-validation'

View File

@@ -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<String> {
@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;
}
}
}

View File

@@ -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<Map<String, Object>> getColumnMapRowMapper() {
return new ColumnMapRowMapper() {
@Override
protected String getColumnKey(String columnName) {
return columnName.toLowerCase();
}
};
}
}
}

View File

@@ -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<Object> {
@Override
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
return true;
}
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType,
Class<? extends HttpMessageConverter<?>> selectedConverterType,
ServerHttpRequest request, ServerHttpResponse response) {
return convertKeys(body);
}
@SuppressWarnings("unchecked")
private Object convertKeys(Object obj) {
if (obj instanceof Map<?, ?> map) {
var result = new LinkedHashMap<String, Object>();
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;
}
}

View File

@@ -1,10 +1,9 @@
package com.tasteby.controller; package com.tasteby.controller;
import com.tasteby.repository.UserRepository; import com.tasteby.domain.Restaurant;
import com.tasteby.repository.ReviewRepository; import com.tasteby.domain.Review;
import com.tasteby.util.JsonUtil; import com.tasteby.service.ReviewService;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; import com.tasteby.service.UserService;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;
@@ -14,49 +13,30 @@ import java.util.Map;
@RequestMapping("/api/admin/users") @RequestMapping("/api/admin/users")
public class AdminUserController { public class AdminUserController {
private final UserRepository userRepo; private final UserService userService;
private final NamedParameterJdbcTemplate jdbc; private final ReviewService reviewService;
public AdminUserController(UserRepository userRepo, NamedParameterJdbcTemplate jdbc) { public AdminUserController(UserService userService, ReviewService reviewService) {
this.userRepo = userRepo; this.userService = userService;
this.jdbc = jdbc; this.reviewService = reviewService;
} }
@GetMapping @GetMapping
public Map<String, Object> listUsers( public Map<String, Object> listUsers(
@RequestParam(defaultValue = "50") int limit, @RequestParam(defaultValue = "50") int limit,
@RequestParam(defaultValue = "0") int offset) { @RequestParam(defaultValue = "0") int offset) {
var users = userRepo.findAllWithCounts(limit, offset); var users = userService.findAllWithCounts(limit, offset);
int total = userRepo.countAll(); int total = userService.countAll();
return Map.of("users", users, "total", total); return Map.of("users", users, "total", total);
} }
@GetMapping("/{userId}/favorites") @GetMapping("/{userId}/favorites")
public List<Map<String, Object>> userFavorites(@PathVariable String userId) { public List<Restaurant> userFavorites(@PathVariable String userId) {
String sql = """ return reviewService.getUserFavorites(userId);
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));
} }
@GetMapping("/{userId}/reviews") @GetMapping("/{userId}/reviews")
public List<Map<String, Object>> userReviews(@PathVariable String userId) { public List<Review> userReviews(@PathVariable String userId) {
String sql = """ return reviewService.findByUser(userId, 100, 0);
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;
} }
} }

View File

@@ -1,5 +1,6 @@
package com.tasteby.controller; package com.tasteby.controller;
import com.tasteby.domain.UserInfo;
import com.tasteby.security.AuthUtil; import com.tasteby.security.AuthUtil;
import com.tasteby.service.AuthService; import com.tasteby.service.AuthService;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -23,7 +24,7 @@ public class AuthController {
} }
@GetMapping("/me") @GetMapping("/me")
public Map<String, Object> me() { public UserInfo me() {
String userId = AuthUtil.getUserId(); String userId = AuthUtil.getUserId();
return authService.getCurrentUser(userId); return authService.getCurrentUser(userId);
} }

View File

@@ -1,8 +1,11 @@
package com.tasteby.controller; 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.security.AuthUtil;
import com.tasteby.service.CacheService; import com.tasteby.service.CacheService;
import com.tasteby.service.ChannelService;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
@@ -14,26 +17,26 @@ import java.util.Map;
@RequestMapping("/api/channels") @RequestMapping("/api/channels")
public class ChannelController { public class ChannelController {
private final ChannelRepository repo; private final ChannelService channelService;
private final CacheService cache; private final CacheService cache;
private final ObjectMapper objectMapper;
public ChannelController(ChannelRepository repo, CacheService cache) { public ChannelController(ChannelService channelService, CacheService cache, ObjectMapper objectMapper) {
this.repo = repo; this.channelService = channelService;
this.cache = cache; this.cache = cache;
this.objectMapper = objectMapper;
} }
@GetMapping @GetMapping
public List<Map<String, Object>> list() { public List<Channel> list() {
String key = cache.makeKey("channels"); String key = cache.makeKey("channels");
String cached = cache.getRaw(key); String cached = cache.getRaw(key);
if (cached != null) { if (cached != null) {
try { try {
var mapper = new com.fasterxml.jackson.databind.ObjectMapper(); return objectMapper.readValue(cached, new TypeReference<List<Channel>>() {});
return mapper.readValue(cached,
new com.fasterxml.jackson.core.type.TypeReference<>() {});
} catch (Exception ignored) {} } catch (Exception ignored) {}
} }
var result = repo.findAllActive(); var result = channelService.findAllActive();
cache.set(key, result); cache.set(key, result);
return result; return result;
} }
@@ -46,7 +49,7 @@ public class ChannelController {
String channelName = body.get("channel_name"); String channelName = body.get("channel_name");
String titleFilter = body.get("title_filter"); String titleFilter = body.get("title_filter");
try { try {
String id = repo.create(channelId, channelName, titleFilter); String id = channelService.create(channelId, channelName, titleFilter);
cache.flush(); cache.flush();
return Map.of("id", id, "channel_id", channelId); return Map.of("id", id, "channel_id", channelId);
} catch (Exception e) { } catch (Exception e) {
@@ -60,7 +63,7 @@ public class ChannelController {
@DeleteMapping("/{channelId}") @DeleteMapping("/{channelId}")
public Map<String, Object> delete(@PathVariable String channelId) { public Map<String, Object> delete(@PathVariable String channelId) {
AuthUtil.requireAdmin(); AuthUtil.requireAdmin();
if (!repo.deactivate(channelId)) { if (!channelService.deactivate(channelId)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Channel not found"); throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Channel not found");
} }
cache.flush(); cache.flush();

View File

@@ -1,82 +1,32 @@
package com.tasteby.controller; package com.tasteby.controller;
import com.tasteby.domain.DaemonConfig;
import com.tasteby.security.AuthUtil; import com.tasteby.security.AuthUtil;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; import com.tasteby.service.DaemonConfigService;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.Map; import java.util.Map;
@RestController @RestController
@RequestMapping("/api/daemon") @RequestMapping("/api/daemon")
public class DaemonController { public class DaemonController {
private final NamedParameterJdbcTemplate jdbc; private final DaemonConfigService daemonConfigService;
public DaemonController(NamedParameterJdbcTemplate jdbc) { public DaemonController(DaemonConfigService daemonConfigService) {
this.jdbc = jdbc; this.daemonConfigService = daemonConfigService;
} }
@GetMapping("/config") @GetMapping("/config")
public Map<String, Object> getConfig() { public DaemonConfig getConfig() {
String sql = """ DaemonConfig config = daemonConfigService.getConfig();
SELECT scan_enabled, scan_interval_min, process_enabled, process_interval_min, return config != null ? config : DaemonConfig.builder().build();
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<String, Object>();
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;
} }
@PutMapping("/config") @PutMapping("/config")
public Map<String, Object> updateConfig(@RequestBody Map<String, Object> body) { public Map<String, Object> updateConfig(@RequestBody Map<String, Object> body) {
AuthUtil.requireAdmin(); AuthUtil.requireAdmin();
var sets = new ArrayList<String>(); daemonConfigService.updateConfig(body);
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);
}
return Map.of("ok", true); return Map.of("ok", true);
} }
private int toInt(Object val) {
if (val == null) return 0;
return ((Number) val).intValue();
}
} }

View File

@@ -1,8 +1,11 @@
package com.tasteby.controller; 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.security.AuthUtil;
import com.tasteby.service.CacheService; import com.tasteby.service.CacheService;
import com.tasteby.service.RestaurantService;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
@@ -14,16 +17,18 @@ import java.util.Map;
@RequestMapping("/api/restaurants") @RequestMapping("/api/restaurants")
public class RestaurantController { public class RestaurantController {
private final RestaurantRepository repo; private final RestaurantService restaurantService;
private final CacheService cache; private final CacheService cache;
private final ObjectMapper objectMapper;
public RestaurantController(RestaurantRepository repo, CacheService cache) { public RestaurantController(RestaurantService restaurantService, CacheService cache, ObjectMapper objectMapper) {
this.repo = repo; this.restaurantService = restaurantService;
this.cache = cache; this.cache = cache;
this.objectMapper = objectMapper;
} }
@GetMapping @GetMapping
public List<Map<String, Object>> list( public List<Restaurant> list(
@RequestParam(defaultValue = "100") int limit, @RequestParam(defaultValue = "100") int limit,
@RequestParam(defaultValue = "0") int offset, @RequestParam(defaultValue = "0") int offset,
@RequestParam(required = false) String cuisine, @RequestParam(required = false) String cuisine,
@@ -35,28 +40,24 @@ public class RestaurantController {
String cached = cache.getRaw(key); String cached = cache.getRaw(key);
if (cached != null) { if (cached != null) {
try { try {
var mapper = new com.fasterxml.jackson.databind.ObjectMapper(); return objectMapper.readValue(cached, new TypeReference<List<Restaurant>>() {});
return mapper.readValue(cached,
new com.fasterxml.jackson.core.type.TypeReference<>() {});
} catch (Exception ignored) {} } 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); cache.set(key, result);
return result; return result;
} }
@GetMapping("/{id}") @GetMapping("/{id}")
public Map<String, Object> get(@PathVariable String id) { public Restaurant get(@PathVariable String id) {
String key = cache.makeKey("restaurant", id); String key = cache.makeKey("restaurant", id);
String cached = cache.getRaw(key); String cached = cache.getRaw(key);
if (cached != null) { if (cached != null) {
try { try {
var mapper = new com.fasterxml.jackson.databind.ObjectMapper(); return objectMapper.readValue(cached, Restaurant.class);
return mapper.readValue(cached,
new com.fasterxml.jackson.core.type.TypeReference<>() {});
} catch (Exception ignored) {} } 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"); if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
cache.set(key, r); cache.set(key, r);
return r; return r;
@@ -65,9 +66,9 @@ public class RestaurantController {
@PutMapping("/{id}") @PutMapping("/{id}")
public Map<String, Object> update(@PathVariable String id, @RequestBody Map<String, Object> body) { public Map<String, Object> update(@PathVariable String id, @RequestBody Map<String, Object> body) {
AuthUtil.requireAdmin(); AuthUtil.requireAdmin();
var r = repo.findById(id); var r = restaurantService.findById(id);
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found"); if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
repo.update(id, body); restaurantService.update(id, body);
cache.flush(); cache.flush();
return Map.of("ok", true); return Map.of("ok", true);
} }
@@ -75,9 +76,9 @@ public class RestaurantController {
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
public Map<String, Object> delete(@PathVariable String id) { public Map<String, Object> delete(@PathVariable String id) {
AuthUtil.requireAdmin(); AuthUtil.requireAdmin();
var r = repo.findById(id); var r = restaurantService.findById(id);
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found"); if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
repo.delete(id); restaurantService.delete(id);
cache.flush(); cache.flush();
return Map.of("ok", true); return Map.of("ok", true);
} }
@@ -88,14 +89,12 @@ public class RestaurantController {
String cached = cache.getRaw(key); String cached = cache.getRaw(key);
if (cached != null) { if (cached != null) {
try { try {
var mapper = new com.fasterxml.jackson.databind.ObjectMapper(); return objectMapper.readValue(cached, new TypeReference<List<Map<String, Object>>>() {});
return mapper.readValue(cached,
new com.fasterxml.jackson.core.type.TypeReference<>() {});
} catch (Exception ignored) {} } 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"); 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); cache.set(key, result);
return result; return result;
} }

View File

@@ -1,7 +1,9 @@
package com.tasteby.controller; 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.security.AuthUtil;
import com.tasteby.service.ReviewService;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
@@ -14,10 +16,10 @@ import java.util.Map;
@RequestMapping("/api") @RequestMapping("/api")
public class ReviewController { public class ReviewController {
private final ReviewRepository repo; private final ReviewService reviewService;
public ReviewController(ReviewRepository repo) { public ReviewController(ReviewService reviewService) {
this.repo = repo; this.reviewService = reviewService;
} }
@GetMapping("/restaurants/{restaurantId}/reviews") @GetMapping("/restaurants/{restaurantId}/reviews")
@@ -25,15 +27,15 @@ public class ReviewController {
@PathVariable String restaurantId, @PathVariable String restaurantId,
@RequestParam(defaultValue = "20") int limit, @RequestParam(defaultValue = "20") int limit,
@RequestParam(defaultValue = "0") int offset) { @RequestParam(defaultValue = "0") int offset) {
var reviews = repo.findByRestaurant(restaurantId, limit, offset); var reviews = reviewService.findByRestaurant(restaurantId, limit, offset);
var stats = repo.getAvgRating(restaurantId); var stats = reviewService.getAvgRating(restaurantId);
return Map.of("reviews", reviews, "avg_rating", stats.get("avg_rating"), return Map.of("reviews", reviews, "avg_rating", stats.get("avg_rating"),
"review_count", stats.get("review_count")); "review_count", stats.get("review_count"));
} }
@PostMapping("/restaurants/{restaurantId}/reviews") @PostMapping("/restaurants/{restaurantId}/reviews")
@ResponseStatus(HttpStatus.CREATED) @ResponseStatus(HttpStatus.CREATED)
public Map<String, Object> createReview( public Review createReview(
@PathVariable String restaurantId, @PathVariable String restaurantId,
@RequestBody Map<String, Object> body) { @RequestBody Map<String, Object> body) {
String userId = AuthUtil.getUserId(); String userId = AuthUtil.getUserId();
@@ -41,7 +43,7 @@ public class ReviewController {
String text = (String) body.get("review_text"); String text = (String) body.get("review_text");
LocalDate visitedAt = body.get("visited_at") != null LocalDate visitedAt = body.get("visited_at") != null
? LocalDate.parse((String) 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}") @PutMapping("/reviews/{reviewId}")
@@ -54,43 +56,42 @@ public class ReviewController {
String text = (String) body.get("review_text"); String text = (String) body.get("review_text");
LocalDate visitedAt = body.get("visited_at") != null LocalDate visitedAt = body.get("visited_at") != null
? LocalDate.parse((String) body.get("visited_at")) : null; ? LocalDate.parse((String) body.get("visited_at")) : null;
var result = repo.update(reviewId, userId, rating, text, visitedAt); if (!reviewService.update(reviewId, userId, rating, text, visitedAt)) {
if (result == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Review not found or not yours"); throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Review not found or not yours");
} }
return result; return Map.of("ok", true);
} }
@DeleteMapping("/reviews/{reviewId}") @DeleteMapping("/reviews/{reviewId}")
@ResponseStatus(HttpStatus.NO_CONTENT) @ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteReview(@PathVariable String reviewId) { public void deleteReview(@PathVariable String reviewId) {
String userId = AuthUtil.getUserId(); 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"); throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Review not found or not yours");
} }
} }
@GetMapping("/users/me/reviews") @GetMapping("/users/me/reviews")
public List<Map<String, Object>> myReviews( public List<Review> myReviews(
@RequestParam(defaultValue = "20") int limit, @RequestParam(defaultValue = "20") int limit,
@RequestParam(defaultValue = "0") int offset) { @RequestParam(defaultValue = "0") int offset) {
return repo.findByUser(AuthUtil.getUserId(), limit, offset); return reviewService.findByUser(AuthUtil.getUserId(), limit, offset);
} }
// Favorites // Favorites
@GetMapping("/restaurants/{restaurantId}/favorite") @GetMapping("/restaurants/{restaurantId}/favorite")
public Map<String, Object> favoriteStatus(@PathVariable String restaurantId) { public Map<String, Object> 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") @PostMapping("/restaurants/{restaurantId}/favorite")
public Map<String, Object> toggleFavorite(@PathVariable String restaurantId) { public Map<String, Object> toggleFavorite(@PathVariable String restaurantId) {
boolean result = repo.toggleFavorite(AuthUtil.getUserId(), restaurantId); boolean result = reviewService.toggleFavorite(AuthUtil.getUserId(), restaurantId);
return Map.of("favorited", result); return Map.of("favorited", result);
} }
@GetMapping("/users/me/favorites") @GetMapping("/users/me/favorites")
public List<Map<String, Object>> myFavorites() { public List<Restaurant> myFavorites() {
return repo.getUserFavorites(AuthUtil.getUserId()); return reviewService.getUserFavorites(AuthUtil.getUserId());
} }
} }

View File

@@ -1,10 +1,10 @@
package com.tasteby.controller; package com.tasteby.controller;
import com.tasteby.domain.Restaurant;
import com.tasteby.service.SearchService; import com.tasteby.service.SearchService;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;
import java.util.Map;
@RestController @RestController
@RequestMapping("/api/search") @RequestMapping("/api/search")
@@ -17,7 +17,7 @@ public class SearchController {
} }
@GetMapping @GetMapping
public List<Map<String, Object>> search( public List<Restaurant> search(
@RequestParam String q, @RequestParam String q,
@RequestParam(defaultValue = "keyword") String mode, @RequestParam(defaultValue = "keyword") String mode,
@RequestParam(defaultValue = "20") int limit) { @RequestParam(defaultValue = "20") int limit) {

View File

@@ -1,6 +1,7 @@
package com.tasteby.controller; 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 org.springframework.web.bind.annotation.*;
import java.util.Map; import java.util.Map;
@@ -9,20 +10,20 @@ import java.util.Map;
@RequestMapping("/api/stats") @RequestMapping("/api/stats")
public class StatsController { public class StatsController {
private final StatsRepository repo; private final StatsService statsService;
public StatsController(StatsRepository repo) { public StatsController(StatsService statsService) {
this.repo = repo; this.statsService = statsService;
} }
@PostMapping("/visit") @PostMapping("/visit")
public Map<String, Object> recordVisit() { public Map<String, Object> recordVisit() {
repo.recordVisit(); statsService.recordVisit();
return Map.of("ok", true); return Map.of("ok", true);
} }
@GetMapping("/visits") @GetMapping("/visits")
public Map<String, Object> getVisits() { public SiteVisitStats getVisits() {
return repo.getVisits(); return statsService.getVisits();
} }
} }

View File

@@ -1,8 +1,10 @@
package com.tasteby.controller; 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.security.AuthUtil;
import com.tasteby.service.CacheService; import com.tasteby.service.CacheService;
import com.tasteby.service.VideoService;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
@@ -14,22 +16,22 @@ import java.util.Map;
@RequestMapping("/api/videos") @RequestMapping("/api/videos")
public class VideoController { public class VideoController {
private final VideoRepository repo; private final VideoService videoService;
private final CacheService cache; private final CacheService cache;
public VideoController(VideoRepository repo, CacheService cache) { public VideoController(VideoService videoService, CacheService cache) {
this.repo = repo; this.videoService = videoService;
this.cache = cache; this.cache = cache;
} }
@GetMapping @GetMapping
public List<Map<String, Object>> list(@RequestParam(required = false) String status) { public List<VideoSummary> list(@RequestParam(required = false) String status) {
return repo.findAll(status); return videoService.findAll(status);
} }
@GetMapping("/{id}") @GetMapping("/{id}")
public Map<String, Object> detail(@PathVariable String id) { public VideoDetail detail(@PathVariable String id) {
var video = repo.findDetail(id); var video = videoService.findDetail(id);
if (video == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Video not found"); if (video == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Video not found");
return video; return video;
} }
@@ -41,7 +43,7 @@ public class VideoController {
if (title == null || title.isBlank()) { if (title == null || title.isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "title is required"); throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "title is required");
} }
repo.updateTitle(id, title); videoService.updateTitle(id, title);
cache.flush(); cache.flush();
return Map.of("ok", true); return Map.of("ok", true);
} }
@@ -49,7 +51,7 @@ public class VideoController {
@PostMapping("/{id}/skip") @PostMapping("/{id}/skip")
public Map<String, Object> skip(@PathVariable String id) { public Map<String, Object> skip(@PathVariable String id) {
AuthUtil.requireAdmin(); AuthUtil.requireAdmin();
repo.updateStatus(id, "skip"); videoService.updateStatus(id, "skip");
cache.flush(); cache.flush();
return Map.of("ok", true); return Map.of("ok", true);
} }
@@ -57,7 +59,7 @@ public class VideoController {
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
public Map<String, Object> delete(@PathVariable String id) { public Map<String, Object> delete(@PathVariable String id) {
AuthUtil.requireAdmin(); AuthUtil.requireAdmin();
repo.delete(id); videoService.delete(id);
cache.flush(); cache.flush();
return Map.of("ok", true); return Map.of("ok", true);
} }
@@ -66,7 +68,7 @@ public class VideoController {
public Map<String, Object> deleteVideoRestaurant( public Map<String, Object> deleteVideoRestaurant(
@PathVariable String videoId, @PathVariable String restaurantId) { @PathVariable String videoId, @PathVariable String restaurantId) {
AuthUtil.requireAdmin(); AuthUtil.requireAdmin();
repo.deleteVideoRestaurant(videoId, restaurantId); videoService.deleteVideoRestaurant(videoId, restaurantId);
cache.flush(); cache.flush();
return Map.of("ok", true); return Map.of("ok", true);
} }

View File

@@ -7,8 +7,6 @@ import com.tasteby.util.CuisineTypes;
import com.tasteby.util.JsonUtil; import com.tasteby.util.JsonUtil;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; 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.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; 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 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 PipelineService pipelineService;
private final OciGenAiService genAi; private final OciGenAiService genAi;
private final CacheService cache; private final CacheService cache;
private final ObjectMapper mapper; private final ObjectMapper mapper;
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
public VideoSseController(NamedParameterJdbcTemplate jdbc, public VideoSseController(VideoService videoService,
RestaurantService restaurantService,
PipelineService pipelineService, PipelineService pipelineService,
OciGenAiService genAi, OciGenAiService genAi,
CacheService cache, CacheService cache,
ObjectMapper mapper) { ObjectMapper mapper) {
this.jdbc = jdbc; this.videoService = videoService;
this.restaurantService = restaurantService;
this.pipelineService = pipelineService; this.pipelineService = pipelineService;
this.genAi = genAi; this.genAi = genAi;
this.cache = cache; this.cache = cache;
@@ -70,24 +71,7 @@ public class VideoSseController {
executor.execute(() -> { executor.execute(() -> {
try { try {
String sql = """ var rows = videoService.findVideosForBulkExtract();
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<String, Object> 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;
});
int total = rows.size(); int total = rows.size();
int totalRestaurants = 0; int totalRestaurants = 0;
@@ -131,22 +115,8 @@ public class VideoSseController {
executor.execute(() -> { executor.execute(() -> {
try { try {
String sql = """ var rows = restaurantService.findForRemapCuisine();
SELECT r.id, r.name, r.cuisine_type, rows = rows.stream().map(JsonUtil::lowerKeys).toList();
(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<String, Object> 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;
});
int total = rows.size(); int total = rows.size();
emit(emitter, Map.of("type", "start", "total", total)); emit(emitter, Map.of("type", "start", "total", total));
@@ -199,21 +169,12 @@ public class VideoSseController {
executor.execute(() -> { executor.execute(() -> {
try { try {
String sql = """ var rows = restaurantService.findForRemapFoods();
SELECT vr.id, r.name, r.cuisine_type, vr.foods_mentioned, v.title rows = rows.stream().map(r -> {
FROM video_restaurants vr var m = JsonUtil.lowerKeys(r);
JOIN restaurants r ON r.id = vr.restaurant_id m.put("foods", JsonUtil.parseStringList(m.get("foods_mentioned")));
JOIN videos v ON v.id = vr.video_id ORDER BY r.name
""";
var rows = jdbc.query(sql, new MapSqlParameterSource(), (rs, rowNum) -> {
Map<String, Object> 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"));
return m; return m;
}); }).toList();
int total = rows.size(); int total = rows.size();
emit(emitter, Map.of("type", "start", "total", total)); emit(emitter, Map.of("type", "start", "total", total));
@@ -330,8 +291,7 @@ public class VideoSseController {
missed.add(b); missed.add(b);
continue; continue;
} }
jdbc.update("UPDATE restaurants SET cuisine_type = :ct WHERE id = :id", restaurantService.updateCuisineType(id, newType);
new MapSqlParameterSource().addValue("ct", newType).addValue("id", id));
updated++; updated++;
} }
return new BatchResult(updated, missed); return new BatchResult(updated, missed);
@@ -383,10 +343,7 @@ public class VideoSseController {
missed.add(b); missed.add(b);
continue; continue;
} }
jdbc.update("UPDATE video_restaurants SET foods_mentioned = :foods WHERE id = :id", restaurantService.updateFoodsMentioned(id, mapper.writeValueAsString(newFoods));
new MapSqlParameterSource()
.addValue("foods", mapper.writeValueAsString(newFoods))
.addValue("id", id));
updated++; updated++;
} }
return new BatchResult(updated, missed); return new BatchResult(updated, missed);

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<String> channels;
private List<String> foodsMentioned;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<VideoRestaurantLink> restaurants;
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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<Channel> 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);
}

View File

@@ -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();
}

View File

@@ -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<Restaurant> 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<Map<String, Object>> findVideoLinks(@Param("restaurantId") String restaurantId);
void insertRestaurant(Restaurant r);
void updateRestaurant(Restaurant r);
void updateFields(@Param("id") String id, @Param("fields") Map<String, Object> 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<Map<String, Object>> findChannelsByRestaurantIds(@Param("ids") List<String> ids);
List<Map<String, Object>> findFoodsByRestaurantIds(@Param("ids") List<String> ids);
void updateCuisineType(@Param("id") String id, @Param("cuisineType") String cuisineType);
void updateFoodsMentioned(@Param("id") String id, @Param("foods") String foods);
List<Map<String, Object>> findForRemapCuisine();
List<Map<String, Object>> findForRemapFoods();
}

View File

@@ -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<Review> findByRestaurant(@Param("restaurantId") String restaurantId,
@Param("limit") int limit,
@Param("offset") int offset);
Map<String, Object> getAvgRating(@Param("restaurantId") String restaurantId);
List<Review> 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<Restaurant> getUserFavorites(@Param("userId") String userId);
}

View File

@@ -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<Restaurant> keywordSearch(@Param("query") String query, @Param("limit") int limit);
List<Map<String, Object>> findChannelsByRestaurantIds(@Param("ids") List<String> ids);
}

View File

@@ -0,0 +1,13 @@
package com.tasteby.mapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface StatsMapper {
void recordVisit();
int getTodayVisits();
int getTotalVisits();
}

View File

@@ -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<UserInfo> findAllWithCounts(@Param("limit") int limit, @Param("offset") int offset);
int countAll();
}

View File

@@ -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<VectorSearchResult> 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);
}

View File

@@ -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<VideoSummary> findAll(@Param("status") String status);
VideoDetail findDetail(@Param("id") String id);
List<VideoRestaurantLink> 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<String> getExistingVideoIds(@Param("channelId") String channelId);
String getLatestVideoDate(@Param("channelId") String channelId);
List<Map<String, Object>> findPendingVideos(@Param("limit") int limit);
void updateVideoFields(@Param("id") String id,
@Param("status") String status,
@Param("transcript") String transcript,
@Param("llmResponse") String llmResponse);
List<Map<String, Object>> findVideosForBulkExtract();
}

View File

@@ -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<Map<String, Object>> 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<String, Object> 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<String, Object> 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();
}
}

View File

@@ -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<Map<String, Object>> findAll(int limit, int offset,
String cuisine, String region, String channel) {
var conditions = new ArrayList<String>();
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<Map<String, Object>> rows = namedJdbc.queryForList(sql, params);
if (!rows.isEmpty()) {
attachChannels(rows);
attachFoodsMentioned(rows);
}
return rows;
}
public Map<String, Object> 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<Map<String, Object>> rows = namedJdbc.queryForList(sql, params);
return rows.isEmpty() ? null : normalizeRow(rows.getFirst());
}
public List<Map<String, Object>> 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<String, Object> 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<String, Object> 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<String, Object> fields) {
var sets = new ArrayList<String>();
var params = new MapSqlParameterSource("rid", id);
List<String> 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<String> foods, String evaluation, List<String> 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<String, Object> 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<Map<String, Object>> rows) {
List<String> 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<String>();
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<String, List<String>> 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<Map<String, Object>> rows) {
List<String> 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<String>();
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<String, List<String>> foodsMap = new HashMap<>();
namedJdbc.query(sql, params, (rs) -> {
String rid = rs.getString("RESTAURANT_ID");
List<String> 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<String> all = foodsMap.getOrDefault(id, List.of());
r.put("foods_mentioned", all.size() > 10 ? all.subList(0, 10) : all);
}
}
private Map<String, Object> normalizeRow(Map<String, Object> row) {
// Oracle returns uppercase keys; normalize to lowercase
var result = new LinkedHashMap<String, Object>();
for (var entry : row.entrySet()) {
result.put(entry.getKey().toLowerCase(), entry.getValue());
}
return result;
}
}

View File

@@ -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<String, Object> 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<String, Object> 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<String, Object> 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<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> 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<String, Object> mapReviewRow(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException {
var m = new LinkedHashMap<String, Object>();
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;
}
}

View File

@@ -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<String, Object> 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);
}
}

View File

@@ -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<String, Object> 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<String, Object> 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<Map<String, Object>> 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;
}
}

View File

@@ -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<Map<String, Object>> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<Map<String, Object>> restaurants = jdbc.query(restSql,
new MapSqlParameterSource("vid", videoDbId), (rs, rowNum) -> {
Map<String, Object> 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<Map<String, Object>> 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<String> getExistingVideoIds(String dbChannelId) {
String sql = "SELECT video_id FROM videos WHERE channel_id = :cid";
List<String> 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();
}
}

View File

@@ -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.googleapis.auth.oauth2.GoogleIdTokenVerifier;
import com.google.api.client.http.javanet.NetHttpTransport; import com.google.api.client.http.javanet.NetHttpTransport;
import com.google.api.client.json.gson.GsonFactory; import com.google.api.client.json.gson.GsonFactory;
import com.tasteby.repository.UserRepository; import com.tasteby.domain.UserInfo;
import com.tasteby.security.JwtTokenProvider; import com.tasteby.security.JwtTokenProvider;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -16,12 +16,12 @@ import java.util.Map;
@Service @Service
public class AuthService { public class AuthService {
private final UserRepository userRepo; private final UserService userService;
private final JwtTokenProvider jwtProvider; private final JwtTokenProvider jwtProvider;
private final GoogleIdTokenVerifier verifier; private final GoogleIdTokenVerifier verifier;
public AuthService(UserRepository userRepo, JwtTokenProvider jwtProvider) { public AuthService(UserService userService, JwtTokenProvider jwtProvider) {
this.userRepo = userRepo; this.userService = userService;
this.jwtProvider = jwtProvider; this.jwtProvider = jwtProvider;
this.verifier = new GoogleIdTokenVerifier.Builder( this.verifier = new GoogleIdTokenVerifier.Builder(
new NetHttpTransport(), GsonFactory.getDefaultInstance()) new NetHttpTransport(), GsonFactory.getDefaultInstance())
@@ -37,15 +37,21 @@ public class AuthService {
} }
GoogleIdToken.Payload payload = idToken.getPayload(); GoogleIdToken.Payload payload = idToken.getPayload();
Map<String, Object> user = userRepo.findOrCreate( UserInfo user = userService.findOrCreate(
"google", "google",
payload.getSubject(), payload.getSubject(),
payload.getEmail(), payload.getEmail(),
(String) payload.get("name"), (String) payload.get("name"),
(String) payload.get("picture") (String) payload.get("picture"));
);
String accessToken = jwtProvider.createToken(user); // Convert to Map for JWT
Map<String, Object> 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); return Map.of("access_token", accessToken, "user", user);
} catch (ResponseStatusException e) { } catch (ResponseStatusException e) {
throw e; throw e;
@@ -54,8 +60,8 @@ public class AuthService {
} }
} }
public Map<String, Object> getCurrentUser(String userId) { public UserInfo getCurrentUser(String userId) {
Map<String, Object> user = userRepo.findById(userId); UserInfo user = userService.findById(userId);
if (user == null) { if (user == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found"); throw new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found");
} }

View File

@@ -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<Channel> 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);
}
}

View File

@@ -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<String, Object> 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();
}
}

View File

@@ -1,9 +1,8 @@
package com.tasteby.service; package com.tasteby.service;
import com.tasteby.domain.DaemonConfig;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; 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.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -18,16 +17,16 @@ public class DaemonScheduler {
private static final Logger log = LoggerFactory.getLogger(DaemonScheduler.class); private static final Logger log = LoggerFactory.getLogger(DaemonScheduler.class);
private final NamedParameterJdbcTemplate jdbc; private final DaemonConfigService daemonConfigService;
private final YouTubeService youTubeService; private final YouTubeService youTubeService;
private final PipelineService pipelineService; private final PipelineService pipelineService;
private final CacheService cacheService; private final CacheService cacheService;
public DaemonScheduler(NamedParameterJdbcTemplate jdbc, public DaemonScheduler(DaemonConfigService daemonConfigService,
YouTubeService youTubeService, YouTubeService youTubeService,
PipelineService pipelineService, PipelineService pipelineService,
CacheService cacheService) { CacheService cacheService) {
this.jdbc = jdbc; this.daemonConfigService = daemonConfigService;
this.youTubeService = youTubeService; this.youTubeService = youTubeService;
this.pipelineService = pipelineService; this.pipelineService = pipelineService;
this.cacheService = cacheService; this.cacheService = cacheService;
@@ -39,13 +38,12 @@ public class DaemonScheduler {
var config = getConfig(); var config = getConfig();
if (config == null) return; if (config == null) return;
// Channel scanning if (config.isScanEnabled()) {
if (config.scanEnabled) { Instant lastScan = config.getLastScanAt() != null ? config.getLastScanAt().toInstant() : null;
Instant lastScan = config.lastScanAt; if (lastScan == null || Instant.now().isAfter(lastScan.plus(config.getScanIntervalMin(), ChronoUnit.MINUTES))) {
if (lastScan == null || Instant.now().isAfter(lastScan.plus(config.scanIntervalMin, ChronoUnit.MINUTES))) {
log.info("Running scheduled channel scan..."); log.info("Running scheduled channel scan...");
int newVideos = youTubeService.scanAllChannels(); int newVideos = youTubeService.scanAllChannels();
updateLastScan(); daemonConfigService.updateLastScan();
if (newVideos > 0) { if (newVideos > 0) {
cacheService.flush(); cacheService.flush();
log.info("Scan completed: {} new videos", newVideos); log.info("Scan completed: {} new videos", newVideos);
@@ -53,13 +51,12 @@ public class DaemonScheduler {
} }
} }
// Video processing if (config.isProcessEnabled()) {
if (config.processEnabled) { Instant lastProcess = config.getLastProcessAt() != null ? config.getLastProcessAt().toInstant() : null;
Instant lastProcess = config.lastProcessAt; if (lastProcess == null || Instant.now().isAfter(lastProcess.plus(config.getProcessIntervalMin(), ChronoUnit.MINUTES))) {
if (lastProcess == null || Instant.now().isAfter(lastProcess.plus(config.processIntervalMin, ChronoUnit.MINUTES))) { log.info("Running scheduled video processing (limit={})...", config.getProcessLimit());
log.info("Running scheduled video processing (limit={})...", config.processLimit); int restaurants = pipelineService.processPending(config.getProcessLimit());
int restaurants = pipelineService.processPending(config.processLimit); daemonConfigService.updateLastProcess();
updateLastProcess();
if (restaurants > 0) { if (restaurants > 0) {
cacheService.flush(); cacheService.flush();
log.info("Processing completed: {} restaurants extracted", restaurants); 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() { private DaemonConfig getConfig() {
try { try {
var rows = jdbc.queryForList( return daemonConfigService.getConfig();
"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
);
} catch (Exception e) { } catch (Exception e) {
log.debug("Cannot read daemon config: {}", e.getMessage()); log.debug("Cannot read daemon config: {}", e.getMessage());
return null; 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();
}
} }

View File

@@ -1,10 +1,7 @@
package com.tasteby.service; package com.tasteby.service;
import com.tasteby.repository.RestaurantRepository;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.HashMap; import java.util.HashMap;
@@ -23,26 +20,26 @@ public class PipelineService {
private static final Logger log = LoggerFactory.getLogger(PipelineService.class); private static final Logger log = LoggerFactory.getLogger(PipelineService.class);
private final NamedParameterJdbcTemplate jdbc;
private final YouTubeService youTubeService; private final YouTubeService youTubeService;
private final ExtractorService extractorService; private final ExtractorService extractorService;
private final GeocodingService geocodingService; private final GeocodingService geocodingService;
private final RestaurantRepository restaurantRepo; private final RestaurantService restaurantService;
private final VideoService videoService;
private final VectorService vectorService; private final VectorService vectorService;
private final CacheService cacheService; private final CacheService cacheService;
public PipelineService(NamedParameterJdbcTemplate jdbc, public PipelineService(YouTubeService youTubeService,
YouTubeService youTubeService,
ExtractorService extractorService, ExtractorService extractorService,
GeocodingService geocodingService, GeocodingService geocodingService,
RestaurantRepository restaurantRepo, RestaurantService restaurantService,
VideoService videoService,
VectorService vectorService, VectorService vectorService,
CacheService cacheService) { CacheService cacheService) {
this.jdbc = jdbc;
this.youTubeService = youTubeService; this.youTubeService = youTubeService;
this.extractorService = extractorService; this.extractorService = extractorService;
this.geocodingService = geocodingService; this.geocodingService = geocodingService;
this.restaurantRepo = restaurantRepo; this.restaurantService = restaurantService;
this.videoService = videoService;
this.vectorService = vectorService; this.vectorService = vectorService;
this.cacheService = cacheService; this.cacheService = cacheService;
} }
@@ -117,13 +114,13 @@ public class PipelineService {
data.put("rating", geo != null ? geo.get("rating") : null); data.put("rating", geo != null ? geo.get("rating") : null);
data.put("rating_count", geo != null ? geo.get("rating_count") : 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 // Link video <-> restaurant
var foods = restData.get("foods_mentioned"); var foods = restData.get("foods_mentioned");
var evaluation = restData.get("evaluation"); var evaluation = restData.get("evaluation");
var guests = restData.get("guests"); var guests = restData.get("guests");
restaurantRepo.linkVideoRestaurant( restaurantService.linkVideoRestaurant(
videoDbId, restId, videoDbId, restId,
foods instanceof List<?> ? (List<String>) foods : null, foods instanceof List<?> ? (List<String>) foods : null,
evaluation instanceof String ? (String) evaluation : null, evaluation instanceof String ? (String) evaluation : null,
@@ -153,46 +150,20 @@ public class PipelineService {
* Process up to `limit` pending videos. * Process up to `limit` pending videos.
*/ */
public int processPending(int limit) { public int processPending(int limit) {
String sql = """ var videos = videoService.findPendingVideos(limit);
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));
if (videos.isEmpty()) { if (videos.isEmpty()) {
log.info("No pending videos"); log.info("No pending videos");
return 0; return 0;
} }
int total = 0; int total = 0;
for (var v : videos) { for (var v : videos) {
// Normalize Oracle uppercase keys total += processVideo(v);
var normalized = normalizeKeys(v);
total += processVideo(normalized);
} }
if (total > 0) cacheService.flush(); if (total > 0) cacheService.flush();
return total; return total;
} }
private void updateVideoStatus(String videoDbId, String status, String transcript, String llmRaw) { private void updateVideoStatus(String videoDbId, String status, String transcript, String llmRaw) {
var sets = new java.util.ArrayList<>(List.of("status = :st", "processed_at = SYSTIMESTAMP")); videoService.updateVideoFields(videoDbId, status, transcript, llmRaw);
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<String, Object> normalizeKeys(Map<String, Object> row) {
var result = new HashMap<String, Object>();
for (var entry : row.entrySet()) {
result.put(entry.getKey().toLowerCase(), entry.getValue());
}
return result;
} }
} }

View File

@@ -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<Restaurant> findAll(int limit, int offset, String cuisine, String region, String channel) {
List<Restaurant> 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<Map<String, Object>> findVideoLinks(String restaurantId) {
var rows = mapper.findVideoLinks(restaurantId);
return rows.stream().map(JsonUtil::lowerKeys).toList();
}
public void update(String id, Map<String, Object> 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<String, Object> 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<String> foods, String evaluation, List<String> 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<Map<String, Object>> findForRemapCuisine() {
return mapper.findForRemapCuisine();
}
public List<Map<String, Object>> findForRemapFoods() {
return mapper.findForRemapFoods();
}
private void enrichRestaurants(List<Restaurant> restaurants) {
if (restaurants.isEmpty()) return;
List<String> ids = restaurants.stream().map(Restaurant::getId).filter(Objects::nonNull).toList();
if (ids.isEmpty()) return;
// Channels
List<Map<String, Object>> channelRows = mapper.findChannelsByRestaurantIds(ids);
Map<String, List<String>> 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<Map<String, Object>> foodRows = mapper.findFoodsByRestaurantIds(ids);
Map<String, Set<String>> 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<String> 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<String> 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);
}
}

View File

@@ -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<Review> findByRestaurant(String restaurantId, int limit, int offset) {
return mapper.findByRestaurant(restaurantId, limit, offset);
}
public Map<String, Object> getAvgRating(String restaurantId) {
Map<String, Object> 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<Review> 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<Restaurant> getUserFavorites(String userId) {
return mapper.getUserFavorites(userId);
}
}

View File

@@ -1,10 +1,9 @@
package com.tasteby.service; 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.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.*; import java.util.*;
@@ -14,48 +13,41 @@ public class SearchService {
private static final Logger log = LoggerFactory.getLogger(SearchService.class); private static final Logger log = LoggerFactory.getLogger(SearchService.class);
private final NamedParameterJdbcTemplate jdbc; private final SearchMapper searchMapper;
private final RestaurantRepository restaurantRepo; private final RestaurantService restaurantService;
private final VectorService vectorService; private final VectorService vectorService;
private final CacheService cache; private final CacheService cache;
public SearchService(NamedParameterJdbcTemplate jdbc, public SearchService(SearchMapper searchMapper,
RestaurantRepository restaurantRepo, RestaurantService restaurantService,
VectorService vectorService, VectorService vectorService,
CacheService cache) { CacheService cache) {
this.jdbc = jdbc; this.searchMapper = searchMapper;
this.restaurantRepo = restaurantRepo; this.restaurantService = restaurantService;
this.vectorService = vectorService; this.vectorService = vectorService;
this.cache = cache; this.cache = cache;
} }
public List<Map<String, Object>> search(String q, String mode, int limit) { public List<Restaurant> search(String q, String mode, int limit) {
String key = cache.makeKey("search", "q=" + q, "m=" + mode, "l=" + limit); String key = cache.makeKey("search", "q=" + q, "m=" + mode, "l=" + limit);
String cached = cache.getRaw(key); String cached = cache.getRaw(key);
if (cached != null) { if (cached != null) {
// Deserialize from cache
try { try {
var mapper = new com.fasterxml.jackson.databind.ObjectMapper(); 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<List<Restaurant>>() {});
} catch (Exception ignored) {} } catch (Exception ignored) {}
} }
List<Map<String, Object>> result; List<Restaurant> result;
switch (mode) { switch (mode) {
case "semantic" -> result = semanticSearch(q, limit); case "semantic" -> result = semanticSearch(q, limit);
case "hybrid" -> { case "hybrid" -> {
var kw = keywordSearch(q, limit); var kw = keywordSearch(q, limit);
var sem = semanticSearch(q, limit); var sem = semanticSearch(q, limit);
Set<String> seen = new HashSet<>(); Set<String> seen = new HashSet<>();
var merged = new ArrayList<Map<String, Object>>(); var merged = new ArrayList<Restaurant>();
for (var r : kw) { for (var r : kw) { if (seen.add(r.getId())) merged.add(r); }
String id = (String) r.get("id"); for (var r : sem) { if (seen.add(r.getId())) merged.add(r); }
if (seen.add(id)) merged.add(r);
}
for (var r : sem) {
String id = (String) r.get("id");
if (seen.add(id)) merged.add(r);
}
result = merged.size() > limit ? merged.subList(0, limit) : merged; result = merged.size() > limit ? merged.subList(0, limit) : merged;
} }
default -> result = keywordSearch(q, limit); default -> result = keywordSearch(q, limit);
@@ -65,53 +57,33 @@ public class SearchService {
return result; return result;
} }
private List<Map<String, Object>> keywordSearch(String q, int limit) { private List<Restaurant> keywordSearch(String q, int limit) {
String pattern = "%" + q + "%"; String pattern = "%" + q + "%";
String sql = """ List<Restaurant> results = searchMapper.keywordSearch(pattern, limit);
SELECT DISTINCT r.id, r.name, r.address, r.region, r.latitude, r.longitude, if (!results.isEmpty()) {
r.cuisine_type, r.price_range, r.google_place_id, attachChannels(results);
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<Map<String, Object>> rows = jdbc.queryForList(sql, params);
if (!rows.isEmpty()) {
attachChannels(rows);
} }
return rows; return results;
} }
private List<Map<String, Object>> semanticSearch(String q, int limit) { private List<Restaurant> semanticSearch(String q, int limit) {
try { try {
var similar = vectorService.searchSimilar(q, Math.max(30, limit * 3), 0.57); var similar = vectorService.searchSimilar(q, Math.max(30, limit * 3), 0.57);
if (similar.isEmpty()) return List.of(); if (similar.isEmpty()) return List.of();
// Deduplicate by restaurant_id, preserving distance order
Set<String> seen = new LinkedHashSet<>(); Set<String> seen = new LinkedHashSet<>();
for (var s : similar) { for (var s : similar) {
seen.add((String) s.get("restaurant_id")); seen.add((String) s.get("restaurant_id"));
} }
List<Map<String, Object>> results = new ArrayList<>(); List<Restaurant> results = new ArrayList<>();
for (String rid : seen) { for (String rid : seen) {
if (results.size() >= limit) break; if (results.size() >= limit) break;
var r = restaurantRepo.findById(rid); var r = restaurantService.findById(rid);
if (r != null && r.get("latitude") != null) { if (r != null && r.getLatitude() != null) {
results.add(r); results.add(r);
} }
} }
if (!results.isEmpty()) attachChannels(results);
return results; return results;
} catch (Exception e) { } catch (Exception e) {
log.warn("Semantic search failed, falling back to keyword: {}", e.getMessage()); log.warn("Semantic search failed, falling back to keyword: {}", e.getMessage());
@@ -119,34 +91,21 @@ public class SearchService {
} }
} }
private void attachChannels(List<Map<String, Object>> rows) { private void attachChannels(List<Restaurant> restaurants) {
List<String> ids = rows.stream() List<String> ids = restaurants.stream().map(Restaurant::getId).filter(Objects::nonNull).toList();
.map(r -> (String) r.get("id"))
.filter(Objects::nonNull).toList();
if (ids.isEmpty()) return; if (ids.isEmpty()) return;
var params = new MapSqlParameterSource(); var channelRows = searchMapper.findChannelsByRestaurantIds(ids);
var placeholders = new ArrayList<String>();
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<String, List<String>> chMap = new HashMap<>(); Map<String, List<String>> chMap = new HashMap<>();
jdbc.query(sql, params, rs -> { for (var row : channelRows) {
chMap.computeIfAbsent(rs.getString("RESTAURANT_ID"), k -> new ArrayList<>()) String rid = (String) row.getOrDefault("restaurant_id", row.get("RESTAURANT_ID"));
.add(rs.getString("CHANNEL_NAME")); String ch = (String) row.getOrDefault("channel_name", row.get("CHANNEL_NAME"));
}); if (rid != null && ch != null) {
for (var r : rows) { chMap.computeIfAbsent(rid, k -> new ArrayList<>()).add(ch);
String id = (String) r.get("id"); }
r.put("channels", chMap.getOrDefault(id, List.of())); }
for (var r : restaurants) {
r.setChannels(chMap.getOrDefault(r.getId(), List.of()));
} }
} }
} }

View File

@@ -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();
}
}

View File

@@ -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<UserInfo> findAllWithCounts(int limit, int offset) {
return mapper.findAllWithCounts(limit, offset);
}
public int countAll() {
return mapper.countAll();
}
}

View File

@@ -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<VideoSummary> findAll(String status) {
return mapper.findAll(status);
}
public VideoDetail findDetail(String id) {
VideoDetail detail = mapper.findDetail(id);
if (detail == null) return null;
List<VideoRestaurantLink> 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<Map<String, Object>> videos) {
Set<String> 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<String> getExistingVideoIds(String channelId) {
return new HashSet<>(mapper.getExistingVideoIds(channelId));
}
public String getLatestVideoDate(String channelId) {
return mapper.getLatestVideoDate(channelId);
}
public List<Map<String, Object>> findPendingVideos(int limit) {
return mapper.findPendingVideos(limit).stream()
.map(JsonUtil::lowerKeys).toList();
}
public List<Map<String, Object>> 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);
}
}

View File

@@ -10,8 +10,7 @@ import io.github.thoroldvix.api.YoutubeTranscriptApi;
import com.microsoft.playwright.*; import com.microsoft.playwright.*;
import com.microsoft.playwright.options.Cookie; import com.microsoft.playwright.options.Cookie;
import com.microsoft.playwright.options.WaitUntilState; import com.microsoft.playwright.options.WaitUntilState;
import com.tasteby.repository.ChannelRepository; import com.tasteby.domain.Channel;
import com.tasteby.repository.VideoRepository;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
@@ -33,20 +32,20 @@ public class YouTubeService {
private final WebClient webClient; private final WebClient webClient;
private final ObjectMapper mapper; private final ObjectMapper mapper;
private final ChannelRepository channelRepo; private final ChannelService channelService;
private final VideoRepository videoRepo; private final VideoService videoService;
private final String apiKey; private final String apiKey;
public YouTubeService(ObjectMapper mapper, public YouTubeService(ObjectMapper mapper,
ChannelRepository channelRepo, ChannelService channelService,
VideoRepository videoRepo, VideoService videoService,
@Value("${app.google.youtube-api-key}") String apiKey) { @Value("${app.google.youtube-api-key}") String apiKey) {
this.webClient = WebClient.builder() this.webClient = WebClient.builder()
.baseUrl("https://www.googleapis.com/youtube/v3") .baseUrl("https://www.googleapis.com/youtube/v3")
.build(); .build();
this.mapper = mapper; this.mapper = mapper;
this.channelRepo = channelRepo; this.channelService = channelService;
this.videoRepo = videoRepo; this.videoService = videoService;
this.apiKey = apiKey; this.apiKey = apiKey;
} }
@@ -151,13 +150,13 @@ public class YouTubeService {
* Scan a single channel for new videos. Returns scan result map. * Scan a single channel for new videos. Returns scan result map.
*/ */
public Map<String, Object> scanChannel(String channelId, boolean full) { public Map<String, Object> scanChannel(String channelId, boolean full) {
var ch = channelRepo.findByChannelId(channelId); Channel ch = channelService.findByChannelId(channelId);
if (ch == null) return null; if (ch == null) return null;
String dbId = (String) ch.get("id"); String dbId = ch.getId();
String titleFilter = (String) ch.get("title_filter"); String titleFilter = ch.getTitleFilter();
String after = full ? null : videoRepo.getLatestVideoDate(dbId); String after = full ? null : videoService.getLatestVideoDate(dbId);
Set<String> existing = videoRepo.getExistingVideoIds(dbId); Set<String> existing = videoService.getExistingVideoIds(dbId);
List<Map<String, Object>> allFetched = fetchChannelVideos(channelId, after, true); List<Map<String, Object>> allFetched = fetchChannelVideos(channelId, after, true);
int totalFetched = allFetched.size(); int totalFetched = allFetched.size();
@@ -169,7 +168,7 @@ public class YouTubeService {
candidates.add(v); candidates.add(v);
} }
int newCount = videoRepo.saveVideosBatch(dbId, candidates); int newCount = videoService.saveVideosBatch(dbId, candidates);
return Map.of( return Map.of(
"total_fetched", totalFetched, "total_fetched", totalFetched,
"new_videos", newCount, "new_videos", newCount,
@@ -181,17 +180,16 @@ public class YouTubeService {
* Scan all active channels. Returns total new video count. * Scan all active channels. Returns total new video count.
*/ */
public int scanAllChannels() { public int scanAllChannels() {
var channels = channelRepo.findAllActive(); List<Channel> channels = channelService.findAllActive();
int totalNew = 0; int totalNew = 0;
for (var ch : channels) { for (var ch : channels) {
try { try {
String chId = (String) ch.get("channel_id"); var result = scanChannel(ch.getChannelId(), false);
var result = scanChannel(chId, false);
if (result != null) { if (result != null) {
totalNew += ((Number) result.get("new_videos")).intValue(); totalNew += ((Number) result.get("new_videos")).intValue();
} }
} catch (Exception e) { } 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; return totalNew;

View File

@@ -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();
}
}

View File

@@ -29,6 +29,7 @@ spring:
jackson: jackson:
default-property-inclusion: non_null default-property-inclusion: non_null
property-naming-strategy: SNAKE_CASE
serialization: serialization:
write-dates-as-timestamps: false write-dates-as-timestamps: false
@@ -57,7 +58,13 @@ app:
cache: cache:
ttl-seconds: 600 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: logging:
level: level:
com.tasteby: DEBUG com.tasteby: DEBUG
org.springframework.jdbc: DEBUG com.tasteby.mapper: DEBUG

View File

@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.tasteby.mapper.ChannelMapper">
<resultMap id="channelResultMap" type="com.tasteby.domain.Channel">
<id property="id" column="id"/>
<result property="channelId" column="channel_id"/>
<result property="channelName" column="channel_name"/>
<result property="titleFilter" column="title_filter"/>
<result property="videoCount" column="video_count"/>
<result property="lastVideoAt" column="last_video_at"/>
</resultMap>
<select id="findAllActive" resultMap="channelResultMap">
SELECT c.id, c.channel_id, c.channel_name, c.title_filter,
(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
</select>
<insert id="insert">
INSERT INTO channels (id, channel_id, channel_name, title_filter)
VALUES (#{id}, #{channelId}, #{channelName}, #{titleFilter})
</insert>
<update id="deactivateByChannelId">
UPDATE channels SET is_active = 0
WHERE channel_id = #{channelId} AND is_active = 1
</update>
<update id="deactivateById">
UPDATE channels SET is_active = 0
WHERE id = #{id} AND is_active = 1
</update>
<select id="findByChannelId" resultMap="channelResultMap">
SELECT id, channel_id, channel_name, title_filter
FROM channels
WHERE channel_id = #{channelId} AND is_active = 1
</select>
</mapper>

View File

@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.tasteby.mapper.DaemonConfigMapper">
<resultMap id="daemonConfigResultMap" type="com.tasteby.domain.DaemonConfig">
<id property="id" column="id"/>
<result property="scanEnabled" column="scan_enabled" javaType="boolean"/>
<result property="scanIntervalMin" column="scan_interval_min"/>
<result property="processEnabled" column="process_enabled" javaType="boolean"/>
<result property="processIntervalMin" column="process_interval_min"/>
<result property="processLimit" column="process_limit"/>
<result property="lastScanAt" column="last_scan_at"/>
<result property="lastProcessAt" column="last_process_at"/>
<result property="updatedAt" column="updated_at"/>
</resultMap>
<select id="getConfig" resultMap="daemonConfigResultMap">
SELECT id, 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
</select>
<update id="updateConfig">
UPDATE daemon_config
<set>
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,
</set>
WHERE id = 1
</update>
<update id="updateLastScan">
UPDATE daemon_config SET last_scan_at = SYSTIMESTAMP WHERE id = 1
</update>
<update id="updateLastProcess">
UPDATE daemon_config SET last_process_at = SYSTIMESTAMP WHERE id = 1
</update>
</mapper>

View File

@@ -0,0 +1,230 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.tasteby.mapper.RestaurantMapper">
<!-- ===== Result Maps ===== -->
<resultMap id="restaurantMap" type="Restaurant">
<id property="id" column="id"/>
<result property="name" column="name"/>
<result property="address" column="address"/>
<result property="region" column="region"/>
<result property="latitude" column="latitude"/>
<result property="longitude" column="longitude"/>
<result property="cuisineType" column="cuisine_type"/>
<result property="priceRange" column="price_range"/>
<result property="phone" column="phone"/>
<result property="website" column="website"/>
<result property="googlePlaceId" column="google_place_id"/>
<result property="businessStatus" column="business_status"/>
<result property="rating" column="rating"/>
<result property="ratingCount" column="rating_count"/>
<result property="updatedAt" column="updated_at"/>
</resultMap>
<!-- ===== Queries ===== -->
<select id="findAll" resultMap="restaurantMap">
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
<if test="channel != null and channel != ''">
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
</if>
<where>
r.latitude IS NOT NULL
AND EXISTS (SELECT 1 FROM video_restaurants vr0 WHERE vr0.restaurant_id = r.id)
<if test="cuisine != null and cuisine != ''">
AND r.cuisine_type = #{cuisine}
</if>
<if test="region != null and region != ''">
AND r.region LIKE '%' || #{region} || '%'
</if>
<if test="channel != null and channel != ''">
AND c_f.channel_name = #{channel}
</if>
</where>
ORDER BY r.updated_at DESC
OFFSET #{offset} ROWS FETCH NEXT #{limit} ROWS ONLY
</select>
<select id="findById" resultMap="restaurantMap">
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}
</select>
<select id="findVideoLinks" resultType="map">
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 = #{restaurantId}
ORDER BY v.published_at DESC
</select>
<!-- ===== Insert ===== -->
<insert id="insertRestaurant">
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})
</insert>
<!-- ===== Update with COALESCE ===== -->
<update id="updateRestaurant">
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>
<!-- ===== Dynamic field update ===== -->
<update id="updateFields">
UPDATE restaurants SET
<trim suffixOverrides=",">
<if test="fields.containsKey('name')">
name = #{fields.name},
</if>
<if test="fields.containsKey('address')">
address = #{fields.address},
</if>
<if test="fields.containsKey('region')">
region = #{fields.region},
</if>
<if test="fields.containsKey('cuisine_type')">
cuisine_type = #{fields.cuisine_type},
</if>
<if test="fields.containsKey('price_range')">
price_range = #{fields.price_range},
</if>
<if test="fields.containsKey('phone')">
phone = #{fields.phone},
</if>
<if test="fields.containsKey('website')">
website = #{fields.website},
</if>
<if test="fields.containsKey('latitude')">
latitude = #{fields.latitude},
</if>
<if test="fields.containsKey('longitude')">
longitude = #{fields.longitude},
</if>
updated_at = SYSTIMESTAMP,
</trim>
WHERE id = #{id}
</update>
<!-- ===== Cascade deletes ===== -->
<delete id="deleteVectors">
DELETE FROM restaurant_vectors WHERE restaurant_id = #{id}
</delete>
<delete id="deleteReviews">
DELETE FROM user_reviews WHERE restaurant_id = #{id}
</delete>
<delete id="deleteFavorites">
DELETE FROM user_favorites WHERE restaurant_id = #{id}
</delete>
<delete id="deleteVideoRestaurants">
DELETE FROM video_restaurants WHERE restaurant_id = #{id}
</delete>
<delete id="deleteRestaurant">
DELETE FROM restaurants WHERE id = #{id}
</delete>
<!-- ===== Link video-restaurant ===== -->
<insert id="linkVideoRestaurant">
INSERT INTO video_restaurants (id, video_id, restaurant_id, foods_mentioned, evaluation, guests)
VALUES (#{id}, #{videoId}, #{restaurantId}, #{foods}, #{evaluation}, #{guests})
</insert>
<!-- ===== Lookups ===== -->
<select id="findIdByPlaceId" resultType="string">
SELECT id FROM restaurants WHERE google_place_id = #{placeId} FETCH FIRST 1 ROWS ONLY
</select>
<select id="findIdByName" resultType="string">
SELECT id FROM restaurants WHERE name = #{name} FETCH FIRST 1 ROWS ONLY
</select>
<!-- ===== Batch enrichment queries ===== -->
<select id="findChannelsByRestaurantIds" resultType="map">
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
<foreach item="id" collection="ids" open="(" separator="," close=")">
#{id}
</foreach>
</select>
<select id="findFoodsByRestaurantIds" resultType="map">
SELECT vr.restaurant_id, vr.foods_mentioned
FROM video_restaurants vr
WHERE vr.restaurant_id IN
<foreach item="id" collection="ids" open="(" separator="," close=")">
#{id}
</foreach>
</select>
<!-- ===== Remap operations ===== -->
<update id="updateCuisineType">
UPDATE restaurants SET cuisine_type = #{cuisineType} WHERE id = #{id}
</update>
<update id="updateFoodsMentioned">
UPDATE video_restaurants SET foods_mentioned = #{foods} WHERE id = #{id}
</update>
<select id="findForRemapCuisine" resultType="map">
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
</select>
<select id="findForRemapFoods" resultType="map">
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
</select>
</mapper>

View File

@@ -0,0 +1,130 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.tasteby.mapper.ReviewMapper">
<resultMap id="reviewResultMap" type="com.tasteby.domain.Review">
<id property="id" column="id"/>
<result property="userId" column="user_id"/>
<result property="restaurantId" column="restaurant_id"/>
<result property="rating" column="rating"/>
<result property="reviewText" column="review_text" typeHandler="com.tasteby.config.ClobTypeHandler"/>
<result property="visitedAt" column="visited_at"/>
<result property="createdAt" column="created_at"/>
<result property="updatedAt" column="updated_at"/>
<result property="userNickname" column="nickname"/>
<result property="userAvatarUrl" column="avatar_url"/>
<result property="restaurantName" column="restaurant_name"/>
</resultMap>
<resultMap id="restaurantResultMap" type="com.tasteby.domain.Restaurant">
<id property="id" column="id"/>
<result property="name" column="name"/>
<result property="address" column="address"/>
<result property="region" column="region"/>
<result property="latitude" column="latitude"/>
<result property="longitude" column="longitude"/>
<result property="cuisineType" column="cuisine_type"/>
<result property="priceRange" column="price_range"/>
<result property="googlePlaceId" column="google_place_id"/>
<result property="businessStatus" column="business_status"/>
<result property="rating" column="rating"/>
<result property="ratingCount" column="rating_count"/>
<result property="updatedAt" column="created_at"/>
</resultMap>
<insert id="insertReview">
INSERT INTO user_reviews (id, user_id, restaurant_id, rating, review_text, visited_at)
VALUES (#{id}, #{userId}, #{restaurantId}, #{rating}, #{reviewText},
<choose>
<when test="visitedAt != null">TO_DATE(#{visitedAt}, 'YYYY-MM-DD')</when>
<otherwise>NULL</otherwise>
</choose>)
</insert>
<update id="updateReview">
UPDATE user_reviews SET
rating = COALESCE(#{rating}, rating),
review_text = COALESCE(#{reviewText}, review_text),
visited_at = COALESCE(
<choose>
<when test="visitedAt != null">TO_DATE(#{visitedAt}, 'YYYY-MM-DD')</when>
<otherwise>NULL</otherwise>
</choose>, visited_at),
updated_at = SYSTIMESTAMP
WHERE id = #{id} AND user_id = #{userId}
</update>
<delete id="deleteReview">
DELETE FROM user_reviews WHERE id = #{id} AND user_id = #{userId}
</delete>
<select id="findById" resultMap="reviewResultMap">
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}
</select>
<select id="findByRestaurant" resultMap="reviewResultMap">
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 = #{restaurantId}
ORDER BY r.created_at DESC
OFFSET #{offset} ROWS FETCH NEXT #{limit} ROWS ONLY
</select>
<select id="getAvgRating" resultType="map">
SELECT ROUND(AVG(rating), 1) AS avg_rating, COUNT(*) AS review_count
FROM user_reviews
WHERE restaurant_id = #{restaurantId}
</select>
<select id="findByUser" resultMap="reviewResultMap">
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 = #{userId}
ORDER BY r.created_at DESC
OFFSET #{offset} ROWS FETCH NEXT #{limit} ROWS ONLY
</select>
<select id="countFavorite" resultType="int">
SELECT COUNT(*) FROM user_favorites
WHERE user_id = #{userId} AND restaurant_id = #{restaurantId}
</select>
<insert id="insertFavorite">
INSERT INTO user_favorites (id, user_id, restaurant_id)
VALUES (#{id}, #{userId}, #{restaurantId})
</insert>
<delete id="deleteFavorite">
DELETE FROM user_favorites
WHERE user_id = #{userId} AND restaurant_id = #{restaurantId}
</delete>
<select id="findFavoriteId" resultType="string">
SELECT id FROM user_favorites
WHERE user_id = #{userId} AND restaurant_id = #{restaurantId}
</select>
<select id="getUserFavorites" resultMap="restaurantResultMap">
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 = #{userId}
ORDER BY f.created_at DESC
</select>
</mapper>

View File

@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.tasteby.mapper.SearchMapper">
<resultMap id="restaurantMap" type="Restaurant">
<id property="id" column="id"/>
<result property="name" column="name"/>
<result property="address" column="address"/>
<result property="region" column="region"/>
<result property="latitude" column="latitude"/>
<result property="longitude" column="longitude"/>
<result property="cuisineType" column="cuisine_type"/>
<result property="priceRange" column="price_range"/>
<result property="googlePlaceId" column="google_place_id"/>
<result property="businessStatus" column="business_status"/>
<result property="rating" column="rating"/>
<result property="ratingCount" column="rating_count"/>
</resultMap>
<select id="keywordSearch" resultMap="restaurantMap">
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(#{query})
OR UPPER(r.address) LIKE UPPER(#{query})
OR UPPER(r.region) LIKE UPPER(#{query})
OR UPPER(r.cuisine_type) LIKE UPPER(#{query})
OR UPPER(vr.foods_mentioned) LIKE UPPER(#{query})
OR UPPER(v.title) LIKE UPPER(#{query}))
FETCH FIRST #{limit} ROWS ONLY
</select>
<select id="findChannelsByRestaurantIds" resultType="map">
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
<foreach item="id" collection="ids" open="(" separator="," close=")">
#{id}
</foreach>
</select>
</mapper>

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.tasteby.mapper.StatsMapper">
<update id="recordVisit">
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)
</update>
<select id="getTodayVisits" resultType="int">
SELECT NVL(visit_count, 0)
FROM site_visits
WHERE visit_date = TRUNC(SYSDATE)
</select>
<select id="getTotalVisits" resultType="int">
SELECT NVL(SUM(visit_count), 0)
FROM site_visits
</select>
</mapper>

View File

@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.tasteby.mapper.UserMapper">
<resultMap id="userResultMap" type="com.tasteby.domain.UserInfo">
<id property="id" column="id"/>
<result property="email" column="email"/>
<result property="nickname" column="nickname"/>
<result property="avatarUrl" column="avatar_url"/>
<result property="isAdmin" column="is_admin" javaType="boolean"/>
<result property="provider" column="provider"/>
<result property="createdAt" column="created_at"/>
<result property="favoriteCount" column="favorite_count"/>
<result property="reviewCount" column="review_count"/>
</resultMap>
<select id="findByProviderAndProviderId" resultMap="userResultMap">
SELECT id, email, nickname, avatar_url, is_admin, provider, created_at
FROM tasteby_users
WHERE provider = #{provider} AND provider_id = #{providerId}
</select>
<update id="updateLastLogin">
UPDATE tasteby_users SET last_login_at = SYSTIMESTAMP WHERE id = #{id}
</update>
<insert id="insert">
INSERT INTO tasteby_users (id, provider, provider_id, email, nickname, avatar_url)
VALUES (#{id}, #{provider}, #{providerId}, #{email}, #{nickname}, #{avatarUrl})
</insert>
<select id="findById" resultMap="userResultMap">
SELECT id, email, nickname, avatar_url, is_admin, provider, created_at
FROM tasteby_users
WHERE id = #{id}
</select>
<select id="findAllWithCounts" resultMap="userResultMap">
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 #{offset} ROWS FETCH NEXT #{limit} ROWS ONLY
</select>
<select id="countAll" resultType="int">
SELECT COUNT(*) FROM tasteby_users
</select>
</mapper>

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.tasteby.mapper.VectorMapper">
<resultMap id="vectorSearchResultMap" type="VectorSearchResult">
<result property="restaurantId" column="restaurant_id"/>
<result property="chunkText" column="chunk_text"
typeHandler="com.tasteby.config.ClobTypeHandler"/>
<result property="distance" column="dist"/>
</resultMap>
<select id="searchSimilar" resultMap="vectorSearchResultMap">
<![CDATA[
SELECT rv.restaurant_id, rv.chunk_text,
VECTOR_DISTANCE(rv.embedding, TO_VECTOR(#{queryVec}), COSINE) AS dist
FROM restaurant_vectors rv
WHERE VECTOR_DISTANCE(rv.embedding, TO_VECTOR(#{queryVec}), COSINE) <= #{maxDistance}
ORDER BY dist
FETCH FIRST #{topK} ROWS ONLY
]]>
</select>
<insert id="insertVector">
INSERT INTO restaurant_vectors (id, restaurant_id, chunk_text, embedding)
VALUES (#{id}, #{restaurantId}, #{chunkText}, TO_VECTOR(#{embedding}))
</insert>
</mapper>

View File

@@ -0,0 +1,219 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.tasteby.mapper.VideoMapper">
<!-- ===== Result Maps ===== -->
<resultMap id="videoSummaryMap" type="VideoSummary">
<id property="id" column="id"/>
<result property="videoId" column="video_id"/>
<result property="title" column="title"/>
<result property="url" column="url"/>
<result property="status" column="status"/>
<result property="publishedAt" column="published_at"/>
<result property="channelName" column="channel_name"/>
<result property="hasTranscript" column="has_transcript"/>
<result property="hasLlm" column="has_llm"/>
<result property="restaurantCount" column="restaurant_count"/>
<result property="matchedCount" column="matched_count"/>
</resultMap>
<resultMap id="videoDetailMap" type="VideoDetail">
<id property="id" column="id"/>
<result property="videoId" column="video_id"/>
<result property="title" column="title"/>
<result property="url" column="url"/>
<result property="status" column="status"/>
<result property="publishedAt" column="published_at"/>
<result property="channelName" column="channel_name"/>
<result property="transcriptText" column="transcript_text"
typeHandler="com.tasteby.config.ClobTypeHandler"/>
</resultMap>
<resultMap id="videoRestaurantLinkMap" type="VideoRestaurantLink">
<result property="restaurantId" column="id"/>
<result property="name" column="name"/>
<result property="address" column="address"/>
<result property="cuisineType" column="cuisine_type"/>
<result property="priceRange" column="price_range"/>
<result property="region" column="region"/>
<result property="foodsMentioned" column="foods_mentioned"
typeHandler="com.tasteby.config.ClobTypeHandler"/>
<result property="evaluation" column="evaluation"
typeHandler="com.tasteby.config.ClobTypeHandler"/>
<result property="guests" column="guests"
typeHandler="com.tasteby.config.ClobTypeHandler"/>
<result property="googlePlaceId" column="google_place_id"/>
<result property="latitude" column="latitude"/>
<result property="longitude" column="longitude"/>
</resultMap>
<!-- ===== Queries ===== -->
<select id="findAll" resultMap="videoSummaryMap">
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) &gt; 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) &gt; 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
<if test="status != null and status != ''">
WHERE v.status = #{status}
</if>
ORDER BY v.published_at DESC NULLS LAST
</select>
<select id="findDetail" resultMap="videoDetailMap">
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 = #{id}
</select>
<select id="findVideoRestaurants" resultMap="videoRestaurantLinkMap">
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 = #{videoId}
</select>
<!-- ===== Updates ===== -->
<update id="updateStatus">
UPDATE videos SET status = #{status} WHERE id = #{id}
</update>
<update id="updateTitle">
UPDATE videos SET title = #{title} WHERE id = #{id}
</update>
<update id="updateTranscript">
UPDATE videos SET transcript_text = #{transcript} WHERE id = #{id}
</update>
<update id="updateVideoFields">
UPDATE videos SET
status = #{status},
processed_at = SYSTIMESTAMP
<if test="transcript != null">
, transcript_text = #{transcript}
</if>
<if test="llmResponse != null">
, llm_raw_response = #{llmResponse}
</if>
WHERE id = #{id}
</update>
<!-- ===== Cascade deletes for video deletion ===== -->
<delete id="deleteVectorsByVideoOnly">
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>
<delete id="deleteReviewsByVideoOnly">
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>
<delete id="deleteFavoritesByVideoOnly">
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>
<delete id="deleteRestaurantsByVideoOnly">
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>
<delete id="deleteVideoRestaurants">
DELETE FROM video_restaurants WHERE video_id = #{videoId}
</delete>
<delete id="deleteVideo">
DELETE FROM videos WHERE id = #{videoId}
</delete>
<!-- ===== Single video-restaurant unlink + orphan cleanup ===== -->
<delete id="deleteOneVideoRestaurant">
DELETE FROM video_restaurants WHERE video_id = #{videoId} AND restaurant_id = #{restaurantId}
</delete>
<delete id="cleanupOrphanVectors">
DELETE FROM restaurant_vectors WHERE restaurant_id = #{restaurantId}
AND NOT EXISTS (SELECT 1 FROM video_restaurants WHERE restaurant_id = #{restaurantId})
</delete>
<delete id="cleanupOrphanReviews">
DELETE FROM user_reviews WHERE restaurant_id = #{restaurantId}
AND NOT EXISTS (SELECT 1 FROM video_restaurants WHERE restaurant_id = #{restaurantId})
</delete>
<delete id="cleanupOrphanFavorites">
DELETE FROM user_favorites WHERE restaurant_id = #{restaurantId}
AND NOT EXISTS (SELECT 1 FROM video_restaurants WHERE restaurant_id = #{restaurantId})
</delete>
<delete id="cleanupOrphanRestaurant">
DELETE FROM restaurants WHERE id = #{restaurantId}
AND NOT EXISTS (SELECT 1 FROM video_restaurants WHERE restaurant_id = #{restaurantId})
</delete>
<!-- ===== Insert / Lookup ===== -->
<insert id="insertVideo">
INSERT INTO videos (id, channel_id, video_id, title, url, published_at)
VALUES (#{id}, #{channelId}, #{videoId}, #{title}, #{url}, #{publishedAt})
</insert>
<select id="getExistingVideoIds" resultType="string">
SELECT video_id FROM videos WHERE channel_id = #{channelId}
</select>
<select id="getLatestVideoDate" resultType="string">
SELECT TO_CHAR(MAX(published_at), 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
FROM videos WHERE channel_id = #{channelId}
</select>
<!-- ===== Pipeline queries ===== -->
<select id="findPendingVideos" resultType="map">
SELECT id, video_id, title, url FROM videos
WHERE status = 'pending' ORDER BY created_at
FETCH FIRST #{limit} ROWS ONLY
</select>
<select id="findVideosForBulkExtract" resultType="map">
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) &gt; 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
</select>
</mapper>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration PUBLIC "-//mybatis.org//DTD Config 3.0//EN" "http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
<settings>
<setting name="mapUnderscoreToCamelCase" value="true"/>
<setting name="callSettersOnNulls" value="true"/>
<setting name="returnInstanceForEmptyRow" value="true"/>
</settings>
</configuration>