import { NextResponse } from "next/server"; import { hasKisConfig } from "@/lib/kis/config"; import { readKisAccountParts, readKisCredentialsFromHeaders, } from "@/app/api/kis/domestic/_shared"; import type { AutotradeSessionInfo, AutotradeStopReason, } from "@/features/autotrade/types/autotrade.types"; import { createClient } from "@/utils/supabase/server"; export const AUTOTRADE_DEV_BYPASS_HEADER = "x-autotrade-dev-bypass"; export const AUTOTRADE_WORKER_TOKEN_HEADER = "x-autotrade-worker-token"; export const AUTOTRADE_API_ERROR_CODE = { AUTH_REQUIRED: "AUTOTRADE_AUTH_REQUIRED", INVALID_REQUEST: "AUTOTRADE_INVALID_REQUEST", CREDENTIAL_REQUIRED: "AUTOTRADE_CREDENTIAL_REQUIRED", SESSION_NOT_FOUND: "AUTOTRADE_SESSION_NOT_FOUND", CONFLICT: "AUTOTRADE_CONFLICT", INTERNAL: "AUTOTRADE_INTERNAL", } as const; export type AutotradeApiErrorCode = (typeof AUTOTRADE_API_ERROR_CODE)[keyof typeof AUTOTRADE_API_ERROR_CODE]; export interface AutotradeSessionRecord extends AutotradeSessionInfo { userId: string; strategySummary: string; } declare global { var __autotradeSessionMap: Map | undefined; } function getSessionMap() { if (!globalThis.__autotradeSessionMap) { globalThis.__autotradeSessionMap = new Map(); } return globalThis.__autotradeSessionMap; } export function createAutotradeErrorResponse(options: { status: number; code: AutotradeApiErrorCode; message: string; extra?: Record; }) { return NextResponse.json( { ok: false, errorCode: options.code, message: options.message, ...(options.extra ?? {}), }, { status: options.status }, ); } export async function getAutotradeUserId(headers?: Headers) { if (isAutotradeDevBypass(headers)) { return "dev-autotrade-user"; } const supabase = await createClient(); const { data: { user }, error, } = await supabase.auth.getUser(); if (error || !user) return null; return user.id; } export async function readJsonBody(request: Request) { const text = await request.text(); if (!text.trim()) return null; try { return JSON.parse(text) as unknown; } catch { return null; } } export function hasAutotradeKisRuntimeHeaders(headers: Headers) { if (isAutotradeDevBypass(headers)) { return true; } const credentials = readKisCredentialsFromHeaders(headers); const account = readKisAccountParts(headers); return Boolean(hasKisConfig(credentials) && account); } export function upsertAutotradeSession(record: AutotradeSessionRecord) { const map = getSessionMap(); map.set(record.userId, record); return record; } export function getAutotradeSession(userId: string) { const map = getSessionMap(); const record = map.get(userId) ?? null; if (!record) return null; if (record.runtimeState === "RUNNING" && isHeartbeatExpired(record.lastHeartbeatAt)) { const stoppedRecord = { ...record, runtimeState: "STOPPED" as const, stopReason: "heartbeat_timeout" as const, endedAt: new Date().toISOString(), }; map.set(userId, stoppedRecord); return stoppedRecord; } return record; } export function listAutotradeSessions() { return Array.from(getSessionMap().values()).sort((a, b) => b.startedAt.localeCompare(a.startedAt), ); } export function stopAutotradeSession(userId: string, reason: AutotradeStopReason) { const map = getSessionMap(); const record = map.get(userId); if (!record) return null; const stoppedRecord: AutotradeSessionRecord = { ...record, runtimeState: "STOPPED", stopReason: reason, endedAt: new Date().toISOString(), lastHeartbeatAt: new Date().toISOString(), }; map.set(userId, stoppedRecord); return stoppedRecord; } export function sweepExpiredAutotradeSessions() { const map = getSessionMap(); let expiredCount = 0; for (const [userId, record] of map.entries()) { if (record.runtimeState !== "RUNNING") continue; if (!isHeartbeatExpired(record.lastHeartbeatAt)) continue; const stoppedRecord: AutotradeSessionRecord = { ...record, runtimeState: "STOPPED", stopReason: "heartbeat_timeout", endedAt: new Date().toISOString(), lastHeartbeatAt: new Date().toISOString(), }; map.set(userId, stoppedRecord); expiredCount += 1; } return { totalSessionCount: map.size, expiredCount, }; } export function getAutotradeHeartbeatTtlSec() { const parsed = Number.parseInt(process.env.AUTOTRADE_HEARTBEAT_TTL_SEC ?? "90", 10); if (!Number.isFinite(parsed)) return 90; return Math.min(300, Math.max(30, parsed)); } export function isHeartbeatExpired(lastHeartbeatAt: string) { const lastHeartbeatMs = new Date(lastHeartbeatAt).getTime(); if (!Number.isFinite(lastHeartbeatMs)) return true; return Date.now() - lastHeartbeatMs > getAutotradeHeartbeatTtlSec() * 1000; } export function sanitizeAutotradeError(error: unknown, fallback: string) { const message = error instanceof Error ? error.message : fallback; return maskSensitiveTokens(message) || fallback; } export function maskSensitiveTokens(value: string) { return value .replace(/([A-Za-z0-9]{4})[A-Za-z0-9]{8,}([A-Za-z0-9]{4})/g, "$1********$2") .replace(/(x-kis-app-secret\s*[:=]\s*)([^\s]+)/gi, "$1********") .replace(/(x-kis-app-key\s*[:=]\s*)([^\s]+)/gi, "$1********"); } export function isAutotradeWorkerAuthorized(headers: Headers) { const providedToken = headers.get(AUTOTRADE_WORKER_TOKEN_HEADER)?.trim(); if (!providedToken) return false; const expectedToken = process.env.AUTOTRADE_WORKER_TOKEN?.trim(); if (expectedToken) { return providedToken === expectedToken; } // 운영 환경에서는 토큰 미설정 상태를 허용하지 않습니다. if (process.env.NODE_ENV === "production") { return false; } return providedToken === "autotrade-worker-local"; } function isAutotradeDevBypass(headers?: Headers) { if (!headers || process.env.NODE_ENV === "production") { return false; } const providedToken = headers.get(AUTOTRADE_DEV_BYPASS_HEADER)?.trim(); if (!providedToken) return false; const expectedToken = process.env.AUTOTRADE_DEV_BYPASS_TOKEN?.trim() || "autotrade-dev-bypass"; return providedToken === expectedToken; }