diff --git a/packages/bot/src/lib/logger.ts b/packages/bot/src/lib/logger.ts index 15edeb2..1543ad5 100644 --- a/packages/bot/src/lib/logger.ts +++ b/packages/bot/src/lib/logger.ts @@ -27,6 +27,11 @@ const logger = pino({ formatters: { level: (label) => ({ level: label }), }, + // Serialize Error objects properly (pino defaults to {} for non-'err' keys) + serializers: { + error: pino.stdSerializers.err, + err: pino.stdSerializers.err, + }, // ISO 8601 timestamp with timezone offset timestamp: pino.stdTimeFunctions.isoTime, // Development: pretty print diff --git a/packages/bot/src/schedulers/rss-poller.ts b/packages/bot/src/schedulers/rss-poller.ts index e816688..b0afe1e 100644 --- a/packages/bot/src/schedulers/rss-poller.ts +++ b/packages/bot/src/schedulers/rss-poller.ts @@ -5,6 +5,7 @@ */ import { getMemberService } from '../services/member.service'; +import { getPostService } from '../services/post.service'; import { getRssService, type PollResult, type RssFeedItem } from '../services/rss.service'; import { type Member, MemberStatus } from '@blog-study/shared/db'; import logger from '../lib/logger'; @@ -79,26 +80,36 @@ export class RssPoller { try { const rssService = getRssService(); + const postService = getPostService(); const items = await rssService.fetchFeed(member.rssUrl); - + + if (items.length === 0) { + return { memberId: member.id, success: true, newItems: [] }; + } + + // Batch duplicate check: 1 IN query instead of N individual SELECTs + // postService.create() has its own getByUrl guard as a write-time safety net + const feedUrls = items.map(item => item.link).filter(Boolean); + const existingUrls = await postService.getExistingUrls(feedUrls); + const newItems = items.filter(item => !existingUrls.has(item.link)); + return { memberId: member.id, success: true, - newItems: items, + newItems, }; } catch (error) { // Requirements: 6.5 - Log error and continue processing other feeds - const errorMessage = error instanceof Error ? error.message : String(error); logger.error({ member: member.discordUsername, - error: errorMessage, + error, }, 'πŸ“‘ [RSS] 멀버 ν”Όλ“œ 폴링 μ—λŸ¬'); - + return { memberId: member.id, success: false, newItems: [], - error: errorMessage, + error: error instanceof Error ? error.message : String(error), }; } } @@ -141,13 +152,13 @@ export class RssPoller { try { await this.onNewPost(member, result.newItems); } catch (callbackError) { - const errorMsg = callbackError instanceof Error - ? callbackError.message - : String(callbackError); logger.error({ member: member.discordUsername, - error: errorMsg, + error: callbackError, }, 'πŸ“‘ [RSS] 콜백 μ—λŸ¬'); + const errorMsg = callbackError instanceof Error + ? callbackError.message + : String(callbackError); errors.push(`Callback error for ${member.discordUsername}: ${errorMsg}`); } } diff --git a/packages/bot/src/services/post.service.ts b/packages/bot/src/services/post.service.ts index e0503c2..8903cdd 100644 --- a/packages/bot/src/services/post.service.ts +++ b/packages/bot/src/services/post.service.ts @@ -4,7 +4,7 @@ * Requirements: 6.3, 6.4, 6.6 */ -import { eq, and, count } from 'drizzle-orm'; +import { eq, and, count, inArray } from 'drizzle-orm'; import { getDb, posts, @@ -175,6 +175,22 @@ export class PostService { return post || null; } + /** + * Get existing URLs from a list of URLs (batch duplicate check) + * Note: URL is globally unique across all members (schema unique constraint), + * so no memberId scoping is needed here. + */ + async getExistingUrls(urls: string[]): Promise> { + if (urls.length === 0) return new Set(); + + const results = await this.db + .select({ url: posts.url }) + .from(posts) + .where(inArray(posts.url, urls)); + + return new Set(results.map(r => r.url)); + } + /** * Get all posts */