228 lines
6.2 KiB
TypeScript
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;
|
||
|
|
}
|