feat(511-A 계산 자리 서버 창구): POST /api/projects/{id}/calc — 프로젝트 권한 · 본문 상한 8 MB · 늘 떠 있는 Node 하나(design_calc_node --serve · 죽음 · 묶음 바뀜 = 다시 띄움 · 1.4 MB 33 ms · 새 Node 110~170 ms) · 시험 길 id 따로(TRIAL_IDS) · 브라우저 길 design_calc_server(잠잠 400 ms · 늦은 답 버림 · 대조 · 기록 __aisloCalcServer) — 요구 320-4
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
/* =============================================================================
|
||||
* design_calc_server.ts
|
||||
* 계산 자리 「서버」 길(PLAN 511-A) — 판(`design_calc_sites.ts`)이 묶음을 server 로 둔 때 조작 중 계산을 서버
|
||||
* 창구(`POST /projects/{id}/calc` · `B06_Section_Router_Calc.py`)가 같은 TS 로 셈. 판이 both 면 지금 그대로.
|
||||
* serverCalc 한 번 묻기(signal 로 끊음 · 왕복 흉내 `delayMs` · 기록)
|
||||
* afterQuiet 잠잠 뒤 — 같은 칸 새 부름이 앞 것을 끊음(앞 것 = undefined · 늦은 답 버림)
|
||||
* calcChecked 동기 자리 — 브라우저 값 바로 · server 면 잠잠 뒤 같은 입력 서버 셈 → 대조 · 다르면 apply
|
||||
* calcFromServer 그리기 자리 — server 면 서버 값만(입력 같은 답 · 없으면 앞 답) · 답 오면 redraw
|
||||
* 기록 = `window.__aisloCalcServer`(calls · local = 브라우저 ms · delayMs) — 시간 표 · 화면 검증용.
|
||||
* 조작 중 왕복 = 지침 5장 「네트워크 왕복 금지」의 시험용 예외(판 기본값 both).
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { calcOnServerNow, calcProjectId } from "./design_calc_sites";
|
||||
import { plain } from "./logic/design_calc_node_trial";
|
||||
|
||||
/** 편집이 멈춘 것으로 볼 시간(ms) — 배분 선반입과 같은 값(`B05_Profile_Api_HaulPlan.ts`). */
|
||||
export const QUIET_MS = 400;
|
||||
const LOG_SIZE = 200;
|
||||
const LOCAL_SIZE = 20;
|
||||
|
||||
export interface CalcServerCall {
|
||||
key: string;
|
||||
id: string;
|
||||
/** 브라우저 셈 ms(같이 셈한 자리만). */
|
||||
browserMs: number | null;
|
||||
/** 보내기 → 답(흉내 지연 포함). */
|
||||
roundMs: number;
|
||||
/** Node 셈 · 파이썬 ↔ Node(창구 답). */
|
||||
nodeMs: number | null;
|
||||
wallMs: number | null;
|
||||
bytes: number;
|
||||
/** 브라우저 값 = 서버 값(대조 자리만 · 아니면 null). */
|
||||
same: boolean | null;
|
||||
applied: boolean;
|
||||
}
|
||||
|
||||
interface CalcServerLog {
|
||||
calls: CalcServerCall[];
|
||||
local: Record<string, number[]>;
|
||||
delayMs: number;
|
||||
}
|
||||
|
||||
const log = (): CalcServerLog =>
|
||||
((globalThis as { __aisloCalcServer?: CalcServerLog }).__aisloCalcServer ??= {
|
||||
calls: [],
|
||||
local: {},
|
||||
delayMs: 0,
|
||||
});
|
||||
|
||||
const now = (): number => performance.now();
|
||||
const r2 = (value: number): number => Math.round(value * 100) / 100;
|
||||
|
||||
function wait(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) return reject(new DOMException("끊김", "AbortError"));
|
||||
const timer = setTimeout(resolve, ms);
|
||||
signal?.addEventListener("abort", () => {
|
||||
clearTimeout(timer);
|
||||
reject(new DOMException("끊김", "AbortError"));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const bodyOf = (id: string, args: unknown[]): string =>
|
||||
JSON.stringify({ run: [id], inputs: { args: { [id]: args } } });
|
||||
|
||||
function record(call: CalcServerCall): void {
|
||||
const calls = log().calls;
|
||||
calls.push(call);
|
||||
if (calls.length > LOG_SIZE) calls.splice(0, calls.length - LOG_SIZE);
|
||||
}
|
||||
|
||||
/** 서버 계산 한 번 — `{id: 값}` · 실패 = throw · `body` = 이미 굳힌 본문(입력이 뒤에 바뀌어도 그때 값). */
|
||||
export async function serverCalc(
|
||||
key: string,
|
||||
run: string[],
|
||||
inputs: Record<string, unknown> | string,
|
||||
signal?: AbortSignal,
|
||||
extra: Partial<CalcServerCall> = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
const body = typeof inputs === "string" ? inputs : JSON.stringify({ run, inputs });
|
||||
const t0 = now();
|
||||
if (log().delayMs > 0) await wait(log().delayMs, signal);
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${calcProjectId()}/calc`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body,
|
||||
signal,
|
||||
});
|
||||
const data = (await response.json()) as {
|
||||
results?: Record<string, unknown>;
|
||||
ms?: number;
|
||||
wall_ms?: number;
|
||||
message?: string;
|
||||
};
|
||||
if (!response.ok || !data.results) throw new Error(data.message ?? `HTTP ${response.status}`);
|
||||
record({
|
||||
key,
|
||||
id: run.join("+"),
|
||||
browserMs: null,
|
||||
roundMs: r2(now() - t0),
|
||||
nodeMs: data.ms ?? null,
|
||||
wallMs: data.wall_ms ?? null,
|
||||
bytes: body.length,
|
||||
same: null,
|
||||
applied: true,
|
||||
...extra,
|
||||
});
|
||||
return data.results;
|
||||
}
|
||||
|
||||
const quiet = new Map<string, AbortController>();
|
||||
|
||||
/** 잠잠 뒤 task — 같은 칸 새 부름 · outer 끊김 = 앞 것 끊음(undefined) · 실패도 undefined(경고 한 줄). */
|
||||
export async function afterQuiet<T>(
|
||||
slot: string,
|
||||
task: (signal: AbortSignal) => Promise<T>,
|
||||
outer?: AbortSignal,
|
||||
): Promise<T | undefined> {
|
||||
quiet.get(slot)?.abort();
|
||||
const controller = new AbortController();
|
||||
quiet.set(slot, controller);
|
||||
outer?.addEventListener("abort", () => controller.abort());
|
||||
try {
|
||||
await wait(QUIET_MS, controller.signal);
|
||||
return await task(controller.signal);
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) console.warn(`[calc-server] ${slot} 실패`, error);
|
||||
return undefined;
|
||||
} finally {
|
||||
if (quiet.get(slot) === controller) quiet.delete(slot);
|
||||
}
|
||||
}
|
||||
|
||||
/** 브라우저 셈 + 시간 기록(묶음 판 상관없이) · server 면 잠잠 뒤 서버 대조 · 다르면 apply(서버 값). */
|
||||
export function calcChecked<A extends unknown[], R>(
|
||||
key: string,
|
||||
id: string,
|
||||
fn: (...args: A) => R,
|
||||
args: A,
|
||||
apply?: (value: R) => void,
|
||||
): R {
|
||||
const t0 = now();
|
||||
const value = fn(...args);
|
||||
const browserMs = r2(now() - t0);
|
||||
const local = (log().local[id] ??= []);
|
||||
local.push(browserMs);
|
||||
if (local.length > LOCAL_SIZE) local.shift();
|
||||
if (!calcOnServerNow(key)) return value;
|
||||
const body = bodyOf(id, args);
|
||||
const mine = JSON.stringify(plain(value));
|
||||
const extra: Partial<CalcServerCall> = { browserMs, same: null, applied: false };
|
||||
void afterQuiet(id, (signal) => serverCalc(key, [id], body, signal, extra)).then((results) => {
|
||||
if (!results) return;
|
||||
extra.same = JSON.stringify(results[id]) === mine;
|
||||
if (!extra.same && apply) {
|
||||
apply(results[id] as R);
|
||||
extra.applied = true;
|
||||
}
|
||||
});
|
||||
return value;
|
||||
}
|
||||
|
||||
const held = new Map<string, { body: string; value: unknown }>();
|
||||
const asked = new Map<string, string>();
|
||||
|
||||
/** 그리기 자리 서버 값 — 같은 입력 답이면 fresh · 아니면 앞 답(없으면 undefined) + 잠잠 뒤 묻고 redraw. */
|
||||
export function calcFromServer<R>(
|
||||
key: string,
|
||||
id: string,
|
||||
args: unknown[],
|
||||
redraw: () => void,
|
||||
): { value: R | undefined; fresh: boolean } {
|
||||
const body = bodyOf(id, args);
|
||||
const hold = held.get(id);
|
||||
if (hold?.body === body) return { value: hold.value as R, fresh: true };
|
||||
if (asked.get(id) !== body) {
|
||||
asked.set(id, body);
|
||||
void afterQuiet(id, (signal) => serverCalc(key, [id], body, signal)).then((results) => {
|
||||
if (asked.get(id) === body) asked.delete(id);
|
||||
if (!results) return;
|
||||
held.set(id, { body, value: results[id] });
|
||||
redraw();
|
||||
});
|
||||
}
|
||||
return { value: hold?.value as R | undefined, fresh: false };
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
* 「계산 자리」 창(`B01_Dashboard_UI_CalcSites.ts`). 열린 페이지는 다음 열기부터 · 못 받으면 전부 both.
|
||||
* ground_sample(G1) server = ground 단계가 격자 대신 서버 표본(`B05_Profile_Api_TerrainGrid.ts`)
|
||||
* corridor_mesh(D3) server = 3D 미리보기 워커 안 띄움 · 서버 모형만(`B05_Profile_UI_Corridor_Worker_Client.ts`)
|
||||
* quantity · carry · fixed_browser server = 조작 중 서버 창구(511-A · `design_calc_server.ts`)
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
@@ -43,11 +44,15 @@ export const saveCalcSites = (sites: Record<string, CalcSiteValue>): Promise<Cal
|
||||
request({ method: "PUT", body: JSON.stringify({ sites }) });
|
||||
|
||||
let opened: Promise<Record<string, CalcSiteValue>> | null = null;
|
||||
let loaded: Record<string, CalcSiteValue> = {};
|
||||
let project = "";
|
||||
|
||||
/** 페이지 열 때 — 새로 받음(앞 페이지 판 버림). */
|
||||
export function openCalcSites(): Promise<Record<string, CalcSiteValue>> {
|
||||
/** 페이지 열 때 — 새로 받음(앞 페이지 판 버림) · 서버 창구가 쓸 프로젝트. */
|
||||
export function openCalcSites(projectId = project): Promise<Record<string, CalcSiteValue>> {
|
||||
project = projectId;
|
||||
loaded = {};
|
||||
opened = fetchCalcSites().then(
|
||||
(board) => board.sites,
|
||||
(board) => (loaded = board.sites),
|
||||
() => ({}),
|
||||
);
|
||||
return opened;
|
||||
@@ -57,3 +62,8 @@ export function openCalcSites(): Promise<Record<string, CalcSiteValue>> {
|
||||
export async function calcOnServer(key: string): Promise<boolean> {
|
||||
return (await (opened ?? openCalcSites()))[key] === "server";
|
||||
}
|
||||
|
||||
/** 동기 자리용 — 받기 전 = both. */
|
||||
export const calcOnServerNow = (key: string): boolean => loaded[key] === "server";
|
||||
|
||||
export const calcProjectId = (): string => project;
|
||||
|
||||
@@ -120,7 +120,7 @@ export function renderDesignPage(root: HTMLElement): MountedPage {
|
||||
root.replaceChildren(page);
|
||||
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? "";
|
||||
pinDraftProject(projectId); // 연 프로젝트 고정(388-3) — 다른 탭이 바꿔도 이 탭 초안 칸 그대로
|
||||
void openCalcSites(); // 계산 자리 판(504-2) — 열 때 한 번 · 열린 동안 바뀐 판은 다음 열기부터
|
||||
void openCalcSites(projectId); // 계산 자리 판(504-2) — 열 때 한 번 · 열린 동안 바뀐 판은 다음 열기부터
|
||||
usePipeIds(projectId || null); // 관 세션 id 장부 = 이 프로젝트(323-5 · 카드 · 알약 · 종단 · 3D 같은 id)
|
||||
// 관 길이 · 흙막이 개수 = 공용 캐시(페이지 노선) 한 벌 — 마지막 연 보기의 사본이 아님(416 · B05 = B06) ·
|
||||
// 노선 초안 동안 = 계산 줄 새 노선 상세(다시 센 횡단 · 428)
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* 브라우저가 쓰는 같은 TS 를 그대로 부름 — 계산을 두 벌로 짜지 않음.
|
||||
*
|
||||
* 실행: node <묶음> <입력.json> <출력.json> (package.json `build:design-calc`)
|
||||
* node <묶음> --serve 늘 떠 있음 — 표준 입력 한 줄 = 요청 JSON · 표준 출력 한 줄 = 출력 JSON(511-A)
|
||||
* 입력 { run: [id…], inputs: { detail?: 종횡단 상세(API 꼴) · route_points?: 노선 폴리라인 ·
|
||||
* context?: { earthwork_conversion · natural_spoil_min_ground_slope ·
|
||||
* haul_equipment_limits · 채집석 · 구조물 잔토(B08) } ·
|
||||
@@ -17,7 +18,8 @@
|
||||
* 출력 { results: { [id]: 값 }, available: [id…] }
|
||||
* — 차례는 요청 차례가 아니라 기댐 차례(누가토량 앞에 구조물 면적을 상세에 얹음 ·
|
||||
* server-calc 묶음과 같은 차례).
|
||||
* 끝 코드: 0 성공 / 2 인자 오류 / 4 모르는 id
|
||||
* — 시험 길 id(`design_calc_node_trial.ts`)도 돌지만 `available` 에는 없음.
|
||||
* 끝 코드: 0 성공 / 2 인자 오류 / 4 모르는 id(--serve 는 그 줄 { error })
|
||||
* ========================================================================== */
|
||||
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
@@ -40,10 +42,11 @@ import {
|
||||
} from "../../../B06_Section/B06_Section_Structure_Layouts";
|
||||
import { carryRunners, type CarryInputs } from "./design_calc_node_carry";
|
||||
import { followRunners, type FollowInputs } from "./design_calc_node_follow";
|
||||
import { TRIAL_IDS, trialRunners, type TrialInputs } from "./design_calc_node_trial";
|
||||
|
||||
type HaulOptions = NonNullable<Parameters<typeof computeHaulPlan>[2]>;
|
||||
|
||||
interface CalcInputs extends FollowInputs, CarryInputs {
|
||||
interface CalcInputs extends FollowInputs, CarryInputs, TrialInputs {
|
||||
detail?: SectionDetailResponse;
|
||||
route_points?: RoutePoint[];
|
||||
context?: Partial<
|
||||
@@ -141,6 +144,7 @@ function runDesignCalc(run: readonly string[], inputs: CalcInputs) {
|
||||
const RUNNERS: Record<string, () => unknown> = {
|
||||
...followRunners(inputs), // 측점 열쇠 · 따라감 · 꼭짓점 세움(472-4 ①)
|
||||
...carryRunners(inputs), // 노선 바뀐 [저장] 다시 얹기 — 관 · 구조물 초안 · 횡단 고침(472-6 6-B)
|
||||
...trialRunners(inputs), // 계산 자리 「서버」 시험 길(511-A)
|
||||
// 구조물 측점 면적 · 관 길이를 상세에 얹음(자리에서) — 뒤 누가토량이 고친 면적 위에서 쌓임.
|
||||
"structure.layouts": () => {
|
||||
const areas = structureAreaRows(sections);
|
||||
@@ -226,21 +230,48 @@ function runDesignCalc(run: readonly string[], inputs: CalcInputs) {
|
||||
const unknown = run.filter((id) => !(id in RUNNERS));
|
||||
const results: Record<string, unknown> = {};
|
||||
if (!unknown.length) for (const id of run) results[id] = need(id);
|
||||
return { results, unknown, available: Object.keys(RUNNERS) };
|
||||
const available = Object.keys(RUNNERS).filter((id) => !TRIAL_IDS.includes(id));
|
||||
return { results, unknown, available };
|
||||
}
|
||||
|
||||
type CalcRequest = { run?: string[]; inputs?: CalcInputs };
|
||||
|
||||
/** 늘 떠 있음(511-A) — 한 줄 받아 한 줄 냄 · 한 줄 실패는 그 줄 { error }(프로세스는 삶). */
|
||||
function serve(): void {
|
||||
let buffer = "";
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.on("data", (chunk: string) => {
|
||||
buffer += chunk;
|
||||
for (let at = buffer.indexOf("\n"); at >= 0; at = buffer.indexOf("\n")) {
|
||||
const line = buffer.slice(0, at);
|
||||
buffer = buffer.slice(at + 1);
|
||||
let answer: unknown;
|
||||
try {
|
||||
const request = JSON.parse(line) as CalcRequest;
|
||||
const t0 = performance.now();
|
||||
const { results, unknown } = runDesignCalc(request.run ?? [], request.inputs ?? {});
|
||||
const ms = Math.round((performance.now() - t0) * 100) / 100;
|
||||
answer = unknown.length ? { error: `모르는 id: ${unknown.join(", ")}` } : { results, ms };
|
||||
} catch (error) {
|
||||
answer = { error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
process.stdout.write(`${JSON.stringify(answer)}\n`);
|
||||
}
|
||||
});
|
||||
process.stdin.on("end", () => process.exit(0));
|
||||
}
|
||||
|
||||
const [inputPath, outputPath] = process.argv.slice(2);
|
||||
if (!inputPath || !outputPath) {
|
||||
console.error("사용법: node <묶음> <입력.json> <출력.json>");
|
||||
if (inputPath === "--serve") serve();
|
||||
else if (!inputPath || !outputPath) {
|
||||
console.error("사용법: node <묶음> <입력.json> <출력.json> | node <묶음> --serve");
|
||||
process.exit(2);
|
||||
} else {
|
||||
const request = JSON.parse(readFileSync(inputPath, "utf8")) as CalcRequest;
|
||||
const { results, unknown, available } = runDesignCalc(request.run ?? [], request.inputs ?? {});
|
||||
if (unknown.length) {
|
||||
console.error(`모르는 id: ${unknown.join(", ")}`);
|
||||
process.exit(4);
|
||||
}
|
||||
writeFileSync(outputPath, JSON.stringify({ results, available }));
|
||||
}
|
||||
const request = JSON.parse(readFileSync(inputPath, "utf8")) as {
|
||||
run?: string[];
|
||||
inputs?: CalcInputs;
|
||||
};
|
||||
const { results, unknown, available } = runDesignCalc(request.run ?? [], request.inputs ?? {});
|
||||
if (unknown.length) {
|
||||
console.error(`모르는 id: ${unknown.join(", ")}`);
|
||||
process.exit(4);
|
||||
}
|
||||
writeFileSync(outputPath, JSON.stringify({ results, available }));
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/logic/design_calc_node_trial.ts
|
||||
* 계산 자리 「서버」 시험 길(PLAN 511-A) — 조작 중 브라우저가 부르는 함수를 서버 Node 가 **같은 TS 로** 부름.
|
||||
* 등록부 `node:` 실행기 밖(파이썬 `TRIAL_IDS` 와 같은 표) · 입력 = `inputs.args[id]`(그 함수 인자 그대로 · JSON).
|
||||
* masshaul.series E1 유토곡선 그리기(`computeMassHaulSeries`)
|
||||
* stations.carry_keyed R9 측점 열쇠 넘김(`carryKeyedStates` · 읽기 = 칸 이름 → 값 표)
|
||||
* route.handle R2 · route.station_fit R4 · stations.swap R8 · profile.tools P7 · cross.fit C7 · footprint.plan D4
|
||||
* Map · Set 은 목록으로(JSON) — 브라우저 대조도 같은 꼴(`design_calc_server.ts` `plain`).
|
||||
* ========================================================================== */
|
||||
|
||||
import { computeMassHaulSeries } from "@util/common_util_mass_haul";
|
||||
import { footprintStage } from "../../../B05_Profile/logic/B05_Profile_Footprint_Stage";
|
||||
import { fitCurveStations } from "../../../B05_Profile/logic/B05_Profile_RouteEdit_StationFit";
|
||||
import { straightenBetween } from "../../../B05_Profile/logic/B05_Profile_UI_Profile_Straighten";
|
||||
import { dragHandleTo } from "../../../B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve";
|
||||
import {
|
||||
carryKeyedStates,
|
||||
type KeyedState,
|
||||
type Raw,
|
||||
} from "../../../B06_Section/logic/B06_Section_Route_Carry";
|
||||
import { swapCrossSections } from "../../../B06_Section/logic/B06_Section_Station_Swap";
|
||||
import { toeOffsets } from "../../../B06_Section/logic/B06_Section_UI_Cross_Fit";
|
||||
import type { StageContext } from "./design_pipeline_types";
|
||||
|
||||
export interface TrialInputs {
|
||||
args?: Record<string, unknown[]>;
|
||||
}
|
||||
|
||||
type Call = (...args: never[]) => unknown;
|
||||
|
||||
/** Map · Set → 목록 — JSON 으로 오가는 꼴. */
|
||||
export function plain(value: unknown): unknown {
|
||||
return JSON.parse(
|
||||
JSON.stringify(value, (_key, item: unknown) =>
|
||||
item instanceof Map ? [...item.entries()] : item instanceof Set ? [...item] : item,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const TRIAL: Record<string, Call> = {
|
||||
"masshaul.series": computeMassHaulSeries as Call,
|
||||
"stations.carry_keyed": ((states: Record<string, Raw>, from, to) =>
|
||||
carryKeyedStates((name: KeyedState) => states[name] ?? {}, from, to)) as Call,
|
||||
"route.handle": dragHandleTo as Call,
|
||||
"route.station_fit": fitCurveStations as Call,
|
||||
"stations.swap": swapCrossSections as Call,
|
||||
"profile.tools": straightenBetween as Call,
|
||||
"cross.fit": toeOffsets as Call,
|
||||
// 발자국 단계 — 앞 결과 없이 전 구간(브라우저 범위 밖 재사용과 같은 값)
|
||||
"footprint.plan": ((out: StageContext<"footprint">["out"]) =>
|
||||
footprintStage({ out, range: null, prev: null } as StageContext<"footprint">)) as Call,
|
||||
};
|
||||
|
||||
export const TRIAL_IDS = Object.keys(TRIAL);
|
||||
|
||||
/** id → 셈(입구 `RUNNERS` 에 얹음 · `available` 에는 안 넣음). */
|
||||
export function trialRunners(inputs: TrialInputs): Record<string, () => unknown> {
|
||||
return Object.fromEntries(
|
||||
TRIAL_IDS.map((id) => [id, () => plain(TRIAL[id](...((inputs.args?.[id] ?? []) as never[])))]),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"""계산 자리 「서버」 창구(PLAN 511-A · 요구 320-4) — 조작 중 계산을 서버가 같은 TS 로.
|
||||
|
||||
POST /api/projects/{id}/calc {run: [id…], inputs: {…}} → {results, ms, wall_ms}
|
||||
|
||||
브라우저가 계산 자리 판(`calc_sites.json`)에서 묶음을 「서버」로 둔 때만 부름
|
||||
(`design_calc_server.ts`).
|
||||
권한 = 프로젝트 접근(`protected_with_company`) · 호출량 = `main.py` 미들웨어 · 본문 상한 `MAX_BODY`.
|
||||
실행 = 늘 떠 있는 Node 하나(`common_util_calc_runner.run_calc_served`) ·
|
||||
정본 저장 길은 이 창구를 안 씀.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from common_util.common_util_calc_runner import run_calc_served
|
||||
|
||||
router = APIRouter(prefix="/api/projects", tags=["B06 Calc Server"])
|
||||
|
||||
#: 본문 상한 — 종횡단 상세 한 벌(1.3~1.5 MB) 여유 · 넘으면 413.
|
||||
MAX_BODY = 8 * 1024 * 1024
|
||||
|
||||
|
||||
def _error(status: int, message: str) -> JSONResponse:
|
||||
return JSONResponse(status_code=status, content={"status": "error", "message": message})
|
||||
|
||||
|
||||
@router.post("/{project_id}/calc", response_model=None)
|
||||
async def run_calc_endpoint(project_id: UUID, request: Request) -> dict | JSONResponse:
|
||||
declared = request.headers.get("content-length")
|
||||
if declared and declared.isdigit() and int(declared) > MAX_BODY:
|
||||
return _error(413, "계산 입력이 너무 큼")
|
||||
body = b""
|
||||
async for chunk in request.stream():
|
||||
body += chunk
|
||||
if len(body) > MAX_BODY:
|
||||
return _error(413, "계산 입력이 너무 큼")
|
||||
try:
|
||||
payload = json.loads(body)
|
||||
except ValueError:
|
||||
return _error(400, "JSON 아님")
|
||||
run = payload.get("run") if isinstance(payload, dict) else None
|
||||
inputs = payload.get("inputs", {}) if isinstance(payload, dict) else None
|
||||
if not isinstance(run, list) or not all(isinstance(i, str) for i in run):
|
||||
return _error(400, "run = id 목록")
|
||||
if not isinstance(inputs, dict):
|
||||
return _error(400, "inputs = 객체")
|
||||
try:
|
||||
answer = await asyncio.to_thread(run_calc_served, run, inputs)
|
||||
except ValueError as exc:
|
||||
return _error(400, str(exc))
|
||||
if answer is None:
|
||||
return _error(503, "서버 계산을 못 돌림")
|
||||
if "error" in answer:
|
||||
return _error(422, str(answer["error"]))
|
||||
return {"status": "success", "project_id": str(project_id), **answer}
|
||||
@@ -4,15 +4,28 @@
|
||||
(`build:design-calc`). 부름은 `common_util_node_bundle.run_bundle_json` 한 길 · 계산 없음.
|
||||
`NODE_IDS` = 등록부에서 실행기가 `node:` 인 줄(같은지 시험 `test_472_3_calc_runner.py`).
|
||||
입력 꼴 = 입구 머리(`detail` · `route_points` · `context` · `excavation`).
|
||||
`run_calc_served` = 계산 자리 「서버」 창구(511-A · `B06_Section_Router_Calc`) —
|
||||
늘 떠 있는 Node 하나(`--serve` · 한 줄 요청 · 한 줄 답) · 죽음 · 묶음 다시 지음 = 다시 띄움 ·
|
||||
시험 길 `TRIAL_IDS` 도 받음.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import json
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from common_util.common_util_node_bundle import run_bundle_json
|
||||
from common_util.common_util_node_bundle import (
|
||||
build_bundle,
|
||||
bundle_stale,
|
||||
node_env,
|
||||
run_bundle_json,
|
||||
)
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
BUNDLE = ROOT / "config" / "design_calc_node" / "design_calc_node.js"
|
||||
@@ -33,6 +46,20 @@ NODE_IDS = frozenset(
|
||||
"stations.carry",
|
||||
}
|
||||
)
|
||||
# 시험 길(`design_calc_node_trial.ts` `TRIAL` 표와 같음) — 조작 중 브라우저 함수를 서버가 같은 TS 로
|
||||
TRIAL_IDS = frozenset(
|
||||
{
|
||||
"masshaul.series",
|
||||
"stations.carry_keyed",
|
||||
"route.handle",
|
||||
"route.station_fit",
|
||||
"stations.swap",
|
||||
"profile.tools",
|
||||
"cross.fit",
|
||||
"footprint.plan",
|
||||
}
|
||||
)
|
||||
SERVE_TIMEOUT_S = 60
|
||||
|
||||
|
||||
def run_calc(ids: Sequence[str], inputs: dict[str, Any]) -> dict[str, Any] | None:
|
||||
@@ -42,3 +69,71 @@ def run_calc(ids: Sequence[str], inputs: dict[str, Any]) -> dict[str, Any] | Non
|
||||
raise ValueError(f"Node 실행기 아닌 id: {', '.join(unknown)}")
|
||||
output = run_bundle_json(BUNDLE, NPM_SCRIPT, {"run": list(ids), "inputs": inputs})
|
||||
return output.get("results") if isinstance(output, dict) else None
|
||||
|
||||
|
||||
class _Served:
|
||||
"""늘 떠 있는 Node 하나 — 한 번에 한 요청(잠금) · 답 없음 · 죽음 = 끄고 다음 부름에 다시."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.lock = threading.Lock()
|
||||
self.proc: subprocess.Popen[str] | None = None
|
||||
self.stamp = 0
|
||||
|
||||
def _start(self) -> subprocess.Popen[str]:
|
||||
self.stop()
|
||||
self.proc = subprocess.Popen( # noqa: S603 — 고정 실행 파일 · 입력은 표준 입력 한 줄
|
||||
["node", str(BUNDLE), "--serve"],
|
||||
cwd=str(ROOT),
|
||||
env=node_env(),
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
)
|
||||
self.stamp = BUNDLE.stat().st_mtime_ns
|
||||
return self.proc
|
||||
|
||||
def stop(self) -> None:
|
||||
if self.proc and self.proc.poll() is None:
|
||||
self.proc.kill()
|
||||
self.proc = None
|
||||
|
||||
def call(self, payload: dict[str, Any]) -> dict[str, Any] | None:
|
||||
line = json.dumps(payload, default=float) + "\n"
|
||||
with self.lock:
|
||||
if bundle_stale(BUNDLE) and not build_bundle(NPM_SCRIPT):
|
||||
return None
|
||||
for _ in range(2): # 죽은 프로세스에 쓴 첫 부름 = 다시 띄워 한 번 더
|
||||
proc = self.proc
|
||||
if not proc or proc.poll() is not None or BUNDLE.stat().st_mtime_ns != self.stamp:
|
||||
proc = self._start()
|
||||
timer = threading.Timer(SERVE_TIMEOUT_S, proc.kill)
|
||||
timer.start()
|
||||
try:
|
||||
proc.stdin.write(line) # type: ignore[union-attr]
|
||||
proc.stdin.flush() # type: ignore[union-attr]
|
||||
answer = proc.stdout.readline() # type: ignore[union-attr]
|
||||
except OSError:
|
||||
answer = ""
|
||||
finally:
|
||||
timer.cancel()
|
||||
if answer:
|
||||
return json.loads(answer)
|
||||
self.stop()
|
||||
return None
|
||||
|
||||
|
||||
_SERVED = _Served()
|
||||
atexit.register(_SERVED.stop)
|
||||
|
||||
|
||||
def run_calc_served(ids: Sequence[str], inputs: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""창구 길 — `{results, ms(Node 셈), wall_ms(파이썬 ↔ Node)}` · `{error}` · 못 돌림 None."""
|
||||
unknown = [item for item in ids if item not in NODE_IDS | TRIAL_IDS]
|
||||
if unknown:
|
||||
raise ValueError(f"서버 계산 id 아님: {', '.join(unknown)}")
|
||||
t0 = time.perf_counter()
|
||||
answer = _SERVED.call({"run": list(ids), "inputs": inputs})
|
||||
if answer is not None:
|
||||
answer["wall_ms"] = round((time.perf_counter() - t0) * 1000, 2)
|
||||
return answer
|
||||
|
||||
@@ -59,6 +59,7 @@ from B06_Section.B06_Section_Router_HaulPlan import (
|
||||
router as b06_section_haul_plan_router,
|
||||
)
|
||||
from B06_Section.B06_Section_Router_CrossTable import router as b06_section_cross_table_router
|
||||
from B06_Section.B06_Section_Router_Calc import router as b06_calc_router
|
||||
from B07_DesignDetail.B07_DesignDetail_Router import router as b07_design_router
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Frame import router as b07_frame_router
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_PlanKinds import router as b07_plan_kinds_router
|
||||
@@ -477,6 +478,7 @@ app.include_router(b06_design_save_router, dependencies=protected_with_company)
|
||||
app.include_router(b06_section_confirm_router, dependencies=protected_with_company)
|
||||
app.include_router(b06_section_haul_plan_router, dependencies=protected_with_company)
|
||||
app.include_router(b06_section_cross_table_router, dependencies=protected_with_company)
|
||||
app.include_router(b06_calc_router, dependencies=protected_with_company)
|
||||
app.include_router(b07_design_router, dependencies=protected_with_company)
|
||||
app.include_router(b07_frame_router, dependencies=protected_with_company)
|
||||
app.include_router(b07_plan_kinds_router, dependencies=protected_with_company)
|
||||
|
||||
@@ -237,7 +237,7 @@ def test_연결_자리_cross_격자_그대로():
|
||||
assert body.index('if (await calcOnServer("corridor_mesh")) return null;') < body.index(
|
||||
"getWorker()"
|
||||
)
|
||||
assert "void openCalcSites();" in _text("A00_Common/design/design_page.ts")
|
||||
assert "void openCalcSites(projectId);" in _text("A00_Common/design/design_page.ts")
|
||||
# 저장 길은 판을 안 읽음
|
||||
for rel in ("A00_Common/design/design_save.ts", "A00_Common/design/design_save_api.ts"):
|
||||
assert "calc_sites" not in _text(rel) and "calcOnServer" not in _text(rel)
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"""PLAN 511-A (요구 320-4) — 계산 자리 「서버」 창구 `POST /api/projects/{id}/calc` · 떠 있는 Node.
|
||||
|
||||
못박는 것
|
||||
- 창구 = 프로젝트 권한(다른 회사 403 · 시스템 관리자 통과) · 본문 상한 413 · 모르는 id · 꼴 400.
|
||||
- 늘 떠 있는 Node 답 = 파일 길(`run_calc`) 답 · 죽여도 다음 부름에 다시 띄움.
|
||||
- 시험 길 id(`TRIAL_IDS`) = TS `design_calc_node_trial.ts` 표 · `available` 에 안 섞임.
|
||||
- 공유 DB 안 씀(가짜 풀) · 실제 마스터 안 씀(고정 자료 golden_472).
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_511_a_calc_server.py -q
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import Depends, FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B06_Section import B06_Section_Router_Calc as calc_router # noqa: E402
|
||||
from common_util import common_util_auth as auth # noqa: E402
|
||||
from common_util import common_util_calc_runner as runner # noqa: E402
|
||||
|
||||
FIXTURE = ROOT / "resources" / "tester" / "fixtures" / "golden_472"
|
||||
TRIAL_TS = ROOT / "A00_Common" / "design" / "logic" / "design_calc_node_trial.ts"
|
||||
PROJECT = "11111111-2222-3333-4444-555555555555"
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not (ROOT / "config" / "node_modules" / "vite").is_dir(), reason="프론트엔드 의존성 미설치"
|
||||
)
|
||||
|
||||
|
||||
def _fixture(name: str):
|
||||
return json.loads((FIXTURE / name).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _inputs() -> dict:
|
||||
ctx = _fixture("context.json")
|
||||
keys = ("earthwork_conversion", "haul_equipment_limits", "natural_spoil_min_ground_slope")
|
||||
return {"detail": _fixture("detail.json"), "context": {k: ctx[k] for k in keys}}
|
||||
|
||||
|
||||
class _Cursor:
|
||||
async def execute(self, sql, args=None):
|
||||
self.args = args
|
||||
|
||||
async def fetchone(self):
|
||||
return (1,) if self.args == (PROJECT,) else None # 회사 1 프로젝트 하나
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
|
||||
class _Conn(_Cursor):
|
||||
def cursor(self):
|
||||
return _Cursor()
|
||||
|
||||
|
||||
class _Pool:
|
||||
def acquire(self):
|
||||
return _Conn()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch):
|
||||
monkeypatch.setattr(auth, "get_db_pool", lambda: _Pool())
|
||||
app = FastAPI()
|
||||
app.include_router(
|
||||
calc_router.router,
|
||||
dependencies=[Depends(auth.verify_session), Depends(auth.require_project_access)],
|
||||
)
|
||||
session = {"role": "USER", "company_id": 1, "user_id": 7}
|
||||
app.dependency_overrides[auth.verify_session] = lambda: dict(session)
|
||||
return TestClient(app), session
|
||||
|
||||
|
||||
def test_main_프로젝트_권한으로_붙음():
|
||||
main = (ROOT / "main.py").read_text(encoding="utf-8")
|
||||
assert "app.include_router(b06_calc_router, dependencies=protected_with_company)" in main
|
||||
assert "note_api_call(session_id)" in main # 호출량 = 미들웨어(/api 전부)
|
||||
|
||||
|
||||
def test_권한_다른_회사_403_관리자_통과(client):
|
||||
http, session = client
|
||||
body = {"run": ["cross.fit"], "inputs": {"args": {"cross.fit": [{}]}}}
|
||||
ok = http.post(f"/api/projects/{PROJECT}/calc", json=body)
|
||||
assert ok.status_code == 200, ok.text
|
||||
assert ok.json()["results"] == {"cross.fit": {"left": None, "right": None}}
|
||||
session["company_id"] = 2
|
||||
assert http.post(f"/api/projects/{PROJECT}/calc", json=body).status_code == 403
|
||||
missing = "00000000-0000-0000-0000-000000000000"
|
||||
assert http.post(f"/api/projects/{missing}/calc", json=body).status_code == 404
|
||||
session["role"] = "SYSTEM_ADMIN"
|
||||
assert http.post(f"/api/projects/{PROJECT}/calc", json=body).status_code == 200
|
||||
|
||||
|
||||
def test_본문_상한_꼴_모르는_id(client, monkeypatch):
|
||||
http, _ = client
|
||||
url = f"/api/projects/{PROJECT}/calc"
|
||||
monkeypatch.setattr(calc_router, "MAX_BODY", 100)
|
||||
big = {"run": ["cross.fit"], "inputs": {"pad": "x" * 200}}
|
||||
assert http.post(url, json=big).status_code == 413
|
||||
monkeypatch.setattr(calc_router, "MAX_BODY", 8 * 1024 * 1024)
|
||||
assert http.post(url, content=b"{nope").status_code == 400
|
||||
assert http.post(url, json={"run": "cross.fit"}).status_code == 400
|
||||
assert http.post(url, json={"run": ["cross.fit"], "inputs": []}).status_code == 400
|
||||
bad = http.post(url, json={"run": ["stations.list"], "inputs": {}})
|
||||
assert bad.status_code == 400 and "stations.list" in bad.json()["message"]
|
||||
|
||||
|
||||
def test_떠_있는_Node_답_파일_길과_같음_죽어도_다시():
|
||||
ids = ["structure.layouts", "masshaul.cumulative", "haul.plan", "excavation.trench"]
|
||||
want = runner.run_calc(ids, _inputs())
|
||||
got = runner.run_calc_served(ids, _inputs())
|
||||
assert got["results"] == want and want["masshaul.cumulative"]["final_cumulative_m3"]
|
||||
assert got["ms"] >= 0 and got["wall_ms"] >= got["ms"]
|
||||
runner._SERVED.proc.kill()
|
||||
runner._SERVED.proc.wait()
|
||||
again = runner.run_calc_served(["structure.layouts"], _inputs())
|
||||
assert again["results"]["structure.layouts"] == want["structure.layouts"]
|
||||
|
||||
|
||||
def test_시험_길_id_TS_표와_같음_available_밖():
|
||||
src = TRIAL_TS.read_text(encoding="utf-8")
|
||||
table = src.split("const TRIAL: Record<string, Call> = {")[1].split("\n};")[0]
|
||||
assert set(re.findall(r'^ "([a-z_.]+)":', table, re.M)) == runner.TRIAL_IDS
|
||||
out = runner.run_bundle_json(runner.BUNDLE, runner.NPM_SCRIPT, {"run": [], "inputs": {}})
|
||||
assert set(out["available"]) == runner.NODE_IDS
|
||||
assert not runner.NODE_IDS & runner.TRIAL_IDS
|
||||
Reference in New Issue
Block a user