전체적인 리팩토링
This commit is contained in:
227
app/api/autotrade/_shared.ts
Normal file
227
app/api/autotrade/_shared.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user