Files
ai-code-app/etc/servers/work-server/src/services/baseball-ticket-bay-service.ts
2026-05-28 08:09:49 +09:00

1654 lines
54 KiB
TypeScript

import type { Knex } from 'knex';
import { z } from 'zod';
import { db } from '../db/client.js';
import { sendNotifications } from './notification-service.js';
import { getSharedResourceTokenDetail } from './shared-resource-token-service.js';
const TICKET_BAY_ORIGIN = 'https://www.ticketbay.co.kr';
const SPORTS_SEARCH_KEY = '5';
const MAX_CATEGORY_COUNT = 16;
const MAX_RESULT_COUNT = 12;
const TICKET_BAY_FETCH_TIMEOUT_MS = 12_000;
const TICKET_BAY_PRODUCT_PAGE_SIZE = 100;
const BASEBALL_TICKET_BAY_BATCH_ADVISORY_LOCK_KEY = 741_205_261;
const teamKeywordMap: Record<string, string> = {
LG: 'LG',
: '두산',
SSG: 'SSG',
: '키움',
KT: 'KT',
KIA: 'KIA',
NC: 'NC',
: '롯데',
: '삼성',
: '한화',
: '야구',
};
const titleSearchKeywordEntries = [
'잠실',
'고척',
'광주',
'대구',
'대전',
'사직',
'수원',
'창원',
'인천',
'문학',
'LG',
'두산',
'SSG',
'키움',
'KT',
'KIA',
'NC',
'롯데',
'삼성',
'한화',
] as const;
const ticketAlertSearchSchema = z.object({
title: z.string().trim().min(1).max(200),
eventDate: z.string().trim().regex(/^\d{4}-\d{2}-\d{2}$/),
team: z.string().trim().min(1).max(50),
zone: z.string().trim().min(1).max(100),
aisleSide: z.string().trim().min(1).max(100),
seatDirections: z.array(z.string().trim().min(1).max(50)).max(10).default([]),
maxPrice: z.number().finite().positive().nullable(),
seatCount: z.number().int().positive().max(10),
});
type TicketAlertSearchInput = z.infer<typeof ticketAlertSearchSchema>;
type AlertMatchFailureReason =
| 'status'
| 'eventDate'
| 'team'
| 'maxPrice'
| 'seatCount'
| 'zone'
| 'aisleSide'
| 'seatDirections';
type AlertMatchEvaluation =
| { matched: true }
| {
matched: false;
failedReason: AlertMatchFailureReason;
};
type SearchCategoryItem = {
depth1Id?: number;
depth2Id?: number;
depth3Id?: number;
depth1Name?: string;
depth2Name?: string;
depth3Name?: string;
startDate?: string | null;
endDate?: string | null;
performCity?: string | null;
performLocation?: string | null;
};
type TicketBayProductListItem = {
id: number;
category_id: number;
display_number: string;
name: string;
start_perform_date: string | null;
end_perform_date: string | null;
depth1_name?: string | null;
depth2_name?: string | null;
depth3_name?: string | null;
info_type?: string | null;
seat_number_type?: string | null;
seat_number?: string | null;
area?: string | null;
floor?: string | null;
grade?: string | null;
addinfo?: string | null;
sale_quantity?: number | null;
is_together?: string | null;
price?: number | null;
total_price?: number | null;
product_status?: string | null;
is_use_safe?: string | null;
is_use_pin?: string | null;
is_use_delivery?: string | null;
is_use_field?: string | null;
is_use_etc?: string | null;
description?: string | null;
created_at?: string | null;
transaction_type?: string | null;
};
type TicketBayProductDetailInfo = TicketBayProductListItem & {
product_remark?: Array<{ id?: number; content?: string | null }>;
seat_remark?: Array<{ id?: number; content?: string | null }>;
photo_res?: Array<{ photo_url?: string | null; origin_name?: string | null }>;
transaction_type?: string | null;
};
type TicketBayCategoryInfo = {
seat_image?: string | null;
};
const CATEGORY_VARIANT_SUFFIX_PATTERN = /\s*\((?:|\/)\)\s*$/u;
function normalizeText(value: unknown) {
return String(value ?? '').trim();
}
function normalizeDateTime(value: unknown) {
if (value instanceof Date) {
return value.toISOString();
}
const normalized = normalizeText(value);
if (!normalized) {
return '';
}
const parsed = Date.parse(normalized);
return Number.isNaN(parsed) ? normalized : new Date(parsed).toISOString();
}
function normalizeTimestampForDb(value: unknown) {
if (value instanceof Date) {
return value.toISOString();
}
const normalized = normalizeText(value);
if (!normalized) {
return null;
}
const candidates = [
normalized,
normalized.replace(/\s*\([^)]*\)\s*$/u, ''),
normalized.replace(/\s*\([^)]*\)\s*$/u, '').replace(/\s+GMT([+-]\d{4})$/iu, ' $1'),
];
for (const candidate of candidates) {
const parsed = Date.parse(candidate);
if (!Number.isNaN(parsed)) {
return new Date(parsed).toISOString();
}
const offsetMatch = candidate.match(
/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\s*([+-]\d{2})(\d{2})$/u,
);
if (offsetMatch) {
const [, datePart, timePart, hourOffset, minuteOffset] = offsetMatch;
const reparsed = Date.parse(`${datePart}T${timePart}${hourOffset}:${minuteOffset}`);
if (!Number.isNaN(reparsed)) {
return new Date(reparsed).toISOString();
}
}
}
return null;
}
function normalizeDateOnly(value: unknown) {
const normalized = normalizeText(value);
if (!normalized) {
return '';
}
return normalized.slice(0, 10);
}
function buildCategorySearchKeywords(input: TicketAlertSearchInput) {
const keywords: string[] = [];
const primaryKeyword = teamKeywordMap[input.team] ?? normalizeText(input.team);
if (primaryKeyword) {
keywords.push(primaryKeyword);
}
const title = normalizeText(input.title);
if (title) {
titleSearchKeywordEntries.forEach((keyword) => {
if (title.includes(keyword)) {
keywords.push(keyword);
}
});
}
return [...new Set(keywords.filter(Boolean))];
}
function resolveCategoryFamilyKey(item: SearchCategoryItem) {
const label = normalizeText(item.depth3Name || item.depth2Name || String(item.depth3Id));
return label.replace(CATEGORY_VARIANT_SUFFIX_PATTERN, '').trim();
}
function expandCategoryVariants(categories: SearchCategoryItem[]) {
const familyMap = new Map<string, SearchCategoryItem[]>();
categories.forEach((item) => {
const familyKey = resolveCategoryFamilyKey(item);
const existing = familyMap.get(familyKey);
if (existing) {
existing.push(item);
return;
}
familyMap.set(familyKey, [item]);
});
const expanded: SearchCategoryItem[] = [];
const seenCategoryIds = new Set<number>();
categories.forEach((item) => {
const familyItems = familyMap.get(resolveCategoryFamilyKey(item)) ?? [item];
familyItems.forEach((familyItem) => {
if (typeof familyItem.depth3Id !== 'number' || seenCategoryIds.has(familyItem.depth3Id)) {
return;
}
seenCategoryIds.add(familyItem.depth3Id);
expanded.push(familyItem);
});
});
return expanded;
}
async function fetchText(url: string) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), TICKET_BAY_FETCH_TIMEOUT_MS);
let response: Response;
try {
response = await fetch(url, {
signal: controller.signal,
headers: {
'user-agent':
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36',
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
},
});
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
throw new Error(`티켓베이 요청 시간 초과(${TICKET_BAY_FETCH_TIMEOUT_MS}ms): ${url}`);
}
throw error;
} finally {
clearTimeout(timeout);
}
if (!response.ok) {
throw new Error(`티켓베이 요청 실패: ${response.status} ${url}`);
}
return response.text();
}
async function fetchJson<T>(url: string, init?: RequestInit) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), TICKET_BAY_FETCH_TIMEOUT_MS);
let response: Response;
try {
response = await fetch(url, {
...init,
signal: controller.signal,
headers: {
'user-agent':
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36',
accept: 'application/json, text/plain, */*',
'content-type': 'application/json',
...(init?.headers ?? {}),
},
});
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
throw new Error(`티켓베이 요청 시간 초과(${TICKET_BAY_FETCH_TIMEOUT_MS}ms): ${url}`);
}
throw error;
} finally {
clearTimeout(timeout);
}
if (!response.ok) {
throw new Error(`티켓베이 요청 실패: ${response.status} ${url}`);
}
return (await response.json()) as T;
}
function parseNextDataJson(html: string) {
const match = html.match(/<script id="__NEXT_DATA__" type="application\/json">([\s\S]*?)<\/script>/);
if (!match?.[1]) {
throw new Error('티켓베이 응답에서 NEXT_DATA를 찾지 못했습니다.');
}
return JSON.parse(match[1]) as {
props?: {
pageProps?: Record<string, unknown>;
};
};
}
async function fetchSearchCategories(keyword: string) {
const html = await fetchText(`${TICKET_BAY_ORIGIN}/search?keyword=${encodeURIComponent(keyword)}`);
const pageProps = parseNextDataJson(html).props?.pageProps ?? {};
const searchObj = (pageProps.searchObj ?? {}) as Record<string, SearchCategoryItem[] | undefined>;
return Array.isArray(searchObj[SPORTS_SEARCH_KEY]) ? searchObj[SPORTS_SEARCH_KEY]! : [];
}
async function fetchProductListPage(categoryId: number, page: number, input: TicketAlertSearchInput) {
const requestBody: Record<string, unknown> = {
category_id: categoryId,
depth1_id: 5,
size: TICKET_BAY_PRODUCT_PAGE_SIZE,
offset: page,
// TicketBay public products API narrows results incorrectly when time is appended.
start_perform_date: input.eventDate,
};
if (input.team !== '전체') {
requestBody.seat_floor = `vs ${input.team}`;
}
if (input.seatCount > 0) {
requestBody.sale_quantity = Math.min(input.seatCount, 5);
}
if (input.seatCount > 1) {
requestBody.is_together = 'YES';
}
const response = await fetchJson<{
data?: {
content?: TicketBayProductListItem[];
totalPages?: number;
totalElements?: number;
};
}>(`${TICKET_BAY_ORIGIN}/ticketbayApi/product/v1/public/products`, {
method: 'POST',
body: JSON.stringify(requestBody),
});
const listServer = response.data ?? {};
return {
items: Array.isArray(listServer.content) ? listServer.content : [],
totalPages: typeof listServer.totalPages === 'number' ? listServer.totalPages : 0,
totalElements: typeof listServer.totalElements === 'number' ? listServer.totalElements : 0,
};
}
async function fetchProductDetail(displayNumber: string) {
const html = await fetchText(`${TICKET_BAY_ORIGIN}/product/view/${encodeURIComponent(displayNumber)}`);
const pageProps = parseNextDataJson(html).props?.pageProps ?? {};
return {
info: ((pageProps.info ?? null) as TicketBayProductDetailInfo | null) ?? null,
categoryInfo: ((pageProps.categoryInfo ?? null) as TicketBayCategoryInfo | null) ?? null,
};
}
function matchesZone(zone: string, item: TicketBayProductListItem) {
if (zone === '전체') {
return true;
}
const haystack = [item.grade, item.name, item.description, item.addinfo, item.area].map(normalizeText).join(' ');
const zoneKeywords: Record<string, string[]> = {
: ['내야', '중앙', '1루', '3루', '레드석', '블루석', '네이비석'],
: ['외야'],
: ['테이블'],
: ['중앙'],
: ['응원'],
: ['프리미엄', 'vip', '익사이팅'],
: ['휠체어'],
};
const keywords = zoneKeywords[zone] ?? [zone];
return keywords.some((keyword) => haystack.includes(keyword));
}
function matchesAisleSide(aisleSide: string, item: TicketBayProductListItem) {
if (aisleSide === '전체') {
return true;
}
const haystack = [item.name, item.description, item.addinfo].map((value) => normalizeText(value).toLowerCase()).join(' ');
if (aisleSide === '통로측') {
return haystack.includes('통로');
}
if (aisleSide === '중앙측') {
return haystack.includes('중앙');
}
if (aisleSide === '내측') {
return haystack.includes('내측');
}
if (aisleSide === '외측') {
return haystack.includes('외측');
}
return haystack.includes(aisleSide.toLowerCase());
}
function matchesDirections(seatDirections: string[], item: TicketBayProductListItem) {
if (!seatDirections.length) {
return true;
}
const haystack = [item.grade, item.floor, item.name, item.description].map(normalizeText).join(' ');
return seatDirections.some((direction) => haystack.includes(direction));
}
function matchesSeatCount(requiredSeatCount: number, item: TicketBayProductListItem) {
const saleQuantity = typeof item.sale_quantity === 'number' ? item.sale_quantity : 0;
if (requiredSeatCount <= 1) {
return saleQuantity >= 1;
}
return saleQuantity >= requiredSeatCount && normalizeText(item.is_together) === 'YES';
}
function evaluateAlertMatch(input: TicketAlertSearchInput, item: TicketBayProductListItem): AlertMatchEvaluation {
if (normalizeText(item.product_status) !== 'SALE') {
return { matched: false, failedReason: 'status' };
}
if (normalizeDateOnly(item.start_perform_date) !== input.eventDate) {
return { matched: false, failedReason: 'eventDate' };
}
if (input.team !== '전체' && normalizeText(item.floor) !== `vs ${input.team}`) {
return { matched: false, failedReason: 'team' };
}
if (input.maxPrice !== null && typeof item.price === 'number' && item.price > input.maxPrice) {
return { matched: false, failedReason: 'maxPrice' };
}
if (!matchesSeatCount(input.seatCount, item)) {
return { matched: false, failedReason: 'seatCount' };
}
if (!matchesZone(input.zone, item)) {
return { matched: false, failedReason: 'zone' };
}
if (!matchesAisleSide(input.aisleSide, item)) {
return { matched: false, failedReason: 'aisleSide' };
}
if (!matchesDirections(input.seatDirections, item)) {
return { matched: false, failedReason: 'seatDirections' };
}
return { matched: true };
}
function resolveFailureReasonLabel(reason: AlertMatchFailureReason) {
switch (reason) {
case 'status':
return '판매중 상태 아님';
case 'eventDate':
return '경기 날짜 불일치';
case 'team':
return '상대팀 불일치';
case 'maxPrice':
return '가격 초과';
case 'seatCount':
return '연석/수량 불일치';
case 'zone':
return '구역 조건 불일치';
case 'aisleSide':
return '통로측 조건 불일치';
case 'seatDirections':
return '방향 조건 불일치';
default:
return '조건 불일치';
}
}
function buildAbsoluteTicketBayUrl(path: string | null | undefined) {
const normalized = normalizeText(path);
if (!normalized) {
return null;
}
if (normalized.startsWith('http://') || normalized.startsWith('https://')) {
return normalized;
}
return `${TICKET_BAY_ORIGIN}/${normalized.replace(/^\/+/, '')}`;
}
export const baseballTicketBaySearchBodySchema = ticketAlertSearchSchema;
export async function searchBaseballTicketBayListings(payload: unknown) {
const input = baseballTicketBaySearchBodySchema.parse(payload);
const searchKeywords = buildCategorySearchKeywords(input);
const keyword = searchKeywords[0] ?? (teamKeywordMap[input.team] ?? input.team);
const categoryMap = new Map<number, SearchCategoryItem>();
for (const searchKeyword of searchKeywords) {
const categories = await fetchSearchCategories(searchKeyword);
categories.forEach((item) => {
if (item.depth1Id !== 5 || typeof item.depth3Id !== 'number') {
return;
}
if (!categoryMap.has(item.depth3Id)) {
categoryMap.set(item.depth3Id, item);
}
});
}
const filteredCategories = expandCategoryVariants([...categoryMap.values()]).slice(0, MAX_CATEGORY_COUNT);
const scannedCategories: Array<{ categoryId: number; categoryName: string; pageCount: number; scannedItemCount: number }> = [];
const candidates: TicketBayProductListItem[] = [];
const rejectionSummaryMap = new Map<AlertMatchFailureReason, { count: number }>();
let scannedItemTotalCount = 0;
for (const category of filteredCategories) {
let pageCount = 0;
let scannedItemCount = 0;
for (let page = 0; ; page += 1) {
const listPage = await fetchProductListPage(category.depth3Id!, page, input);
pageCount += 1;
scannedItemCount += listPage.items.length;
scannedItemTotalCount += listPage.items.length;
listPage.items.forEach((item) => {
const evaluation = evaluateAlertMatch(input, item);
if (evaluation.matched) {
candidates.push(item);
return;
}
const current = rejectionSummaryMap.get(evaluation.failedReason) ?? { count: 0 };
current.count += 1;
rejectionSummaryMap.set(evaluation.failedReason, current);
});
const totalPages = listPage.totalPages > 0 ? listPage.totalPages : 1;
if (page + 1 >= totalPages || listPage.items.length === 0) {
break;
}
}
scannedCategories.push({
categoryId: category.depth3Id!,
categoryName: normalizeText(category.depth3Name || category.depth2Name || String(category.depth3Id)),
pageCount,
scannedItemCount,
});
}
const deduped = [...new Map(candidates.map((item) => [item.display_number, item])).values()].slice(0, MAX_RESULT_COUNT);
const results = [];
const rejectionSummary = [...rejectionSummaryMap.entries()]
.sort((left, right) => right[1].count - left[1].count)
.map(([reason, summary]) => ({
reason,
label: resolveFailureReasonLabel(reason),
count: summary.count,
samples: [],
}));
for (const item of deduped) {
const detail = await fetchProductDetail(item.display_number);
const info = detail.info ?? item;
const photoUrls = Array.isArray(detail.info?.photo_res)
? detail.info!.photo_res
.map((photo) => buildAbsoluteTicketBayUrl(photo.photo_url))
.filter((value): value is string => Boolean(value))
: [];
results.push({
productId: info.id,
displayNumber: normalizeText(info.display_number),
saleUrl: `${TICKET_BAY_ORIGIN}/product/view/${encodeURIComponent(normalizeText(info.display_number))}`,
title: normalizeText(info.name),
eventDateTime: normalizeText(info.start_perform_date),
categoryName: normalizeText(info.depth3_name),
teamName: normalizeText(info.depth2_name),
area: normalizeText(info.area),
rowLabel: normalizeText(info.seat_number_type || 'ROW'),
row: normalizeText(info.seat_number),
opponentOrFloor: normalizeText(info.floor),
grade: normalizeText(info.grade),
addInfo: normalizeText(info.addinfo),
seatCount: typeof info.sale_quantity === 'number' ? info.sale_quantity : null,
together: normalizeText(info.is_together) === 'YES',
price: typeof info.price === 'number' ? info.price : null,
totalPrice: typeof info.total_price === 'number' ? info.total_price : null,
transactionType: normalizeText(info.transaction_type),
sellerPost: normalizeText(info.description),
productRemarks: Array.isArray(detail.info?.product_remark)
? detail.info!.product_remark.map((remark) => normalizeText(remark.content)).filter(Boolean)
: [],
seatRemarks: Array.isArray(detail.info?.seat_remark)
? detail.info!.seat_remark.map((remark) => normalizeText(remark.content)).filter(Boolean)
: [],
seatMapImageUrl: buildAbsoluteTicketBayUrl(detail.categoryInfo?.seat_image),
photoUrls,
sellerPhotoCount: photoUrls.length,
createdAt: normalizeText(info.created_at),
safeTrade: normalizeText(info.is_use_safe) === 'YES',
pinTrade: normalizeText(info.is_use_pin) === 'YES',
deliveryTrade: normalizeText(info.is_use_delivery) === 'YES',
fieldTrade: normalizeText(info.is_use_field) === 'YES',
etcTrade: normalizeText(info.is_use_etc) === 'YES',
});
}
return {
ok: true,
keyword,
scannedCategoryCount: filteredCategories.length,
scannedCategories,
scannedItemTotalCount,
resultCount: results.length,
results,
rejectionSummary,
};
}
export const BASEBALL_TICKET_BAY_ALERT_TABLE = 'baseball_ticket_bay_alerts';
export const BASEBALL_TICKET_BAY_LOG_TABLE = 'baseball_ticket_bay_logs';
export const BASEBALL_TICKET_BAY_SEEN_PRODUCT_TABLE = 'baseball_ticket_bay_seen_products';
export type BaseballTicketBayTimeWindow = {
id: string;
start: string;
end: string;
};
export type BaseballTicketBayAlertItem = {
id: string;
clientId: string;
ownerType: BaseballTicketBayOwnerType;
ownerId: string;
ownerLabel?: string | null;
title: string;
eventDate: string;
team: string;
zone: string;
aisleSide: string;
seatDirections: string[];
maxPrice: number | null;
seatCount: number;
batchIntervalMinutes: number;
sameProductAlertEnabled: boolean;
sameProductNotifyOnce: boolean;
active: boolean;
timeWindows: BaseballTicketBayTimeWindow[];
createdAt: string;
updatedAt: string;
lastRunAt: string | null;
lastMatchAt: string | null;
};
export type BaseballTicketBayAlertLogAction = 'create' | 'run' | 'pause' | 'resume' | 'delete' | 'push';
export type BaseballTicketBayAlertLogStatus = 'info' | 'success' | 'warning' | 'error';
export type BaseballTicketBayAlertLogItem = {
id: string;
clientId: string;
ownerType: BaseballTicketBayOwnerType;
ownerId: string;
ownerLabel?: string | null;
alertId: string | null;
alertTitle: string;
action: BaseballTicketBayAlertLogAction;
status: BaseballTicketBayAlertLogStatus;
message: string;
detail: string;
createdAt: string;
payload?: BaseballTicketBayRunPayload | null;
};
export type BaseballTicketBayRunPayload = {
keyword: string;
scannedCategoryCount: number;
scannedCategories: Array<{
categoryId: number;
categoryName: string;
pageCount: number;
scannedItemCount: number;
}>;
scannedItemTotalCount: number;
results: Array<Awaited<ReturnType<typeof searchBaseballTicketBayListings>>['results'][number]>;
rejectionSummary: Array<{
reason: AlertMatchFailureReason;
label: string;
count: number;
samples: string[];
}>;
};
export type BaseballTicketBayAlertMutation = {
title: string;
eventDate: string;
team: string;
zone: string;
aisleSide: string;
seatDirections: string[];
maxPrice: number | null;
seatCount: number;
batchIntervalMinutes: number;
sameProductAlertEnabled: boolean;
sameProductNotifyOnce: boolean;
active: boolean;
timeWindows: BaseballTicketBayTimeWindow[];
};
type BaseballTicketBayOwnerType = 'client' | 'shared-token';
type BaseballTicketBayOwnerScope =
| { kind: 'all' }
| {
kind: 'owner';
ownerType: BaseballTicketBayOwnerType;
ownerId: string;
};
type BaseballTicketBayAlertRow = {
id: string;
client_id: string;
owner_type: BaseballTicketBayOwnerType;
owner_id: string;
app_origin: string | null;
app_domain: string | null;
title: string;
event_date: string;
team: string;
zone: string;
aisle_side: string;
seat_directions_json: string;
max_price: number | string | null;
seat_count: number | string;
batch_interval_minutes: number | string;
same_product_alert_enabled: boolean;
same_product_notify_once: boolean;
active: boolean;
time_windows_json: string;
created_at: string;
updated_at: string;
last_run_at: string | null;
last_match_at: string | null;
};
type BaseballTicketBayLogRow = {
id: string;
client_id: string;
owner_type: BaseballTicketBayOwnerType;
owner_id: string;
alert_id: string | null;
alert_title: string;
action: BaseballTicketBayAlertLogAction;
status: BaseballTicketBayAlertLogStatus;
message: string;
detail: string;
created_at: string;
payload_json?: string | null;
};
type BaseballTicketBaySeenProductRow = {
id: string;
alert_id: string;
product_id: string;
created_at: string;
updated_at: string;
};
let baseballTicketBayTableSetupPromise: Promise<void> | null = null;
function createId(prefix: string) {
return `${prefix}-${crypto.randomUUID()}`;
}
function normalizeOwnerType(value: unknown): BaseballTicketBayOwnerType {
return normalizeText(value) === 'shared-token' ? 'shared-token' : 'client';
}
function normalizeOwnerId(row: { owner_id?: unknown; client_id?: unknown }) {
return normalizeText(row.owner_id) || normalizeText(row.client_id);
}
function applyOwnerScope(query: Knex.QueryBuilder, scope: BaseballTicketBayOwnerScope) {
if (scope.kind === 'all') {
return query;
}
return query.where({
owner_type: scope.ownerType,
owner_id: scope.ownerId,
});
}
function normalizeNumericValue(value: unknown) {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
function parseStringArray(value: string) {
try {
const parsed = JSON.parse(value) as unknown;
return Array.isArray(parsed) ? parsed.map((item) => normalizeText(item)).filter(Boolean) : [];
} catch {
return [];
}
}
function parseTimeWindows(value: string) {
try {
const parsed = JSON.parse(value) as unknown;
if (!Array.isArray(parsed)) {
return [] as BaseballTicketBayTimeWindow[];
}
return parsed
.map((item) => {
const row = item && typeof item === 'object' ? (item as Record<string, unknown>) : {};
return {
id: normalizeText(row.id) || createId('window'),
start: normalizeText(row.start) || '00:00',
end: normalizeText(row.end) || '23:59',
};
})
.filter((item) => item.start <= item.end);
} catch {
return [];
}
}
function mapAlertRow(row: BaseballTicketBayAlertRow): BaseballTicketBayAlertItem {
return {
id: normalizeText(row.id),
clientId: normalizeText(row.client_id),
ownerType: normalizeOwnerType(row.owner_type),
ownerId: normalizeOwnerId(row),
ownerLabel: null,
title: normalizeText(row.title),
eventDate: normalizeText(row.event_date),
team: normalizeText(row.team) || '전체',
zone: normalizeText(row.zone) || '전체',
aisleSide: normalizeText(row.aisle_side) || '전체',
seatDirections: parseStringArray(row.seat_directions_json),
maxPrice: normalizeNumericValue(row.max_price),
seatCount: normalizeNumericValue(row.seat_count) ?? 1,
batchIntervalMinutes: normalizeNumericValue(row.batch_interval_minutes) ?? 3,
sameProductAlertEnabled: row.same_product_alert_enabled !== false,
sameProductNotifyOnce: row.same_product_notify_once === true,
active: row.active !== false,
timeWindows: parseTimeWindows(row.time_windows_json),
createdAt: normalizeDateTime(row.created_at),
updatedAt: normalizeDateTime(row.updated_at),
lastRunAt: row.last_run_at ? normalizeDateTime(row.last_run_at) : null,
lastMatchAt: row.last_match_at ? normalizeDateTime(row.last_match_at) : null,
};
}
function mapLogRow(row: BaseballTicketBayLogRow): BaseballTicketBayAlertLogItem {
let payload: BaseballTicketBayRunPayload | null = null;
if (normalizeText(row.payload_json)) {
try {
payload = JSON.parse(String(row.payload_json)) as BaseballTicketBayRunPayload;
} catch {
payload = null;
}
}
return {
id: normalizeText(row.id),
clientId: normalizeText(row.client_id),
ownerType: normalizeOwnerType(row.owner_type),
ownerId: normalizeOwnerId(row),
ownerLabel: null,
alertId: row.alert_id ? normalizeText(row.alert_id) : null,
alertTitle: normalizeText(row.alert_title),
action: row.action,
status: row.status,
message: normalizeText(row.message),
detail: normalizeText(row.detail),
createdAt: normalizeDateTime(row.created_at),
payload,
};
}
async function attachOwnerLabels<T extends { ownerType: BaseballTicketBayOwnerType; ownerId: string; ownerLabel?: string | null }>(items: T[]) {
const sharedTokenOwnerIds = Array.from(
new Set(
items
.filter((item) => item.ownerType === 'shared-token')
.map((item) => normalizeText(item.ownerId))
.filter(Boolean),
),
);
if (!sharedTokenOwnerIds.length) {
return items;
}
const labelEntries = await Promise.all(
sharedTokenOwnerIds.map(async (tokenId) => {
const detail = await getSharedResourceTokenDetail(tokenId);
return [tokenId, detail?.token.resourceLabel?.trim() || detail?.token.name?.trim() || null] as const;
}),
);
const labelMap = new Map(labelEntries);
return items.map((item) => ({
...item,
ownerLabel: item.ownerType === 'shared-token' ? labelMap.get(normalizeText(item.ownerId)) ?? null : null,
}));
}
function formatTimeInKst(date: Date) {
return new Intl.DateTimeFormat('en-GB', {
timeZone: 'Asia/Seoul',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(date);
}
function isWithinBlockedTime(timeWindows: BaseballTicketBayTimeWindow[], now: Date) {
if (!timeWindows.length) {
return false;
}
const currentTime = formatTimeInKst(now);
return timeWindows.some((item) => item.start <= currentTime && currentTime <= item.end);
}
function buildSummaryLine(item: {
eventDateTime?: string;
opponentOrFloor?: string;
grade?: string;
area?: string;
title?: string;
addInfo?: string;
price?: number | null;
seatCount?: number | null;
sellerPhotoCount?: number | null;
}) {
return [
normalizeText(item.eventDateTime),
normalizeText(item.opponentOrFloor),
normalizeText(item.grade),
normalizeText(item.area),
normalizeText(item.title),
item.price != null ? `${item.price.toLocaleString()}` : '',
item.seatCount != null ? `${item.seatCount}` : '',
normalizeText(item.addInfo),
item.sellerPhotoCount === 0 ? '직관샷 없음' : item.sellerPhotoCount != null ? `직관샷 ${item.sellerPhotoCount}` : '',
]
.filter(Boolean)
.join(' · ');
}
function buildNotificationBody(alert: BaseballTicketBayAlertItem, items: Array<Record<string, unknown>>) {
const header = `${alert.team} · ${alert.eventDate} · ${items.length}개 티켓 발견`;
const lines = items.slice(0, 5).map((item) =>
`- ${buildSummaryLine({
eventDateTime: normalizeText(item.eventDateTime),
opponentOrFloor: normalizeText(item.opponentOrFloor),
grade: normalizeText(item.grade),
area: normalizeText(item.area),
title: normalizeText(item.title),
addInfo: normalizeText(item.addInfo),
price: typeof item.price === 'number' ? item.price : null,
seatCount: typeof item.seatCount === 'number' ? item.seatCount : null,
sellerPhotoCount: typeof item.sellerPhotoCount === 'number' ? item.sellerPhotoCount : null,
})}`,
);
return [header, ...lines].join('\n');
}
function pickProductId(item: Record<string, unknown>) {
return normalizeText(item.displayNumber) || normalizeText(item.productId);
}
export async function ensureBaseballTicketBayTables() {
if (!baseballTicketBayTableSetupPromise) {
baseballTicketBayTableSetupPromise = (async () => {
const hasAlertTable = await db.schema.hasTable(BASEBALL_TICKET_BAY_ALERT_TABLE);
if (!hasAlertTable) {
await db.schema.createTable(BASEBALL_TICKET_BAY_ALERT_TABLE, (table) => {
table.string('id', 120).primary();
table.string('client_id', 200).notNullable().index();
table.string('owner_type', 40).notNullable().defaultTo('client').index();
table.string('owner_id', 200).notNullable().defaultTo('').index();
table.text('app_origin').nullable();
table.string('app_domain', 255).nullable();
table.string('title', 255).notNullable();
table.string('event_date', 20).notNullable().index();
table.string('team', 50).notNullable().defaultTo('전체');
table.string('zone', 100).notNullable().defaultTo('전체');
table.string('aisle_side', 100).notNullable().defaultTo('전체');
table.text('seat_directions_json').notNullable().defaultTo('[]');
table.integer('max_price').nullable();
table.integer('seat_count').notNullable().defaultTo(1);
table.integer('batch_interval_minutes').notNullable().defaultTo(3);
table.boolean('same_product_alert_enabled').notNullable().defaultTo(true);
table.boolean('same_product_notify_once').notNullable().defaultTo(false);
table.boolean('active').notNullable().defaultTo(true);
table.text('time_windows_json').notNullable().defaultTo('[]');
table.timestamp('last_run_at').nullable();
table.timestamp('last_match_at').nullable();
table.timestamp('created_at').notNullable().defaultTo(db.fn.now());
table.timestamp('updated_at').notNullable().defaultTo(db.fn.now());
});
}
const hasLogTable = await db.schema.hasTable(BASEBALL_TICKET_BAY_LOG_TABLE);
if (!hasLogTable) {
await db.schema.createTable(BASEBALL_TICKET_BAY_LOG_TABLE, (table) => {
table.string('id', 120).primary();
table.string('client_id', 200).notNullable().index();
table.string('owner_type', 40).notNullable().defaultTo('client').index();
table.string('owner_id', 200).notNullable().defaultTo('').index();
table.string('alert_id', 120).nullable().index();
table.string('alert_title', 255).notNullable();
table.string('action', 40).notNullable();
table.string('status', 40).notNullable();
table.string('message', 255).notNullable();
table.text('detail').notNullable().defaultTo('');
table.text('payload_json').nullable();
table.timestamp('created_at').notNullable().defaultTo(db.fn.now()).index();
});
}
const hasPayloadJsonColumn = await db.schema.hasColumn(BASEBALL_TICKET_BAY_LOG_TABLE, 'payload_json');
if (!hasPayloadJsonColumn) {
await db.schema.alterTable(BASEBALL_TICKET_BAY_LOG_TABLE, (table) => {
table.text('payload_json').nullable();
});
}
const hasAlertOwnerTypeColumn = await db.schema.hasColumn(BASEBALL_TICKET_BAY_ALERT_TABLE, 'owner_type');
if (!hasAlertOwnerTypeColumn) {
await db.schema.alterTable(BASEBALL_TICKET_BAY_ALERT_TABLE, (table) => {
table.string('owner_type', 40).notNullable().defaultTo('client').index();
});
}
const hasAlertOwnerIdColumn = await db.schema.hasColumn(BASEBALL_TICKET_BAY_ALERT_TABLE, 'owner_id');
if (!hasAlertOwnerIdColumn) {
await db.schema.alterTable(BASEBALL_TICKET_BAY_ALERT_TABLE, (table) => {
table.string('owner_id', 200).notNullable().defaultTo('').index();
});
}
await db(BASEBALL_TICKET_BAY_ALERT_TABLE)
.where((builder) => {
builder.whereNull('owner_type').orWhere('owner_type', '');
})
.update({ owner_type: 'client' });
await db(BASEBALL_TICKET_BAY_ALERT_TABLE)
.where((builder) => {
builder.whereNull('owner_id').orWhere('owner_id', '');
})
.update({ owner_id: db.ref('client_id') });
const hasLogOwnerTypeColumn = await db.schema.hasColumn(BASEBALL_TICKET_BAY_LOG_TABLE, 'owner_type');
if (!hasLogOwnerTypeColumn) {
await db.schema.alterTable(BASEBALL_TICKET_BAY_LOG_TABLE, (table) => {
table.string('owner_type', 40).notNullable().defaultTo('client').index();
});
}
const hasLogOwnerIdColumn = await db.schema.hasColumn(BASEBALL_TICKET_BAY_LOG_TABLE, 'owner_id');
if (!hasLogOwnerIdColumn) {
await db.schema.alterTable(BASEBALL_TICKET_BAY_LOG_TABLE, (table) => {
table.string('owner_id', 200).notNullable().defaultTo('').index();
});
}
await db(BASEBALL_TICKET_BAY_LOG_TABLE)
.where((builder) => {
builder.whereNull('owner_type').orWhere('owner_type', '');
})
.update({ owner_type: 'client' });
await db(BASEBALL_TICKET_BAY_LOG_TABLE)
.where((builder) => {
builder.whereNull('owner_id').orWhere('owner_id', '');
})
.update({ owner_id: db.ref('client_id') });
const hasSeenTable = await db.schema.hasTable(BASEBALL_TICKET_BAY_SEEN_PRODUCT_TABLE);
if (!hasSeenTable) {
await db.schema.createTable(BASEBALL_TICKET_BAY_SEEN_PRODUCT_TABLE, (table) => {
table.string('id', 120).primary();
table.string('alert_id', 120).notNullable().index();
table.string('product_id', 200).notNullable();
table.timestamp('created_at').notNullable().defaultTo(db.fn.now());
table.timestamp('updated_at').notNullable().defaultTo(db.fn.now());
table.unique(['alert_id', 'product_id']);
});
}
})().catch((error) => {
baseballTicketBayTableSetupPromise = null;
throw error;
});
}
return baseballTicketBayTableSetupPromise;
}
export async function listBaseballTicketBayAlerts(scope: BaseballTicketBayOwnerScope) {
await ensureBaseballTicketBayTables();
const query = applyOwnerScope(db(BASEBALL_TICKET_BAY_ALERT_TABLE).select('*'), scope);
const rows = (await query.orderBy([
{ column: 'event_date', order: 'asc' },
{ column: 'owner_type', order: 'asc' },
{ column: 'owner_id', order: 'asc' },
{ column: 'client_id', order: 'asc' },
{ column: 'created_at', order: 'desc' },
])) as BaseballTicketBayAlertRow[];
return attachOwnerLabels(rows.map((row: BaseballTicketBayAlertRow) => mapAlertRow(row)));
}
export async function listBaseballTicketBayLogs(
scope: BaseballTicketBayOwnerScope,
alertId?: string,
) {
await ensureBaseballTicketBayTables();
let query = applyOwnerScope(db(BASEBALL_TICKET_BAY_LOG_TABLE).select('*'), scope).orderBy('created_at', 'desc').limit(200);
if (alertId) {
query = query.andWhere({ alert_id: alertId });
}
const rows = (await query) as BaseballTicketBayLogRow[];
return attachOwnerLabels(rows.map((row: BaseballTicketBayLogRow) => mapLogRow(row)));
}
export async function createBaseballTicketBayLog(args: {
clientId: string;
ownerType: BaseballTicketBayOwnerType;
ownerId: string;
alertId?: string | null;
alertTitle: string;
action: BaseballTicketBayAlertLogAction;
status: BaseballTicketBayAlertLogStatus;
message: string;
detail?: string;
payload?: BaseballTicketBayRunPayload | null;
}) {
await ensureBaseballTicketBayTables();
const row: BaseballTicketBayLogRow = {
id: createId('log'),
client_id: args.clientId,
owner_type: args.ownerType,
owner_id: args.ownerId,
alert_id: args.alertId ?? null,
alert_title: args.alertTitle,
action: args.action,
status: args.status,
message: args.message,
detail: args.detail ?? '',
created_at: new Date().toISOString(),
payload_json: args.payload ? JSON.stringify(args.payload) : null,
};
await db(BASEBALL_TICKET_BAY_LOG_TABLE).insert(row);
return attachOwnerLabels([mapLogRow(row)]).then(([item]) => item);
}
export async function deleteBaseballTicketBayLog(logId: string, scope: BaseballTicketBayOwnerScope) {
await ensureBaseballTicketBayTables();
const existing = await applyOwnerScope(
db(BASEBALL_TICKET_BAY_LOG_TABLE)
.select('*')
.where({
id: logId,
}),
scope,
).first();
if (!existing) {
return null;
}
await applyOwnerScope(
db(BASEBALL_TICKET_BAY_LOG_TABLE).where({
id: logId,
}),
scope,
).delete();
return attachOwnerLabels([mapLogRow(existing as BaseballTicketBayLogRow)]).then(([item]) => item);
}
export async function createBaseballTicketBayAlert(
payload: BaseballTicketBayAlertMutation,
context: { clientId: string; ownerType: BaseballTicketBayOwnerType; ownerId: string; appOrigin?: string; appDomain?: string },
) {
await ensureBaseballTicketBayTables();
const now = new Date().toISOString();
const row: BaseballTicketBayAlertRow = {
id: createId('alert'),
client_id: context.clientId,
owner_type: context.ownerType,
owner_id: context.ownerId,
app_origin: normalizeText(context.appOrigin) || null,
app_domain: normalizeText(context.appDomain) || null,
title: payload.title.trim(),
event_date: payload.eventDate,
team: payload.team,
zone: payload.zone,
aisle_side: payload.aisleSide,
seat_directions_json: JSON.stringify(payload.seatDirections),
max_price: payload.maxPrice,
seat_count: payload.seatCount,
batch_interval_minutes: payload.batchIntervalMinutes,
same_product_alert_enabled: payload.sameProductAlertEnabled,
same_product_notify_once: payload.sameProductNotifyOnce,
active: payload.active,
time_windows_json: JSON.stringify(payload.timeWindows),
created_at: now,
updated_at: now,
last_run_at: null,
last_match_at: null,
};
await db(BASEBALL_TICKET_BAY_ALERT_TABLE).insert(row);
return attachOwnerLabels([mapAlertRow(row)]).then(([item]) => item);
}
export async function updateBaseballTicketBayAlert(
alertId: string,
payload: Partial<BaseballTicketBayAlertMutation>,
context:
| { scope: BaseballTicketBayOwnerScope; appOrigin?: string; appDomain?: string }
| { clientId: string; ownerType: BaseballTicketBayOwnerType; ownerId: string; appOrigin?: string; appDomain?: string },
) {
await ensureBaseballTicketBayTables();
const scope =
'scope' in context
? context.scope
: ({ kind: 'owner', ownerType: context.ownerType, ownerId: context.ownerId } as const);
const current = await applyOwnerScope(
db(BASEBALL_TICKET_BAY_ALERT_TABLE)
.select('*')
.where({ id: alertId }),
scope,
).first();
if (!current) {
throw new Error('수정할 알림을 찾지 못했습니다.');
}
const patch: Record<string, unknown> = {
updated_at: new Date().toISOString(),
};
if (payload.title !== undefined) patch.title = payload.title.trim();
if (payload.eventDate !== undefined) patch.event_date = payload.eventDate;
if (payload.team !== undefined) patch.team = payload.team;
if (payload.zone !== undefined) patch.zone = payload.zone;
if (payload.aisleSide !== undefined) patch.aisle_side = payload.aisleSide;
if (payload.seatDirections !== undefined) patch.seat_directions_json = JSON.stringify(payload.seatDirections);
if (payload.maxPrice !== undefined) patch.max_price = payload.maxPrice;
if (payload.seatCount !== undefined) patch.seat_count = payload.seatCount;
if (payload.batchIntervalMinutes !== undefined) patch.batch_interval_minutes = payload.batchIntervalMinutes;
if (payload.sameProductAlertEnabled !== undefined) patch.same_product_alert_enabled = payload.sameProductAlertEnabled;
if (payload.sameProductNotifyOnce !== undefined) patch.same_product_notify_once = payload.sameProductNotifyOnce;
if (payload.active !== undefined) patch.active = payload.active;
if (payload.timeWindows !== undefined) patch.time_windows_json = JSON.stringify(payload.timeWindows);
if (context.appOrigin) patch.app_origin = normalizeText(context.appOrigin);
if (context.appDomain) patch.app_domain = normalizeText(context.appDomain);
await applyOwnerScope(db(BASEBALL_TICKET_BAY_ALERT_TABLE).where({ id: alertId }), scope).update(patch);
const updated = await applyOwnerScope(
db(BASEBALL_TICKET_BAY_ALERT_TABLE)
.select('*')
.where({ id: alertId }),
scope,
).first();
return attachOwnerLabels([mapAlertRow(updated as BaseballTicketBayAlertRow)]).then(([item]) => item);
}
export async function deleteBaseballTicketBayAlert(alertId: string, scope: BaseballTicketBayOwnerScope) {
await ensureBaseballTicketBayTables();
const row = await applyOwnerScope(
db(BASEBALL_TICKET_BAY_ALERT_TABLE)
.select('*')
.where({ id: alertId }),
scope,
).first();
if (!row) {
return null;
}
await applyOwnerScope(
db(BASEBALL_TICKET_BAY_ALERT_TABLE).where({ id: alertId }),
scope,
).delete();
return attachOwnerLabels([mapAlertRow(row as BaseballTicketBayAlertRow)]).then(([item]) => item);
}
async function getAlertRow(alertId: string, scope?: BaseballTicketBayOwnerScope) {
await ensureBaseballTicketBayTables();
const scopedQuery = scope
? applyOwnerScope(
db(BASEBALL_TICKET_BAY_ALERT_TABLE)
.select('*')
.where({ id: alertId }),
scope,
)
: db(BASEBALL_TICKET_BAY_ALERT_TABLE)
.select('*')
.where({ id: alertId });
const row = await scopedQuery.first();
return row ? (row as BaseballTicketBayAlertRow) : null;
}
async function getSeenProductIds(alertId: string) {
await ensureBaseballTicketBayTables();
const rows = await db(BASEBALL_TICKET_BAY_SEEN_PRODUCT_TABLE)
.select('product_id')
.where({ alert_id: alertId });
return new Set(rows.map((row) => normalizeText(row.product_id)).filter(Boolean));
}
async function markSeenProductIds(alertId: string, productIds: string[]) {
const uniqueProductIds = [...new Set(productIds.map((value) => normalizeText(value)).filter(Boolean))];
if (!uniqueProductIds.length) {
return;
}
const now = new Date().toISOString();
const rows: BaseballTicketBaySeenProductRow[] = uniqueProductIds.map((productId) => ({
id: createId('seen'),
alert_id: alertId,
product_id: productId,
created_at: now,
updated_at: now,
}));
await db(BASEBALL_TICKET_BAY_SEEN_PRODUCT_TABLE)
.insert(rows)
.onConflict(['alert_id', 'product_id'])
.merge({ updated_at: now });
}
async function updateAlertRunTimestamp(alertId: string, patch: { lastRunAt: string; lastMatchAt?: string | null }) {
const normalizedLastRunAt = normalizeTimestampForDb(patch.lastRunAt) ?? new Date().toISOString();
const normalizedLastMatchAt = normalizeTimestampForDb(patch.lastMatchAt);
await db(BASEBALL_TICKET_BAY_ALERT_TABLE)
.where({ id: alertId })
.update({
last_run_at: normalizedLastRunAt,
last_match_at: normalizedLastMatchAt,
updated_at: normalizedLastRunAt,
});
}
export async function runBaseballTicketBayAlert(
alertId: string,
options?: { ignoreTimeWindow?: boolean; scope?: BaseballTicketBayOwnerScope },
) {
const row = await getAlertRow(alertId, options?.scope);
if (!row) {
throw new Error('실행할 알림을 찾지 못했습니다.');
}
const alert = mapAlertRow(row);
const now = new Date();
if (!options?.ignoreTimeWindow && isWithinBlockedTime(alert.timeWindows, now)) {
const log = await createBaseballTicketBayLog({
clientId: row.client_id,
ownerType: normalizeOwnerType(row.owner_type),
ownerId: normalizeOwnerId(row),
alertId: alert.id,
alertTitle: alert.title,
action: 'run',
status: 'warning',
message: '안받을 시간대라 이번 배치를 건너뛰었습니다.',
detail: alert.timeWindows.map((item) => `${item.start}-${item.end}`).join(', '),
});
await updateAlertRunTimestamp(alert.id, { lastRunAt: now.toISOString(), lastMatchAt: alert.lastMatchAt });
return { alert, matches: [] as Array<Record<string, unknown>>, notifiedMatches: [] as Array<Record<string, unknown>>, log };
}
const searchResult = await searchBaseballTicketBayListings({
title: alert.title,
eventDate: alert.eventDate,
team: alert.team,
zone: alert.zone,
aisleSide: alert.aisleSide,
seatDirections: alert.seatDirections,
maxPrice: alert.maxPrice,
seatCount: alert.seatCount,
});
const matches = Array.isArray(searchResult.results) ? searchResult.results : [];
const payload: BaseballTicketBayRunPayload = {
keyword: searchResult.keyword,
scannedCategoryCount: searchResult.scannedCategoryCount,
scannedCategories: searchResult.scannedCategories,
scannedItemTotalCount: searchResult.scannedItemTotalCount,
results: matches,
rejectionSummary: searchResult.rejectionSummary,
};
const seenIds =
!alert.sameProductAlertEnabled || alert.sameProductNotifyOnce
? await getSeenProductIds(alert.id)
: new Set<string>();
const notifiedMatches =
!alert.sameProductAlertEnabled || alert.sameProductNotifyOnce
? matches.filter((item) => !seenIds.has(pickProductId(item as Record<string, unknown>)))
: matches;
if (!notifiedMatches.length) {
const topRejection = payload.rejectionSummary[0] ?? null;
const hasRejectedListings = matches.length === 0 && payload.scannedItemTotalCount > 0 && Boolean(topRejection);
const rejectionDetailLines = topRejection
? [`검색 제외 주된 사유: ${topRejection.label} ${topRejection.count}`]
: [];
const log = await createBaseballTicketBayLog({
clientId: row.client_id,
ownerType: normalizeOwnerType(row.owner_type),
ownerId: normalizeOwnerId(row),
alertId: alert.id,
alertTitle: alert.title,
action: 'run',
status: matches.length > 0 || hasRejectedListings ? 'warning' : 'info',
message:
matches.length > 0
? '조건에 맞는 티켓은 있지만 같은 상품 제외 규칙으로 건너뛰었습니다.'
: hasRejectedListings
? `티켓베이 공개 상품 API에서 검색된 판매글은 있었지만 ${topRejection?.label ?? '조건 불일치'} 때문에 제외됐습니다.`
: '티켓베이 공개 상품 API 기준 조건에 맞는 티켓을 찾지 못했습니다.',
detail:
matches.length > 0
? matches.slice(0, 5).map((item) => buildSummaryLine(item as Record<string, unknown>)).join('\n')
: rejectionDetailLines.join('\n'),
payload,
});
await updateAlertRunTimestamp(alert.id, { lastRunAt: now.toISOString(), lastMatchAt: alert.lastMatchAt });
return { alert, matches, notifiedMatches, log };
}
const notificationResult = await sendNotifications({
title: alert.title,
body: buildNotificationBody(alert, notifiedMatches as Array<Record<string, unknown>>),
targetClientIds: [row.client_id],
targetAppOrigins: row.app_origin ? [row.app_origin] : undefined,
targetAppDomains: row.app_domain ? [row.app_domain] : undefined,
data: {
category: 'play-app',
type: 'baseball-ticket-bay',
notificationScope: 'automation',
notificationKey: `baseball-ticket-bay:${alert.id}:${now.toISOString()}`,
targetUrl: row.app_origin ? `${row.app_origin}/?topMenu=play&playSection=apps&app=baseball-ticket-bay` : '/?topMenu=play&playSection=apps&app=baseball-ticket-bay',
alertId: alert.id,
alertTitle: alert.title,
eventDate: alert.eventDate,
},
});
const sentCount = notificationResult.web.sentCount + notificationResult.ios.sentCount;
const detail = [
...notifiedMatches.slice(0, 5).map((item) => buildSummaryLine(item as Record<string, unknown>)),
`알림 전송: web ${notificationResult.web.sentCount}건 · ios ${notificationResult.ios.sentCount}`,
].join('\n');
const log = await createBaseballTicketBayLog({
clientId: row.client_id,
ownerType: normalizeOwnerType(row.owner_type),
ownerId: normalizeOwnerId(row),
alertId: alert.id,
alertTitle: alert.title,
action: 'run',
status: 'success',
message: `${notifiedMatches.length}개 티켓을 찾았습니다.${sentCount > 0 ? ` 알림 ${sentCount}건 전송.` : ''}`,
detail,
payload,
});
if (!alert.sameProductAlertEnabled || alert.sameProductNotifyOnce) {
await markSeenProductIds(
alert.id,
notifiedMatches.map((item) => pickProductId(item as Record<string, unknown>)),
);
}
await updateAlertRunTimestamp(alert.id, { lastRunAt: now.toISOString(), lastMatchAt: now.toISOString() });
return { alert, matches, notifiedMatches, log };
}
function isAlertDue(alert: BaseballTicketBayAlertItem, now: Date) {
if (!alert.active) {
return false;
}
if (isWithinBlockedTime(alert.timeWindows, now)) {
return false;
}
if (!alert.lastRunAt) {
return true;
}
const lastRunAt = Date.parse(alert.lastRunAt);
if (Number.isNaN(lastRunAt)) {
return true;
}
return now.getTime() - lastRunAt >= alert.batchIntervalMinutes * 60 * 1000;
}
function readBooleanLikeValue(value: unknown) {
return value === true || value === 't' || value === 'true' || value === 1 || value === '1';
}
async function tryAcquireBaseballTicketBayBatchLock() {
const result = (await db.raw('select pg_try_advisory_lock(?) as locked', [
BASEBALL_TICKET_BAY_BATCH_ADVISORY_LOCK_KEY,
])) as { rows?: Array<{ locked?: unknown }> };
return readBooleanLikeValue(result.rows?.[0]?.locked);
}
async function releaseBaseballTicketBayBatchLock() {
await db.raw('select pg_advisory_unlock(?)', [BASEBALL_TICKET_BAY_BATCH_ADVISORY_LOCK_KEY]);
}
export async function processDueBaseballTicketBayAlerts(now = new Date()) {
if (!(await tryAcquireBaseballTicketBayBatchLock())) {
return [];
}
try {
await ensureBaseballTicketBayTables();
const rows = await db(BASEBALL_TICKET_BAY_ALERT_TABLE).select('*').where({ active: true });
const results: Array<{ alertId: string; ok: boolean; message?: string }> = [];
for (const row of rows as BaseballTicketBayAlertRow[]) {
const alert = mapAlertRow(row);
if (!isAlertDue(alert, now)) {
continue;
}
try {
await runBaseballTicketBayAlert(alert.id);
results.push({ alertId: alert.id, ok: true });
} catch (error) {
const handledError = error instanceof Error ? error : new Error(String(error));
await createBaseballTicketBayLog({
clientId: row.client_id,
ownerType: normalizeOwnerType(row.owner_type),
ownerId: normalizeOwnerId(row),
alertId: alert.id,
alertTitle: alert.title,
action: 'run',
status: 'error',
message: handledError.message || '배치 실행에 실패했습니다.',
detail: '',
});
await updateAlertRunTimestamp(alert.id, { lastRunAt: now.toISOString(), lastMatchAt: alert.lastMatchAt });
results.push({ alertId: alert.id, ok: false, message: handledError.message });
}
}
return results;
} finally {
await releaseBaseballTicketBayBatchLock();
}
}