1654 lines
54 KiB
TypeScript
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();
|
|
}
|
|
}
|