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