chore: test deploy snapshot

This commit is contained in:
2026-05-27 10:43:01 +09:00
parent c1d0f4c1db
commit 4c4b3c8d2c
78 changed files with 10392 additions and 2301 deletions

View File

@@ -1,3 +1,4 @@
import type { Knex } from 'knex';
import { z } from 'zod';
import { db } from '../db/client.js';
import { sendNotifications } from './notification-service.js';
@@ -8,6 +9,7 @@ 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',
@@ -690,6 +692,9 @@ export type BaseballTicketBayTimeWindow = {
export type BaseballTicketBayAlertItem = {
id: string;
clientId: string;
ownerType: BaseballTicketBayOwnerType;
ownerId: string;
title: string;
eventDate: string;
team: string;
@@ -714,6 +719,9 @@ export type BaseballTicketBayAlertLogStatus = 'info' | 'success' | 'warning' | '
export type BaseballTicketBayAlertLogItem = {
id: string;
clientId: string;
ownerType: BaseballTicketBayOwnerType;
ownerId: string;
alertId: string | null;
alertTitle: string;
action: BaseballTicketBayAlertLogAction;
@@ -759,9 +767,21 @@ export type BaseballTicketBayAlertMutation = {
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;
@@ -786,6 +806,8 @@ type BaseballTicketBayAlertRow = {
type BaseballTicketBayLogRow = {
id: string;
client_id: string;
owner_type: BaseballTicketBayOwnerType;
owner_id: string;
alert_id: string | null;
alert_title: string;
action: BaseballTicketBayAlertLogAction;
@@ -810,6 +832,25 @@ 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;
@@ -858,6 +899,9 @@ function parseTimeWindows(value: string) {
function mapAlertRow(row: BaseballTicketBayAlertRow): BaseballTicketBayAlertItem {
return {
id: normalizeText(row.id),
clientId: normalizeText(row.client_id),
ownerType: normalizeOwnerType(row.owner_type),
ownerId: normalizeOwnerId(row),
title: normalizeText(row.title),
eventDate: normalizeText(row.event_date),
team: normalizeText(row.team) || '전체',
@@ -891,6 +935,9 @@ function mapLogRow(row: BaseballTicketBayLogRow): BaseballTicketBayAlertLogItem
return {
id: normalizeText(row.id),
clientId: normalizeText(row.client_id),
ownerType: normalizeOwnerType(row.owner_type),
ownerId: normalizeOwnerId(row),
alertId: row.alert_id ? normalizeText(row.alert_id) : null,
alertTitle: normalizeText(row.alert_title),
action: row.action,
@@ -978,6 +1025,8 @@ export async function ensureBaseballTicketBayTables() {
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();
@@ -1006,6 +1055,8 @@ export async function ensureBaseballTicketBayTables() {
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();
@@ -1025,6 +1076,60 @@ export async function ensureBaseballTicketBayTables() {
});
}
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) {
@@ -1046,33 +1151,39 @@ export async function ensureBaseballTicketBayTables() {
return baseballTicketBayTableSetupPromise;
}
export async function listBaseballTicketBayAlerts(clientId: string) {
export async function listBaseballTicketBayAlerts(scope: BaseballTicketBayOwnerScope) {
await ensureBaseballTicketBayTables();
const rows = await db(BASEBALL_TICKET_BAY_ALERT_TABLE)
.select('*')
.where({ client_id: clientId })
.orderBy([{ column: 'event_date', order: 'asc' }, { column: 'created_at', order: 'desc' }]);
return rows.map((row) => mapAlertRow(row as BaseballTicketBayAlertRow));
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 rows.map((row: BaseballTicketBayAlertRow) => mapAlertRow(row));
}
export async function listBaseballTicketBayLogs(clientId: string, alertId?: string) {
export async function listBaseballTicketBayLogs(
scope: BaseballTicketBayOwnerScope,
alertId?: string,
) {
await ensureBaseballTicketBayTables();
let query = db(BASEBALL_TICKET_BAY_LOG_TABLE)
.select('*')
.where({ client_id: clientId })
.orderBy('created_at', 'desc')
.limit(200);
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;
return rows.map((row) => mapLogRow(row as BaseballTicketBayLogRow));
const rows = (await query) as BaseballTicketBayLogRow[];
return rows.map((row: BaseballTicketBayLogRow) => mapLogRow(row));
}
export async function createBaseballTicketBayLog(args: {
clientId: string;
ownerType: BaseballTicketBayOwnerType;
ownerId: string;
alertId?: string | null;
alertTitle: string;
action: BaseballTicketBayAlertLogAction;
@@ -1085,6 +1196,8 @@ export async function createBaseballTicketBayLog(args: {
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,
@@ -1098,40 +1211,43 @@ export async function createBaseballTicketBayLog(args: {
return mapLogRow(row);
}
export async function deleteBaseballTicketBayLog(logId: string, clientId: string) {
export async function deleteBaseballTicketBayLog(logId: string, scope: BaseballTicketBayOwnerScope) {
await ensureBaseballTicketBayTables();
const existing = await db(BASEBALL_TICKET_BAY_LOG_TABLE)
.select('*')
.where({
id: logId,
client_id: clientId,
})
.first();
const existing = await applyOwnerScope(
db(BASEBALL_TICKET_BAY_LOG_TABLE)
.select('*')
.where({
id: logId,
}),
scope,
).first();
if (!existing) {
return null;
}
await db(BASEBALL_TICKET_BAY_LOG_TABLE)
.where({
await applyOwnerScope(
db(BASEBALL_TICKET_BAY_LOG_TABLE).where({
id: logId,
client_id: clientId,
})
.delete();
}),
scope,
).delete();
return mapLogRow(existing as BaseballTicketBayLogRow);
}
export async function createBaseballTicketBayAlert(
payload: BaseballTicketBayAlertMutation,
context: { clientId: string; appOrigin?: string; appDomain?: string },
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(),
@@ -1159,13 +1275,15 @@ export async function createBaseballTicketBayAlert(
export async function updateBaseballTicketBayAlert(
alertId: string,
payload: Partial<BaseballTicketBayAlertMutation>,
context: { clientId: string; appOrigin?: string; appDomain?: string },
context: { clientId: string; ownerType: BaseballTicketBayOwnerType; ownerId: string; appOrigin?: string; appDomain?: string },
) {
await ensureBaseballTicketBayTables();
const current = await db(BASEBALL_TICKET_BAY_ALERT_TABLE)
.select('*')
.where({ id: alertId, client_id: context.clientId })
.first();
const current = await applyOwnerScope(
db(BASEBALL_TICKET_BAY_ALERT_TABLE)
.select('*')
.where({ id: alertId }),
{ kind: 'owner', ownerType: context.ownerType, ownerId: context.ownerId },
).first();
if (!current) {
throw new Error('수정할 알림을 찾지 못했습니다.');
@@ -1191,37 +1309,53 @@ export async function updateBaseballTicketBayAlert(
if (context.appOrigin) patch.app_origin = normalizeText(context.appOrigin);
if (context.appDomain) patch.app_domain = normalizeText(context.appDomain);
await db(BASEBALL_TICKET_BAY_ALERT_TABLE)
.where({ id: alertId, client_id: context.clientId })
.update(patch);
await applyOwnerScope(
db(BASEBALL_TICKET_BAY_ALERT_TABLE).where({ id: alertId }),
{ kind: 'owner', ownerType: context.ownerType, ownerId: context.ownerId },
).update(patch);
const updated = await db(BASEBALL_TICKET_BAY_ALERT_TABLE)
.select('*')
.where({ id: alertId, client_id: context.clientId })
.first();
const updated = await applyOwnerScope(
db(BASEBALL_TICKET_BAY_ALERT_TABLE)
.select('*')
.where({ id: alertId }),
{ kind: 'owner', ownerType: context.ownerType, ownerId: context.ownerId },
).first();
return mapAlertRow(updated as BaseballTicketBayAlertRow);
}
export async function deleteBaseballTicketBayAlert(alertId: string, clientId: string) {
export async function deleteBaseballTicketBayAlert(alertId: string, scope: BaseballTicketBayOwnerScope) {
await ensureBaseballTicketBayTables();
const row = await db(BASEBALL_TICKET_BAY_ALERT_TABLE)
.select('*')
.where({ id: alertId, client_id: clientId })
.first();
const row = await applyOwnerScope(
db(BASEBALL_TICKET_BAY_ALERT_TABLE)
.select('*')
.where({ id: alertId }),
scope,
).first();
if (!row) {
return null;
}
await db(BASEBALL_TICKET_BAY_ALERT_TABLE)
.where({ id: alertId, client_id: clientId })
.delete();
await applyOwnerScope(
db(BASEBALL_TICKET_BAY_ALERT_TABLE).where({ id: alertId }),
scope,
).delete();
return mapAlertRow(row as BaseballTicketBayAlertRow);
}
async function getAlertRow(alertId: string) {
async function getAlertRow(alertId: string, scope?: BaseballTicketBayOwnerScope) {
await ensureBaseballTicketBayTables();
const row = await db(BASEBALL_TICKET_BAY_ALERT_TABLE).select('*').where({ id: alertId }).first();
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;
}
@@ -1268,8 +1402,11 @@ async function updateAlertRunTimestamp(alertId: string, patch: { lastRunAt: stri
});
}
export async function runBaseballTicketBayAlert(alertId: string, options?: { ignoreTimeWindow?: boolean }) {
const row = await getAlertRow(alertId);
export async function runBaseballTicketBayAlert(
alertId: string,
options?: { ignoreTimeWindow?: boolean; scope?: BaseballTicketBayOwnerScope },
) {
const row = await getAlertRow(alertId, options?.scope);
if (!row) {
throw new Error('실행할 알림을 찾지 못했습니다.');
@@ -1281,6 +1418,8 @@ export async function runBaseballTicketBayAlert(alertId: string, options?: { ign
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',
@@ -1328,6 +1467,8 @@ export async function runBaseballTicketBayAlert(alertId: string, options?: { ign
: [];
const log = await createBaseballTicketBayLog({
clientId: row.client_id,
ownerType: normalizeOwnerType(row.owner_type),
ownerId: normalizeOwnerId(row),
alertId: alert.id,
alertTitle: alert.title,
action: 'run',
@@ -1372,6 +1513,8 @@ export async function runBaseballTicketBayAlert(alertId: string, options?: { ign
].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',
@@ -1414,36 +1557,61 @@ function isAlertDue(alert: BaseballTicketBayAlertItem, now: Date) {
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()) {
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,
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 });
}
if (!(await tryAcquireBaseballTicketBayBatchLock())) {
return [];
}
return results;
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();
}
}