Migrate to MyBatis with proper Controller→Service→Mapper layering

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.tasteby.mapper.SearchMapper">
<resultMap id="restaurantMap" type="Restaurant">
<id property="id" column="id"/>
<result property="name" column="name"/>
<result property="address" column="address"/>
<result property="region" column="region"/>
<result property="latitude" column="latitude"/>
<result property="longitude" column="longitude"/>
<result property="cuisineType" column="cuisine_type"/>
<result property="priceRange" column="price_range"/>
<result property="googlePlaceId" column="google_place_id"/>
<result property="businessStatus" column="business_status"/>
<result property="rating" column="rating"/>
<result property="ratingCount" column="rating_count"/>
</resultMap>
<select id="keywordSearch" resultMap="restaurantMap">
SELECT DISTINCT r.id, r.name, r.address, r.region, r.latitude, r.longitude,
r.cuisine_type, r.price_range, r.google_place_id,
r.business_status, r.rating, r.rating_count
FROM restaurants r
JOIN video_restaurants vr ON vr.restaurant_id = r.id
JOIN videos v ON v.id = vr.video_id
WHERE r.latitude IS NOT NULL
AND (UPPER(r.name) LIKE UPPER(#{query})
OR UPPER(r.address) LIKE UPPER(#{query})
OR UPPER(r.region) LIKE UPPER(#{query})
OR UPPER(r.cuisine_type) LIKE UPPER(#{query})
OR UPPER(vr.foods_mentioned) LIKE UPPER(#{query})
OR UPPER(v.title) LIKE UPPER(#{query}))
FETCH FIRST #{limit} ROWS ONLY
</select>
<select id="findChannelsByRestaurantIds" resultType="map">
SELECT DISTINCT vr.restaurant_id, c.channel_name
FROM video_restaurants vr
JOIN videos v ON v.id = vr.video_id
JOIN channels c ON c.id = v.channel_id
WHERE vr.restaurant_id IN
<foreach item="id" collection="ids" open="(" separator="," close=")">
#{id}
</foreach>
</select>
</mapper>

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.tasteby.mapper.StatsMapper">
<update id="recordVisit">
MERGE INTO site_visits sv
USING (SELECT TRUNC(SYSDATE) AS d FROM dual) src
ON (sv.visit_date = src.d)
WHEN MATCHED THEN UPDATE SET sv.visit_count = sv.visit_count + 1
WHEN NOT MATCHED THEN INSERT (visit_date, visit_count) VALUES (src.d, 1)
</update>
<select id="getTodayVisits" resultType="int">
SELECT NVL(visit_count, 0)
FROM site_visits
WHERE visit_date = TRUNC(SYSDATE)
</select>
<select id="getTotalVisits" resultType="int">
SELECT NVL(SUM(visit_count), 0)
FROM site_visits
</select>
</mapper>

View File

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

View File

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

View File

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

View File

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