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:
@@ -22,6 +22,9 @@ dependencies {
|
||||
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-security'
|
||||
|
||||
// MyBatis
|
||||
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.4'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
package com.tasteby.controller;
|
||||
|
||||
import com.tasteby.repository.UserRepository;
|
||||
import com.tasteby.repository.ReviewRepository;
|
||||
import com.tasteby.util.JsonUtil;
|
||||
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
||||
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
||||
import com.tasteby.domain.Restaurant;
|
||||
import com.tasteby.domain.Review;
|
||||
import com.tasteby.service.ReviewService;
|
||||
import com.tasteby.service.UserService;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
@@ -14,49 +13,30 @@ import java.util.Map;
|
||||
@RequestMapping("/api/admin/users")
|
||||
public class AdminUserController {
|
||||
|
||||
private final UserRepository userRepo;
|
||||
private final NamedParameterJdbcTemplate jdbc;
|
||||
private final UserService userService;
|
||||
private final ReviewService reviewService;
|
||||
|
||||
public AdminUserController(UserRepository userRepo, NamedParameterJdbcTemplate jdbc) {
|
||||
this.userRepo = userRepo;
|
||||
this.jdbc = jdbc;
|
||||
public AdminUserController(UserService userService, ReviewService reviewService) {
|
||||
this.userService = userService;
|
||||
this.reviewService = reviewService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public Map<String, Object> listUsers(
|
||||
@RequestParam(defaultValue = "50") int limit,
|
||||
@RequestParam(defaultValue = "0") int offset) {
|
||||
var users = userRepo.findAllWithCounts(limit, offset);
|
||||
int total = userRepo.countAll();
|
||||
var users = userService.findAllWithCounts(limit, offset);
|
||||
int total = userService.countAll();
|
||||
return Map.of("users", users, "total", total);
|
||||
}
|
||||
|
||||
@GetMapping("/{userId}/favorites")
|
||||
public List<Map<String, Object>> userFavorites(@PathVariable String userId) {
|
||||
String sql = """
|
||||
SELECT r.id, r.name, r.address, r.region, r.cuisine_type,
|
||||
r.rating, r.business_status, f.created_at
|
||||
FROM user_favorites f
|
||||
JOIN restaurants r ON r.id = f.restaurant_id
|
||||
WHERE f.user_id = :u ORDER BY f.created_at DESC
|
||||
""";
|
||||
return jdbc.queryForList(sql, new MapSqlParameterSource("u", userId));
|
||||
public List<Restaurant> userFavorites(@PathVariable String userId) {
|
||||
return reviewService.getUserFavorites(userId);
|
||||
}
|
||||
|
||||
@GetMapping("/{userId}/reviews")
|
||||
public List<Map<String, Object>> userReviews(@PathVariable String userId) {
|
||||
String sql = """
|
||||
SELECT r.id, r.restaurant_id, r.rating, r.review_text,
|
||||
r.visited_at, r.created_at, rest.name AS restaurant_name
|
||||
FROM user_reviews r
|
||||
LEFT JOIN restaurants rest ON rest.id = r.restaurant_id
|
||||
WHERE r.user_id = :u ORDER BY r.created_at DESC
|
||||
""";
|
||||
var rows = jdbc.queryForList(sql, new MapSqlParameterSource("u", userId));
|
||||
rows.forEach(r -> {
|
||||
Object text = r.get("review_text");
|
||||
r.put("review_text", JsonUtil.readClob(text));
|
||||
});
|
||||
return rows;
|
||||
public List<Review> userReviews(@PathVariable String userId) {
|
||||
return reviewService.findByUser(userId, 100, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.tasteby.controller;
|
||||
|
||||
import com.tasteby.domain.UserInfo;
|
||||
import com.tasteby.security.AuthUtil;
|
||||
import com.tasteby.service.AuthService;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
@@ -23,7 +24,7 @@ public class AuthController {
|
||||
}
|
||||
|
||||
@GetMapping("/me")
|
||||
public Map<String, Object> me() {
|
||||
public UserInfo me() {
|
||||
String userId = AuthUtil.getUserId();
|
||||
return authService.getCurrentUser(userId);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package com.tasteby.controller;
|
||||
|
||||
import com.tasteby.repository.ChannelRepository;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.tasteby.domain.Channel;
|
||||
import com.tasteby.security.AuthUtil;
|
||||
import com.tasteby.service.CacheService;
|
||||
import com.tasteby.service.ChannelService;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
@@ -14,26 +17,26 @@ import java.util.Map;
|
||||
@RequestMapping("/api/channels")
|
||||
public class ChannelController {
|
||||
|
||||
private final ChannelRepository repo;
|
||||
private final ChannelService channelService;
|
||||
private final CacheService cache;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public ChannelController(ChannelRepository repo, CacheService cache) {
|
||||
this.repo = repo;
|
||||
public ChannelController(ChannelService channelService, CacheService cache, ObjectMapper objectMapper) {
|
||||
this.channelService = channelService;
|
||||
this.cache = cache;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<Map<String, Object>> list() {
|
||||
public List<Channel> list() {
|
||||
String key = cache.makeKey("channels");
|
||||
String cached = cache.getRaw(key);
|
||||
if (cached != null) {
|
||||
try {
|
||||
var mapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
||||
return mapper.readValue(cached,
|
||||
new com.fasterxml.jackson.core.type.TypeReference<>() {});
|
||||
return objectMapper.readValue(cached, new TypeReference<List<Channel>>() {});
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
var result = repo.findAllActive();
|
||||
var result = channelService.findAllActive();
|
||||
cache.set(key, result);
|
||||
return result;
|
||||
}
|
||||
@@ -46,7 +49,7 @@ public class ChannelController {
|
||||
String channelName = body.get("channel_name");
|
||||
String titleFilter = body.get("title_filter");
|
||||
try {
|
||||
String id = repo.create(channelId, channelName, titleFilter);
|
||||
String id = channelService.create(channelId, channelName, titleFilter);
|
||||
cache.flush();
|
||||
return Map.of("id", id, "channel_id", channelId);
|
||||
} catch (Exception e) {
|
||||
@@ -60,7 +63,7 @@ public class ChannelController {
|
||||
@DeleteMapping("/{channelId}")
|
||||
public Map<String, Object> delete(@PathVariable String channelId) {
|
||||
AuthUtil.requireAdmin();
|
||||
if (!repo.deactivate(channelId)) {
|
||||
if (!channelService.deactivate(channelId)) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Channel not found");
|
||||
}
|
||||
cache.flush();
|
||||
|
||||
@@ -1,82 +1,32 @@
|
||||
package com.tasteby.controller;
|
||||
|
||||
import com.tasteby.domain.DaemonConfig;
|
||||
import com.tasteby.security.AuthUtil;
|
||||
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
||||
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
||||
import com.tasteby.service.DaemonConfigService;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/daemon")
|
||||
public class DaemonController {
|
||||
|
||||
private final NamedParameterJdbcTemplate jdbc;
|
||||
private final DaemonConfigService daemonConfigService;
|
||||
|
||||
public DaemonController(NamedParameterJdbcTemplate jdbc) {
|
||||
this.jdbc = jdbc;
|
||||
public DaemonController(DaemonConfigService daemonConfigService) {
|
||||
this.daemonConfigService = daemonConfigService;
|
||||
}
|
||||
|
||||
@GetMapping("/config")
|
||||
public Map<String, Object> getConfig() {
|
||||
String sql = """
|
||||
SELECT scan_enabled, scan_interval_min, process_enabled, process_interval_min,
|
||||
process_limit, last_scan_at, last_process_at, updated_at
|
||||
FROM daemon_config WHERE id = 1
|
||||
""";
|
||||
var rows = jdbc.queryForList(sql, new MapSqlParameterSource());
|
||||
if (rows.isEmpty()) return Map.of();
|
||||
var row = rows.getFirst();
|
||||
var result = new LinkedHashMap<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;
|
||||
public DaemonConfig getConfig() {
|
||||
DaemonConfig config = daemonConfigService.getConfig();
|
||||
return config != null ? config : DaemonConfig.builder().build();
|
||||
}
|
||||
|
||||
@PutMapping("/config")
|
||||
public Map<String, Object> updateConfig(@RequestBody Map<String, Object> body) {
|
||||
AuthUtil.requireAdmin();
|
||||
var sets = new ArrayList<String>();
|
||||
var params = new MapSqlParameterSource();
|
||||
|
||||
if (body.containsKey("scan_enabled")) {
|
||||
sets.add("scan_enabled = :se");
|
||||
params.addValue("se", Boolean.TRUE.equals(body.get("scan_enabled")) ? 1 : 0);
|
||||
}
|
||||
if (body.containsKey("scan_interval_min")) {
|
||||
sets.add("scan_interval_min = :si");
|
||||
params.addValue("si", ((Number) body.get("scan_interval_min")).intValue());
|
||||
}
|
||||
if (body.containsKey("process_enabled")) {
|
||||
sets.add("process_enabled = :pe");
|
||||
params.addValue("pe", Boolean.TRUE.equals(body.get("process_enabled")) ? 1 : 0);
|
||||
}
|
||||
if (body.containsKey("process_interval_min")) {
|
||||
sets.add("process_interval_min = :pi");
|
||||
params.addValue("pi", ((Number) body.get("process_interval_min")).intValue());
|
||||
}
|
||||
if (body.containsKey("process_limit")) {
|
||||
sets.add("process_limit = :pl");
|
||||
params.addValue("pl", ((Number) body.get("process_limit")).intValue());
|
||||
}
|
||||
if (!sets.isEmpty()) {
|
||||
sets.add("updated_at = SYSTIMESTAMP");
|
||||
String sql = "UPDATE daemon_config SET " + String.join(", ", sets) + " WHERE id = 1";
|
||||
jdbc.update(sql, params);
|
||||
}
|
||||
daemonConfigService.updateConfig(body);
|
||||
return Map.of("ok", true);
|
||||
}
|
||||
|
||||
private int toInt(Object val) {
|
||||
if (val == null) return 0;
|
||||
return ((Number) val).intValue();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package com.tasteby.controller;
|
||||
|
||||
import com.tasteby.repository.RestaurantRepository;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.tasteby.domain.Restaurant;
|
||||
import com.tasteby.security.AuthUtil;
|
||||
import com.tasteby.service.CacheService;
|
||||
import com.tasteby.service.RestaurantService;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
@@ -14,16 +17,18 @@ import java.util.Map;
|
||||
@RequestMapping("/api/restaurants")
|
||||
public class RestaurantController {
|
||||
|
||||
private final RestaurantRepository repo;
|
||||
private final RestaurantService restaurantService;
|
||||
private final CacheService cache;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public RestaurantController(RestaurantRepository repo, CacheService cache) {
|
||||
this.repo = repo;
|
||||
public RestaurantController(RestaurantService restaurantService, CacheService cache, ObjectMapper objectMapper) {
|
||||
this.restaurantService = restaurantService;
|
||||
this.cache = cache;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<Map<String, Object>> list(
|
||||
public List<Restaurant> list(
|
||||
@RequestParam(defaultValue = "100") int limit,
|
||||
@RequestParam(defaultValue = "0") int offset,
|
||||
@RequestParam(required = false) String cuisine,
|
||||
@@ -35,28 +40,24 @@ public class RestaurantController {
|
||||
String cached = cache.getRaw(key);
|
||||
if (cached != null) {
|
||||
try {
|
||||
var mapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
||||
return mapper.readValue(cached,
|
||||
new com.fasterxml.jackson.core.type.TypeReference<>() {});
|
||||
return objectMapper.readValue(cached, new TypeReference<List<Restaurant>>() {});
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
var result = repo.findAll(limit, offset, cuisine, region, channel);
|
||||
var result = restaurantService.findAll(limit, offset, cuisine, region, channel);
|
||||
cache.set(key, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public Map<String, Object> get(@PathVariable String id) {
|
||||
public Restaurant get(@PathVariable String id) {
|
||||
String key = cache.makeKey("restaurant", id);
|
||||
String cached = cache.getRaw(key);
|
||||
if (cached != null) {
|
||||
try {
|
||||
var mapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
||||
return mapper.readValue(cached,
|
||||
new com.fasterxml.jackson.core.type.TypeReference<>() {});
|
||||
return objectMapper.readValue(cached, Restaurant.class);
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
var r = repo.findById(id);
|
||||
var r = restaurantService.findById(id);
|
||||
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
|
||||
cache.set(key, r);
|
||||
return r;
|
||||
@@ -65,9 +66,9 @@ public class RestaurantController {
|
||||
@PutMapping("/{id}")
|
||||
public Map<String, Object> update(@PathVariable String id, @RequestBody Map<String, Object> body) {
|
||||
AuthUtil.requireAdmin();
|
||||
var r = repo.findById(id);
|
||||
var r = restaurantService.findById(id);
|
||||
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
|
||||
repo.update(id, body);
|
||||
restaurantService.update(id, body);
|
||||
cache.flush();
|
||||
return Map.of("ok", true);
|
||||
}
|
||||
@@ -75,9 +76,9 @@ public class RestaurantController {
|
||||
@DeleteMapping("/{id}")
|
||||
public Map<String, Object> delete(@PathVariable String id) {
|
||||
AuthUtil.requireAdmin();
|
||||
var r = repo.findById(id);
|
||||
var r = restaurantService.findById(id);
|
||||
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
|
||||
repo.delete(id);
|
||||
restaurantService.delete(id);
|
||||
cache.flush();
|
||||
return Map.of("ok", true);
|
||||
}
|
||||
@@ -88,14 +89,12 @@ public class RestaurantController {
|
||||
String cached = cache.getRaw(key);
|
||||
if (cached != null) {
|
||||
try {
|
||||
var mapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
||||
return mapper.readValue(cached,
|
||||
new com.fasterxml.jackson.core.type.TypeReference<>() {});
|
||||
return objectMapper.readValue(cached, new TypeReference<List<Map<String, Object>>>() {});
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
var r = repo.findById(id);
|
||||
var r = restaurantService.findById(id);
|
||||
if (r == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Restaurant not found");
|
||||
var result = repo.findVideoLinks(id);
|
||||
var result = restaurantService.findVideoLinks(id);
|
||||
cache.set(key, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.tasteby.controller;
|
||||
|
||||
import com.tasteby.repository.ReviewRepository;
|
||||
import com.tasteby.domain.Restaurant;
|
||||
import com.tasteby.domain.Review;
|
||||
import com.tasteby.security.AuthUtil;
|
||||
import com.tasteby.service.ReviewService;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
@@ -14,10 +16,10 @@ import java.util.Map;
|
||||
@RequestMapping("/api")
|
||||
public class ReviewController {
|
||||
|
||||
private final ReviewRepository repo;
|
||||
private final ReviewService reviewService;
|
||||
|
||||
public ReviewController(ReviewRepository repo) {
|
||||
this.repo = repo;
|
||||
public ReviewController(ReviewService reviewService) {
|
||||
this.reviewService = reviewService;
|
||||
}
|
||||
|
||||
@GetMapping("/restaurants/{restaurantId}/reviews")
|
||||
@@ -25,15 +27,15 @@ public class ReviewController {
|
||||
@PathVariable String restaurantId,
|
||||
@RequestParam(defaultValue = "20") int limit,
|
||||
@RequestParam(defaultValue = "0") int offset) {
|
||||
var reviews = repo.findByRestaurant(restaurantId, limit, offset);
|
||||
var stats = repo.getAvgRating(restaurantId);
|
||||
var reviews = reviewService.findByRestaurant(restaurantId, limit, offset);
|
||||
var stats = reviewService.getAvgRating(restaurantId);
|
||||
return Map.of("reviews", reviews, "avg_rating", stats.get("avg_rating"),
|
||||
"review_count", stats.get("review_count"));
|
||||
}
|
||||
|
||||
@PostMapping("/restaurants/{restaurantId}/reviews")
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
public Map<String, Object> createReview(
|
||||
public Review createReview(
|
||||
@PathVariable String restaurantId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
String userId = AuthUtil.getUserId();
|
||||
@@ -41,7 +43,7 @@ public class ReviewController {
|
||||
String text = (String) body.get("review_text");
|
||||
LocalDate visitedAt = body.get("visited_at") != null
|
||||
? LocalDate.parse((String) body.get("visited_at")) : null;
|
||||
return repo.create(userId, restaurantId, rating, text, visitedAt);
|
||||
return reviewService.create(userId, restaurantId, rating, text, visitedAt);
|
||||
}
|
||||
|
||||
@PutMapping("/reviews/{reviewId}")
|
||||
@@ -54,43 +56,42 @@ public class ReviewController {
|
||||
String text = (String) body.get("review_text");
|
||||
LocalDate visitedAt = body.get("visited_at") != null
|
||||
? LocalDate.parse((String) body.get("visited_at")) : null;
|
||||
var result = repo.update(reviewId, userId, rating, text, visitedAt);
|
||||
if (result == null) {
|
||||
if (!reviewService.update(reviewId, userId, rating, text, visitedAt)) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Review not found or not yours");
|
||||
}
|
||||
return result;
|
||||
return Map.of("ok", true);
|
||||
}
|
||||
|
||||
@DeleteMapping("/reviews/{reviewId}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void deleteReview(@PathVariable String reviewId) {
|
||||
String userId = AuthUtil.getUserId();
|
||||
if (!repo.delete(reviewId, userId)) {
|
||||
if (!reviewService.delete(reviewId, userId)) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Review not found or not yours");
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/users/me/reviews")
|
||||
public List<Map<String, Object>> myReviews(
|
||||
public List<Review> myReviews(
|
||||
@RequestParam(defaultValue = "20") int limit,
|
||||
@RequestParam(defaultValue = "0") int offset) {
|
||||
return repo.findByUser(AuthUtil.getUserId(), limit, offset);
|
||||
return reviewService.findByUser(AuthUtil.getUserId(), limit, offset);
|
||||
}
|
||||
|
||||
// Favorites
|
||||
@GetMapping("/restaurants/{restaurantId}/favorite")
|
||||
public Map<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")
|
||||
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);
|
||||
}
|
||||
|
||||
@GetMapping("/users/me/favorites")
|
||||
public List<Map<String, Object>> myFavorites() {
|
||||
return repo.getUserFavorites(AuthUtil.getUserId());
|
||||
public List<Restaurant> myFavorites() {
|
||||
return reviewService.getUserFavorites(AuthUtil.getUserId());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package com.tasteby.controller;
|
||||
|
||||
import com.tasteby.domain.Restaurant;
|
||||
import com.tasteby.service.SearchService;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/search")
|
||||
@@ -17,7 +17,7 @@ public class SearchController {
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<Map<String, Object>> search(
|
||||
public List<Restaurant> search(
|
||||
@RequestParam String q,
|
||||
@RequestParam(defaultValue = "keyword") String mode,
|
||||
@RequestParam(defaultValue = "20") int limit) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.tasteby.controller;
|
||||
|
||||
import com.tasteby.repository.StatsRepository;
|
||||
import com.tasteby.domain.SiteVisitStats;
|
||||
import com.tasteby.service.StatsService;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
@@ -9,20 +10,20 @@ import java.util.Map;
|
||||
@RequestMapping("/api/stats")
|
||||
public class StatsController {
|
||||
|
||||
private final StatsRepository repo;
|
||||
private final StatsService statsService;
|
||||
|
||||
public StatsController(StatsRepository repo) {
|
||||
this.repo = repo;
|
||||
public StatsController(StatsService statsService) {
|
||||
this.statsService = statsService;
|
||||
}
|
||||
|
||||
@PostMapping("/visit")
|
||||
public Map<String, Object> recordVisit() {
|
||||
repo.recordVisit();
|
||||
statsService.recordVisit();
|
||||
return Map.of("ok", true);
|
||||
}
|
||||
|
||||
@GetMapping("/visits")
|
||||
public Map<String, Object> getVisits() {
|
||||
return repo.getVisits();
|
||||
public SiteVisitStats getVisits() {
|
||||
return statsService.getVisits();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package com.tasteby.controller;
|
||||
|
||||
import com.tasteby.repository.VideoRepository;
|
||||
import com.tasteby.domain.VideoDetail;
|
||||
import com.tasteby.domain.VideoSummary;
|
||||
import com.tasteby.security.AuthUtil;
|
||||
import com.tasteby.service.CacheService;
|
||||
import com.tasteby.service.VideoService;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
@@ -14,22 +16,22 @@ import java.util.Map;
|
||||
@RequestMapping("/api/videos")
|
||||
public class VideoController {
|
||||
|
||||
private final VideoRepository repo;
|
||||
private final VideoService videoService;
|
||||
private final CacheService cache;
|
||||
|
||||
public VideoController(VideoRepository repo, CacheService cache) {
|
||||
this.repo = repo;
|
||||
public VideoController(VideoService videoService, CacheService cache) {
|
||||
this.videoService = videoService;
|
||||
this.cache = cache;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<Map<String, Object>> list(@RequestParam(required = false) String status) {
|
||||
return repo.findAll(status);
|
||||
public List<VideoSummary> list(@RequestParam(required = false) String status) {
|
||||
return videoService.findAll(status);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public Map<String, Object> detail(@PathVariable String id) {
|
||||
var video = repo.findDetail(id);
|
||||
public VideoDetail detail(@PathVariable String id) {
|
||||
var video = videoService.findDetail(id);
|
||||
if (video == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Video not found");
|
||||
return video;
|
||||
}
|
||||
@@ -41,7 +43,7 @@ public class VideoController {
|
||||
if (title == null || title.isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "title is required");
|
||||
}
|
||||
repo.updateTitle(id, title);
|
||||
videoService.updateTitle(id, title);
|
||||
cache.flush();
|
||||
return Map.of("ok", true);
|
||||
}
|
||||
@@ -49,7 +51,7 @@ public class VideoController {
|
||||
@PostMapping("/{id}/skip")
|
||||
public Map<String, Object> skip(@PathVariable String id) {
|
||||
AuthUtil.requireAdmin();
|
||||
repo.updateStatus(id, "skip");
|
||||
videoService.updateStatus(id, "skip");
|
||||
cache.flush();
|
||||
return Map.of("ok", true);
|
||||
}
|
||||
@@ -57,7 +59,7 @@ public class VideoController {
|
||||
@DeleteMapping("/{id}")
|
||||
public Map<String, Object> delete(@PathVariable String id) {
|
||||
AuthUtil.requireAdmin();
|
||||
repo.delete(id);
|
||||
videoService.delete(id);
|
||||
cache.flush();
|
||||
return Map.of("ok", true);
|
||||
}
|
||||
@@ -66,7 +68,7 @@ public class VideoController {
|
||||
public Map<String, Object> deleteVideoRestaurant(
|
||||
@PathVariable String videoId, @PathVariable String restaurantId) {
|
||||
AuthUtil.requireAdmin();
|
||||
repo.deleteVideoRestaurant(videoId, restaurantId);
|
||||
videoService.deleteVideoRestaurant(videoId, restaurantId);
|
||||
cache.flush();
|
||||
return Map.of("ok", true);
|
||||
}
|
||||
|
||||
@@ -7,8 +7,6 @@ import com.tasteby.util.CuisineTypes;
|
||||
import com.tasteby.util.JsonUtil;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
||||
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
@@ -25,19 +23,22 @@ public class VideoSseController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(VideoSseController.class);
|
||||
|
||||
private final NamedParameterJdbcTemplate jdbc;
|
||||
private final VideoService videoService;
|
||||
private final RestaurantService restaurantService;
|
||||
private final PipelineService pipelineService;
|
||||
private final OciGenAiService genAi;
|
||||
private final CacheService cache;
|
||||
private final ObjectMapper mapper;
|
||||
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
|
||||
|
||||
public VideoSseController(NamedParameterJdbcTemplate jdbc,
|
||||
public VideoSseController(VideoService videoService,
|
||||
RestaurantService restaurantService,
|
||||
PipelineService pipelineService,
|
||||
OciGenAiService genAi,
|
||||
CacheService cache,
|
||||
ObjectMapper mapper) {
|
||||
this.jdbc = jdbc;
|
||||
this.videoService = videoService;
|
||||
this.restaurantService = restaurantService;
|
||||
this.pipelineService = pipelineService;
|
||||
this.genAi = genAi;
|
||||
this.cache = cache;
|
||||
@@ -70,24 +71,7 @@ public class VideoSseController {
|
||||
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
String sql = """
|
||||
SELECT v.id, v.video_id, v.title, v.url, v.transcript_text
|
||||
FROM videos v
|
||||
WHERE v.transcript_text IS NOT NULL
|
||||
AND dbms_lob.getlength(v.transcript_text) > 0
|
||||
AND (v.llm_raw_response IS NULL OR dbms_lob.getlength(v.llm_raw_response) = 0)
|
||||
AND v.status != 'skip'
|
||||
ORDER BY v.published_at DESC
|
||||
""";
|
||||
var rows = jdbc.query(sql, new MapSqlParameterSource(), (rs, rowNum) -> {
|
||||
Map<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;
|
||||
});
|
||||
var rows = videoService.findVideosForBulkExtract();
|
||||
|
||||
int total = rows.size();
|
||||
int totalRestaurants = 0;
|
||||
@@ -131,22 +115,8 @@ public class VideoSseController {
|
||||
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
String sql = """
|
||||
SELECT r.id, r.name, r.cuisine_type,
|
||||
(SELECT LISTAGG(vr.foods_mentioned, '|') WITHIN GROUP (ORDER BY vr.id)
|
||||
FROM video_restaurants vr WHERE vr.restaurant_id = r.id) AS foods
|
||||
FROM restaurants r
|
||||
WHERE EXISTS (SELECT 1 FROM video_restaurants vr2 WHERE vr2.restaurant_id = r.id)
|
||||
ORDER BY r.name
|
||||
""";
|
||||
var rows = jdbc.query(sql, new MapSqlParameterSource(), (rs, rowNum) -> {
|
||||
Map<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;
|
||||
});
|
||||
var rows = restaurantService.findForRemapCuisine();
|
||||
rows = rows.stream().map(JsonUtil::lowerKeys).toList();
|
||||
|
||||
int total = rows.size();
|
||||
emit(emitter, Map.of("type", "start", "total", total));
|
||||
@@ -199,21 +169,12 @@ public class VideoSseController {
|
||||
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
String sql = """
|
||||
SELECT vr.id, r.name, r.cuisine_type, vr.foods_mentioned, v.title
|
||||
FROM video_restaurants vr
|
||||
JOIN restaurants r ON r.id = vr.restaurant_id
|
||||
JOIN videos v ON v.id = vr.video_id ORDER BY r.name
|
||||
""";
|
||||
var rows = jdbc.query(sql, new MapSqlParameterSource(), (rs, rowNum) -> {
|
||||
Map<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"));
|
||||
var rows = restaurantService.findForRemapFoods();
|
||||
rows = rows.stream().map(r -> {
|
||||
var m = JsonUtil.lowerKeys(r);
|
||||
m.put("foods", JsonUtil.parseStringList(m.get("foods_mentioned")));
|
||||
return m;
|
||||
});
|
||||
}).toList();
|
||||
|
||||
int total = rows.size();
|
||||
emit(emitter, Map.of("type", "start", "total", total));
|
||||
@@ -330,8 +291,7 @@ public class VideoSseController {
|
||||
missed.add(b);
|
||||
continue;
|
||||
}
|
||||
jdbc.update("UPDATE restaurants SET cuisine_type = :ct WHERE id = :id",
|
||||
new MapSqlParameterSource().addValue("ct", newType).addValue("id", id));
|
||||
restaurantService.updateCuisineType(id, newType);
|
||||
updated++;
|
||||
}
|
||||
return new BatchResult(updated, missed);
|
||||
@@ -383,10 +343,7 @@ public class VideoSseController {
|
||||
missed.add(b);
|
||||
continue;
|
||||
}
|
||||
jdbc.update("UPDATE video_restaurants SET foods_mentioned = :foods WHERE id = :id",
|
||||
new MapSqlParameterSource()
|
||||
.addValue("foods", mapper.writeValueAsString(newFoods))
|
||||
.addValue("id", id));
|
||||
restaurantService.updateFoodsMentioned(id, mapper.writeValueAsString(newFoods));
|
||||
updated++;
|
||||
}
|
||||
return new BatchResult(updated, missed);
|
||||
|
||||
19
backend-java/src/main/java/com/tasteby/domain/Channel.java
Normal file
19
backend-java/src/main/java/com/tasteby/domain/Channel.java
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
24
backend-java/src/main/java/com/tasteby/domain/Review.java
Normal file
24
backend-java/src/main/java/com/tasteby/domain/Review.java
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
25
backend-java/src/main/java/com/tasteby/domain/UserInfo.java
Normal file
25
backend-java/src/main/java/com/tasteby/domain/UserInfo.java
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.tasteby.mapper;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface StatsMapper {
|
||||
|
||||
void recordVisit();
|
||||
|
||||
int getTodayVisits();
|
||||
|
||||
int getTotalVisits();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken;
|
||||
import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier;
|
||||
import com.google.api.client.http.javanet.NetHttpTransport;
|
||||
import com.google.api.client.json.gson.GsonFactory;
|
||||
import com.tasteby.repository.UserRepository;
|
||||
import com.tasteby.domain.UserInfo;
|
||||
import com.tasteby.security.JwtTokenProvider;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -16,12 +16,12 @@ import java.util.Map;
|
||||
@Service
|
||||
public class AuthService {
|
||||
|
||||
private final UserRepository userRepo;
|
||||
private final UserService userService;
|
||||
private final JwtTokenProvider jwtProvider;
|
||||
private final GoogleIdTokenVerifier verifier;
|
||||
|
||||
public AuthService(UserRepository userRepo, JwtTokenProvider jwtProvider) {
|
||||
this.userRepo = userRepo;
|
||||
public AuthService(UserService userService, JwtTokenProvider jwtProvider) {
|
||||
this.userService = userService;
|
||||
this.jwtProvider = jwtProvider;
|
||||
this.verifier = new GoogleIdTokenVerifier.Builder(
|
||||
new NetHttpTransport(), GsonFactory.getDefaultInstance())
|
||||
@@ -37,15 +37,21 @@ public class AuthService {
|
||||
}
|
||||
GoogleIdToken.Payload payload = idToken.getPayload();
|
||||
|
||||
Map<String, Object> user = userRepo.findOrCreate(
|
||||
UserInfo user = userService.findOrCreate(
|
||||
"google",
|
||||
payload.getSubject(),
|
||||
payload.getEmail(),
|
||||
(String) payload.get("name"),
|
||||
(String) payload.get("picture")
|
||||
);
|
||||
(String) payload.get("picture"));
|
||||
|
||||
String accessToken = jwtProvider.createToken(user);
|
||||
// Convert to Map for JWT
|
||||
Map<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);
|
||||
} catch (ResponseStatusException e) {
|
||||
throw e;
|
||||
@@ -54,8 +60,8 @@ public class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
public Map<String, Object> getCurrentUser(String userId) {
|
||||
Map<String, Object> user = userRepo.findById(userId);
|
||||
public UserInfo getCurrentUser(String userId) {
|
||||
UserInfo user = userService.findById(userId);
|
||||
if (user == null) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
package com.tasteby.service;
|
||||
|
||||
import com.tasteby.domain.DaemonConfig;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
||||
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -18,16 +17,16 @@ public class DaemonScheduler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(DaemonScheduler.class);
|
||||
|
||||
private final NamedParameterJdbcTemplate jdbc;
|
||||
private final DaemonConfigService daemonConfigService;
|
||||
private final YouTubeService youTubeService;
|
||||
private final PipelineService pipelineService;
|
||||
private final CacheService cacheService;
|
||||
|
||||
public DaemonScheduler(NamedParameterJdbcTemplate jdbc,
|
||||
public DaemonScheduler(DaemonConfigService daemonConfigService,
|
||||
YouTubeService youTubeService,
|
||||
PipelineService pipelineService,
|
||||
CacheService cacheService) {
|
||||
this.jdbc = jdbc;
|
||||
this.daemonConfigService = daemonConfigService;
|
||||
this.youTubeService = youTubeService;
|
||||
this.pipelineService = pipelineService;
|
||||
this.cacheService = cacheService;
|
||||
@@ -39,13 +38,12 @@ public class DaemonScheduler {
|
||||
var config = getConfig();
|
||||
if (config == null) return;
|
||||
|
||||
// Channel scanning
|
||||
if (config.scanEnabled) {
|
||||
Instant lastScan = config.lastScanAt;
|
||||
if (lastScan == null || Instant.now().isAfter(lastScan.plus(config.scanIntervalMin, ChronoUnit.MINUTES))) {
|
||||
if (config.isScanEnabled()) {
|
||||
Instant lastScan = config.getLastScanAt() != null ? config.getLastScanAt().toInstant() : null;
|
||||
if (lastScan == null || Instant.now().isAfter(lastScan.plus(config.getScanIntervalMin(), ChronoUnit.MINUTES))) {
|
||||
log.info("Running scheduled channel scan...");
|
||||
int newVideos = youTubeService.scanAllChannels();
|
||||
updateLastScan();
|
||||
daemonConfigService.updateLastScan();
|
||||
if (newVideos > 0) {
|
||||
cacheService.flush();
|
||||
log.info("Scan completed: {} new videos", newVideos);
|
||||
@@ -53,13 +51,12 @@ public class DaemonScheduler {
|
||||
}
|
||||
}
|
||||
|
||||
// Video processing
|
||||
if (config.processEnabled) {
|
||||
Instant lastProcess = config.lastProcessAt;
|
||||
if (lastProcess == null || Instant.now().isAfter(lastProcess.plus(config.processIntervalMin, ChronoUnit.MINUTES))) {
|
||||
log.info("Running scheduled video processing (limit={})...", config.processLimit);
|
||||
int restaurants = pipelineService.processPending(config.processLimit);
|
||||
updateLastProcess();
|
||||
if (config.isProcessEnabled()) {
|
||||
Instant lastProcess = config.getLastProcessAt() != null ? config.getLastProcessAt().toInstant() : null;
|
||||
if (lastProcess == null || Instant.now().isAfter(lastProcess.plus(config.getProcessIntervalMin(), ChronoUnit.MINUTES))) {
|
||||
log.info("Running scheduled video processing (limit={})...", config.getProcessLimit());
|
||||
int restaurants = pipelineService.processPending(config.getProcessLimit());
|
||||
daemonConfigService.updateLastProcess();
|
||||
if (restaurants > 0) {
|
||||
cacheService.flush();
|
||||
log.info("Processing completed: {} restaurants extracted", restaurants);
|
||||
@@ -71,45 +68,12 @@ public class DaemonScheduler {
|
||||
}
|
||||
}
|
||||
|
||||
private record DaemonConfig(
|
||||
boolean scanEnabled, int scanIntervalMin,
|
||||
boolean processEnabled, int processIntervalMin, int processLimit,
|
||||
Instant lastScanAt, Instant lastProcessAt) {}
|
||||
|
||||
private DaemonConfig getConfig() {
|
||||
try {
|
||||
var rows = jdbc.queryForList(
|
||||
"SELECT * FROM daemon_config WHERE id = 1",
|
||||
new MapSqlParameterSource());
|
||||
if (rows.isEmpty()) return null;
|
||||
var row = rows.getFirst();
|
||||
return new DaemonConfig(
|
||||
toInt(row.get("scan_enabled")) == 1,
|
||||
toInt(row.get("scan_interval_min")),
|
||||
toInt(row.get("process_enabled")) == 1,
|
||||
toInt(row.get("process_interval_min")),
|
||||
toInt(row.get("process_limit")),
|
||||
row.get("last_scan_at") instanceof java.sql.Timestamp ts ? ts.toInstant() : null,
|
||||
row.get("last_process_at") instanceof java.sql.Timestamp ts ? ts.toInstant() : null
|
||||
);
|
||||
return daemonConfigService.getConfig();
|
||||
} catch (Exception e) {
|
||||
log.debug("Cannot read daemon config: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void updateLastScan() {
|
||||
jdbc.update("UPDATE daemon_config SET last_scan_at = SYSTIMESTAMP WHERE id = 1",
|
||||
new MapSqlParameterSource());
|
||||
}
|
||||
|
||||
private void updateLastProcess() {
|
||||
jdbc.update("UPDATE daemon_config SET last_process_at = SYSTIMESTAMP WHERE id = 1",
|
||||
new MapSqlParameterSource());
|
||||
}
|
||||
|
||||
private int toInt(Object val) {
|
||||
if (val == null) return 0;
|
||||
return ((Number) val).intValue();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
package com.tasteby.service;
|
||||
|
||||
import com.tasteby.repository.RestaurantRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
||||
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.HashMap;
|
||||
@@ -23,26 +20,26 @@ public class PipelineService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(PipelineService.class);
|
||||
|
||||
private final NamedParameterJdbcTemplate jdbc;
|
||||
private final YouTubeService youTubeService;
|
||||
private final ExtractorService extractorService;
|
||||
private final GeocodingService geocodingService;
|
||||
private final RestaurantRepository restaurantRepo;
|
||||
private final RestaurantService restaurantService;
|
||||
private final VideoService videoService;
|
||||
private final VectorService vectorService;
|
||||
private final CacheService cacheService;
|
||||
|
||||
public PipelineService(NamedParameterJdbcTemplate jdbc,
|
||||
YouTubeService youTubeService,
|
||||
public PipelineService(YouTubeService youTubeService,
|
||||
ExtractorService extractorService,
|
||||
GeocodingService geocodingService,
|
||||
RestaurantRepository restaurantRepo,
|
||||
RestaurantService restaurantService,
|
||||
VideoService videoService,
|
||||
VectorService vectorService,
|
||||
CacheService cacheService) {
|
||||
this.jdbc = jdbc;
|
||||
this.youTubeService = youTubeService;
|
||||
this.extractorService = extractorService;
|
||||
this.geocodingService = geocodingService;
|
||||
this.restaurantRepo = restaurantRepo;
|
||||
this.restaurantService = restaurantService;
|
||||
this.videoService = videoService;
|
||||
this.vectorService = vectorService;
|
||||
this.cacheService = cacheService;
|
||||
}
|
||||
@@ -117,13 +114,13 @@ public class PipelineService {
|
||||
data.put("rating", geo != null ? geo.get("rating") : null);
|
||||
data.put("rating_count", geo != null ? geo.get("rating_count") : null);
|
||||
|
||||
String restId = restaurantRepo.upsert(data);
|
||||
String restId = restaurantService.upsert(data);
|
||||
|
||||
// Link video <-> restaurant
|
||||
var foods = restData.get("foods_mentioned");
|
||||
var evaluation = restData.get("evaluation");
|
||||
var guests = restData.get("guests");
|
||||
restaurantRepo.linkVideoRestaurant(
|
||||
restaurantService.linkVideoRestaurant(
|
||||
videoDbId, restId,
|
||||
foods instanceof List<?> ? (List<String>) foods : null,
|
||||
evaluation instanceof String ? (String) evaluation : null,
|
||||
@@ -153,46 +150,20 @@ public class PipelineService {
|
||||
* Process up to `limit` pending videos.
|
||||
*/
|
||||
public int processPending(int limit) {
|
||||
String sql = """
|
||||
SELECT id, video_id, title, url FROM videos
|
||||
WHERE status = 'pending' ORDER BY created_at
|
||||
FETCH FIRST :n ROWS ONLY
|
||||
""";
|
||||
var videos = jdbc.queryForList(sql, new MapSqlParameterSource("n", limit));
|
||||
var videos = videoService.findPendingVideos(limit);
|
||||
if (videos.isEmpty()) {
|
||||
log.info("No pending videos");
|
||||
return 0;
|
||||
}
|
||||
int total = 0;
|
||||
for (var v : videos) {
|
||||
// Normalize Oracle uppercase keys
|
||||
var normalized = normalizeKeys(v);
|
||||
total += processVideo(normalized);
|
||||
total += processVideo(v);
|
||||
}
|
||||
if (total > 0) cacheService.flush();
|
||||
return total;
|
||||
}
|
||||
|
||||
private void updateVideoStatus(String videoDbId, String status, String transcript, String llmRaw) {
|
||||
var sets = new java.util.ArrayList<>(List.of("status = :st", "processed_at = SYSTIMESTAMP"));
|
||||
var params = new MapSqlParameterSource().addValue("st", status).addValue("vid", videoDbId);
|
||||
if (transcript != null) {
|
||||
sets.add("transcript_text = :txt");
|
||||
params.addValue("txt", transcript);
|
||||
}
|
||||
if (llmRaw != null) {
|
||||
sets.add("llm_raw_response = :llm");
|
||||
params.addValue("llm", llmRaw);
|
||||
}
|
||||
String sql = "UPDATE videos SET " + String.join(", ", sets) + " WHERE id = :vid";
|
||||
jdbc.update(sql, params);
|
||||
}
|
||||
|
||||
private Map<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;
|
||||
videoService.updateVideoFields(videoDbId, status, transcript, llmRaw);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
package com.tasteby.service;
|
||||
|
||||
import com.tasteby.repository.RestaurantRepository;
|
||||
import com.tasteby.domain.Restaurant;
|
||||
import com.tasteby.mapper.SearchMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
||||
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.*;
|
||||
@@ -14,48 +13,41 @@ public class SearchService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(SearchService.class);
|
||||
|
||||
private final NamedParameterJdbcTemplate jdbc;
|
||||
private final RestaurantRepository restaurantRepo;
|
||||
private final SearchMapper searchMapper;
|
||||
private final RestaurantService restaurantService;
|
||||
private final VectorService vectorService;
|
||||
private final CacheService cache;
|
||||
|
||||
public SearchService(NamedParameterJdbcTemplate jdbc,
|
||||
RestaurantRepository restaurantRepo,
|
||||
public SearchService(SearchMapper searchMapper,
|
||||
RestaurantService restaurantService,
|
||||
VectorService vectorService,
|
||||
CacheService cache) {
|
||||
this.jdbc = jdbc;
|
||||
this.restaurantRepo = restaurantRepo;
|
||||
this.searchMapper = searchMapper;
|
||||
this.restaurantService = restaurantService;
|
||||
this.vectorService = vectorService;
|
||||
this.cache = cache;
|
||||
}
|
||||
|
||||
public List<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 cached = cache.getRaw(key);
|
||||
if (cached != null) {
|
||||
// Deserialize from cache
|
||||
try {
|
||||
var mapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
||||
return mapper.readValue(cached, new com.fasterxml.jackson.core.type.TypeReference<>() {});
|
||||
return mapper.readValue(cached, new com.fasterxml.jackson.core.type.TypeReference<List<Restaurant>>() {});
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
List<Map<String, Object>> result;
|
||||
List<Restaurant> result;
|
||||
switch (mode) {
|
||||
case "semantic" -> result = semanticSearch(q, limit);
|
||||
case "hybrid" -> {
|
||||
var kw = keywordSearch(q, limit);
|
||||
var sem = semanticSearch(q, limit);
|
||||
Set<String> seen = new HashSet<>();
|
||||
var merged = new ArrayList<Map<String, Object>>();
|
||||
for (var r : kw) {
|
||||
String id = (String) r.get("id");
|
||||
if (seen.add(id)) merged.add(r);
|
||||
}
|
||||
for (var r : sem) {
|
||||
String id = (String) r.get("id");
|
||||
if (seen.add(id)) merged.add(r);
|
||||
}
|
||||
var merged = new ArrayList<Restaurant>();
|
||||
for (var r : kw) { if (seen.add(r.getId())) merged.add(r); }
|
||||
for (var r : sem) { if (seen.add(r.getId())) merged.add(r); }
|
||||
result = merged.size() > limit ? merged.subList(0, limit) : merged;
|
||||
}
|
||||
default -> result = keywordSearch(q, limit);
|
||||
@@ -65,53 +57,33 @@ public class SearchService {
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> keywordSearch(String q, int limit) {
|
||||
private List<Restaurant> keywordSearch(String q, int limit) {
|
||||
String pattern = "%" + q + "%";
|
||||
String sql = """
|
||||
SELECT DISTINCT r.id, r.name, r.address, r.region, r.latitude, r.longitude,
|
||||
r.cuisine_type, r.price_range, r.google_place_id,
|
||||
r.business_status, r.rating, r.rating_count
|
||||
FROM restaurants r
|
||||
JOIN video_restaurants vr ON vr.restaurant_id = r.id
|
||||
JOIN videos v ON v.id = vr.video_id
|
||||
WHERE r.latitude IS NOT NULL
|
||||
AND (UPPER(r.name) LIKE UPPER(:q)
|
||||
OR UPPER(r.address) LIKE UPPER(:q)
|
||||
OR UPPER(r.region) LIKE UPPER(:q)
|
||||
OR UPPER(r.cuisine_type) LIKE UPPER(:q)
|
||||
OR UPPER(vr.foods_mentioned) LIKE UPPER(:q)
|
||||
OR UPPER(v.title) LIKE UPPER(:q))
|
||||
FETCH FIRST :lim ROWS ONLY
|
||||
""";
|
||||
var params = new MapSqlParameterSource().addValue("q", pattern).addValue("lim", limit);
|
||||
List<Map<String, Object>> rows = jdbc.queryForList(sql, params);
|
||||
if (!rows.isEmpty()) {
|
||||
attachChannels(rows);
|
||||
List<Restaurant> results = searchMapper.keywordSearch(pattern, limit);
|
||||
if (!results.isEmpty()) {
|
||||
attachChannels(results);
|
||||
}
|
||||
return rows;
|
||||
return results;
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> semanticSearch(String q, int limit) {
|
||||
private List<Restaurant> semanticSearch(String q, int limit) {
|
||||
try {
|
||||
var similar = vectorService.searchSimilar(q, Math.max(30, limit * 3), 0.57);
|
||||
if (similar.isEmpty()) return List.of();
|
||||
|
||||
// Deduplicate by restaurant_id, preserving distance order
|
||||
Set<String> seen = new LinkedHashSet<>();
|
||||
for (var s : similar) {
|
||||
seen.add((String) s.get("restaurant_id"));
|
||||
}
|
||||
|
||||
List<Map<String, Object>> results = new ArrayList<>();
|
||||
List<Restaurant> results = new ArrayList<>();
|
||||
for (String rid : seen) {
|
||||
if (results.size() >= limit) break;
|
||||
var r = restaurantRepo.findById(rid);
|
||||
if (r != null && r.get("latitude") != null) {
|
||||
var r = restaurantService.findById(rid);
|
||||
if (r != null && r.getLatitude() != null) {
|
||||
results.add(r);
|
||||
}
|
||||
}
|
||||
|
||||
if (!results.isEmpty()) attachChannels(results);
|
||||
return results;
|
||||
} catch (Exception e) {
|
||||
log.warn("Semantic search failed, falling back to keyword: {}", e.getMessage());
|
||||
@@ -119,34 +91,21 @@ public class SearchService {
|
||||
}
|
||||
}
|
||||
|
||||
private void attachChannels(List<Map<String, Object>> rows) {
|
||||
List<String> ids = rows.stream()
|
||||
.map(r -> (String) r.get("id"))
|
||||
.filter(Objects::nonNull).toList();
|
||||
private void attachChannels(List<Restaurant> restaurants) {
|
||||
List<String> ids = restaurants.stream().map(Restaurant::getId).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));
|
||||
|
||||
var channelRows = searchMapper.findChannelsByRestaurantIds(ids);
|
||||
Map<String, List<String>> chMap = new HashMap<>();
|
||||
jdbc.query(sql, params, rs -> {
|
||||
chMap.computeIfAbsent(rs.getString("RESTAURANT_ID"), k -> new ArrayList<>())
|
||||
.add(rs.getString("CHANNEL_NAME"));
|
||||
});
|
||||
for (var r : rows) {
|
||||
String id = (String) r.get("id");
|
||||
r.put("channels", chMap.getOrDefault(id, List.of()));
|
||||
for (var row : channelRows) {
|
||||
String rid = (String) row.getOrDefault("restaurant_id", row.get("RESTAURANT_ID"));
|
||||
String ch = (String) row.getOrDefault("channel_name", row.get("CHANNEL_NAME"));
|
||||
if (rid != null && ch != null) {
|
||||
chMap.computeIfAbsent(rid, k -> new ArrayList<>()).add(ch);
|
||||
}
|
||||
}
|
||||
for (var r : restaurants) {
|
||||
r.setChannels(chMap.getOrDefault(r.getId(), List.of()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
104
backend-java/src/main/java/com/tasteby/service/VideoService.java
Normal file
104
backend-java/src/main/java/com/tasteby/service/VideoService.java
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,7 @@ import io.github.thoroldvix.api.YoutubeTranscriptApi;
|
||||
import com.microsoft.playwright.*;
|
||||
import com.microsoft.playwright.options.Cookie;
|
||||
import com.microsoft.playwright.options.WaitUntilState;
|
||||
import com.tasteby.repository.ChannelRepository;
|
||||
import com.tasteby.repository.VideoRepository;
|
||||
import com.tasteby.domain.Channel;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
@@ -33,20 +32,20 @@ public class YouTubeService {
|
||||
|
||||
private final WebClient webClient;
|
||||
private final ObjectMapper mapper;
|
||||
private final ChannelRepository channelRepo;
|
||||
private final VideoRepository videoRepo;
|
||||
private final ChannelService channelService;
|
||||
private final VideoService videoService;
|
||||
private final String apiKey;
|
||||
|
||||
public YouTubeService(ObjectMapper mapper,
|
||||
ChannelRepository channelRepo,
|
||||
VideoRepository videoRepo,
|
||||
ChannelService channelService,
|
||||
VideoService videoService,
|
||||
@Value("${app.google.youtube-api-key}") String apiKey) {
|
||||
this.webClient = WebClient.builder()
|
||||
.baseUrl("https://www.googleapis.com/youtube/v3")
|
||||
.build();
|
||||
this.mapper = mapper;
|
||||
this.channelRepo = channelRepo;
|
||||
this.videoRepo = videoRepo;
|
||||
this.channelService = channelService;
|
||||
this.videoService = videoService;
|
||||
this.apiKey = apiKey;
|
||||
}
|
||||
|
||||
@@ -151,13 +150,13 @@ public class YouTubeService {
|
||||
* Scan a single channel for new videos. Returns scan result map.
|
||||
*/
|
||||
public Map<String, Object> scanChannel(String channelId, boolean full) {
|
||||
var ch = channelRepo.findByChannelId(channelId);
|
||||
Channel ch = channelService.findByChannelId(channelId);
|
||||
if (ch == null) return null;
|
||||
|
||||
String dbId = (String) ch.get("id");
|
||||
String titleFilter = (String) ch.get("title_filter");
|
||||
String after = full ? null : videoRepo.getLatestVideoDate(dbId);
|
||||
Set<String> existing = videoRepo.getExistingVideoIds(dbId);
|
||||
String dbId = ch.getId();
|
||||
String titleFilter = ch.getTitleFilter();
|
||||
String after = full ? null : videoService.getLatestVideoDate(dbId);
|
||||
Set<String> existing = videoService.getExistingVideoIds(dbId);
|
||||
|
||||
List<Map<String, Object>> allFetched = fetchChannelVideos(channelId, after, true);
|
||||
int totalFetched = allFetched.size();
|
||||
@@ -169,7 +168,7 @@ public class YouTubeService {
|
||||
candidates.add(v);
|
||||
}
|
||||
|
||||
int newCount = videoRepo.saveVideosBatch(dbId, candidates);
|
||||
int newCount = videoService.saveVideosBatch(dbId, candidates);
|
||||
return Map.of(
|
||||
"total_fetched", totalFetched,
|
||||
"new_videos", newCount,
|
||||
@@ -181,17 +180,16 @@ public class YouTubeService {
|
||||
* Scan all active channels. Returns total new video count.
|
||||
*/
|
||||
public int scanAllChannels() {
|
||||
var channels = channelRepo.findAllActive();
|
||||
List<Channel> channels = channelService.findAllActive();
|
||||
int totalNew = 0;
|
||||
for (var ch : channels) {
|
||||
try {
|
||||
String chId = (String) ch.get("channel_id");
|
||||
var result = scanChannel(chId, false);
|
||||
var result = scanChannel(ch.getChannelId(), false);
|
||||
if (result != null) {
|
||||
totalNew += ((Number) result.get("new_videos")).intValue();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to scan channel {}: {}", ch.get("channel_name"), e.getMessage());
|
||||
log.error("Failed to scan channel {}: {}", ch.getChannelName(), e.getMessage());
|
||||
}
|
||||
}
|
||||
return totalNew;
|
||||
|
||||
12
backend-java/src/main/java/com/tasteby/util/IdGenerator.java
Normal file
12
backend-java/src/main/java/com/tasteby/util/IdGenerator.java
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ spring:
|
||||
|
||||
jackson:
|
||||
default-property-inclusion: non_null
|
||||
property-naming-strategy: SNAKE_CASE
|
||||
serialization:
|
||||
write-dates-as-timestamps: false
|
||||
|
||||
@@ -57,7 +58,13 @@ app:
|
||||
cache:
|
||||
ttl-seconds: 600
|
||||
|
||||
mybatis:
|
||||
mapper-locations: classpath:mybatis/mapper/*.xml
|
||||
config-location: classpath:mybatis/mybatis-config.xml
|
||||
type-aliases-package: com.tasteby.domain
|
||||
type-handlers-package: com.tasteby.config
|
||||
|
||||
logging:
|
||||
level:
|
||||
com.tasteby: DEBUG
|
||||
org.springframework.jdbc: DEBUG
|
||||
com.tasteby.mapper: DEBUG
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
130
backend-java/src/main/resources/mybatis/mapper/ReviewMapper.xml
Normal file
130
backend-java/src/main/resources/mybatis/mapper/ReviewMapper.xml
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
219
backend-java/src/main/resources/mybatis/mapper/VideoMapper.xml
Normal file
219
backend-java/src/main/resources/mybatis/mapper/VideoMapper.xml
Normal 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) > 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
|
||||
<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) > 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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user