Files
auto-trade/app/api/autotrade/_shared.ts

228 lines
6.2 KiB
TypeScript

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<string, AutotradeSessionRecord> | undefined;
}
function getSessionMap() {
if (!globalThis.__autotradeSessionMap) {
globalThis.__autotradeSessionMap = new Map<string, AutotradeSessionRecord>();
}
return globalThis.__autotradeSessionMap;
}
export function createAutotradeErrorResponse(options: {
status: number;
code: AutotradeApiErrorCode;
message: string;
extra?: Record<string, unknown>;
}) {
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;
}