feat: implement smart factory log transmission and enhance rack structure patterns

- Added a new utility function `sendSmartFactoryLog` to transmit user access logs to the smart factory logging service upon successful login, ensuring non-blocking behavior.
- Integrated the smart factory log transmission into the `AuthController` to log user access asynchronously.
- Introduced pattern utilities for generating location codes and names based on configurable patterns in the rack structure components, improving flexibility and maintainability.
- Enhanced the `RackStructureConfigPanel` to allow users to define custom patterns for location codes and names, with real-time previews and a list of available variables.

Made-with: Cursor
This commit is contained in:
kjs
2026-03-11 18:34:58 +09:00
parent 000484349b
commit 41d58cbb62
8 changed files with 396 additions and 27 deletions

View File

@@ -6,6 +6,7 @@ import { AuthService } from "../services/authService";
import { JwtUtils } from "../utils/jwtUtils";
import { LoginRequest, UserInfo, ApiResponse, PersonBean } from "../types/auth";
import { logger } from "../utils/logger";
import { sendSmartFactoryLog } from "../utils/smartFactoryLog";
export class AuthController {
/**
@@ -86,13 +87,20 @@ export class AuthController {
logger.warn("메뉴 조회 중 오류 발생 (무시하고 계속):", menuError);
}
// 스마트공장 활용 로그 전송 (비동기, 응답 블로킹 안 함)
sendSmartFactoryLog({
userId: userInfo.userId,
remoteAddr,
useType: "접속",
}).catch(() => {});
res.status(200).json({
success: true,
message: "로그인 성공",
data: {
userInfo,
token: loginResult.token,
firstMenuPath, // 첫 번째 접근 가능한 메뉴 경로 추가
firstMenuPath,
},
});
} else {

View File

@@ -0,0 +1,71 @@
// 스마트공장 활용 로그 전송 유틸리티
// https://log.smart-factory.kr 에 사용자 접속 로그를 전송
import axios from "axios";
import { logger } from "./logger";
const SMART_FACTORY_LOG_URL =
"https://log.smart-factory.kr/apisvc/sendLogDataJSON.do";
/**
* 스마트공장 활용 로그 전송
* 로그인 성공 시 비동기로 호출하여 응답을 블로킹하지 않음
*/
export async function sendSmartFactoryLog(params: {
userId: string;
remoteAddr: string;
useType?: string;
}): Promise<void> {
const apiKey = process.env.SMART_FACTORY_API_KEY;
if (!apiKey) {
logger.warn(
"SMART_FACTORY_API_KEY 환경변수가 설정되지 않아 스마트공장 로그 전송을 건너뜁니다."
);
return;
}
try {
const now = new Date();
const logDt = formatDateTime(now);
const logData = {
crtfcKey: apiKey,
logDt,
useSe: params.useType || "접속",
sysUser: params.userId,
conectIp: params.remoteAddr,
dataUsgqty: "",
};
const encodedLogData = encodeURIComponent(JSON.stringify(logData));
const response = await axios.get(SMART_FACTORY_LOG_URL, {
params: { logData: encodedLogData },
timeout: 5000,
});
logger.info("스마트공장 로그 전송 완료", {
userId: params.userId,
status: response.status,
});
} catch (error) {
// 스마트공장 로그 전송 실패해도 로그인에 영향 없도록 에러만 기록
logger.error("스마트공장 로그 전송 실패", {
userId: params.userId,
error: error instanceof Error ? error.message : error,
});
}
}
/** yyyy-MM-dd HH:mm:ss.SSS 형식 */
function formatDateTime(date: Date): string {
const y = date.getFullYear();
const M = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
const H = String(date.getHours()).padStart(2, "0");
const m = String(date.getMinutes()).padStart(2, "0");
const s = String(date.getSeconds()).padStart(2, "0");
const ms = String(date.getMilliseconds()).padStart(3, "0");
return `${y}-${M}-${d} ${H}:${m}:${s}.${ms}`;
}

View File

@@ -20,6 +20,7 @@ import {
GeneratedLocation,
RackStructureContext,
} from "./types";
import { applyLocationPattern, DEFAULT_CODE_PATTERN, DEFAULT_NAME_PATTERN } from "./patternUtils";
// 기존 위치 데이터 타입
interface ExistingLocation {
@@ -512,23 +513,27 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
return { totalLocations, totalRows, maxLevel };
}, [conditions]);
// 위치 코드 생성
// 위치 코드 생성 (패턴 기반)
const generateLocationCode = useCallback(
(row: number, level: number): { code: string; name: string } => {
const warehouseCode = context?.warehouseCode || "WH001";
const floor = context?.floor || "1";
const zone = context?.zone || "A";
const vars = {
warehouse: context?.warehouseCode || "WH001",
warehouseName: context?.warehouseName || "",
floor: context?.floor || "1",
zone: context?.zone || "A",
row,
level,
};
// 코드 생성 (예: WH001-1층D구역-01-1)
const code = `${warehouseCode}-${floor}${zone}-${row.toString().padStart(2, "0")}-${level}`;
const codePattern = config.codePattern || DEFAULT_CODE_PATTERN;
const namePattern = config.namePattern || DEFAULT_NAME_PATTERN;
// 이름 생성 - zone에 이미 "구역"이 포함되어 있으면 그대로 사용
const zoneName = zone.includes("구역") ? zone : `${zone}구역`;
const name = `${zoneName}-${row.toString().padStart(2, "0")}열-${level}`;
return { code, name };
return {
code: applyLocationPattern(codePattern, vars),
name: applyLocationPattern(namePattern, vars),
};
},
[context],
[context, config.codePattern, config.namePattern],
);
// 미리보기 생성

View File

@@ -1,6 +1,6 @@
"use client";
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useMemo } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
@@ -12,6 +12,47 @@ import {
SelectValue,
} from "@/components/ui/select";
import { RackStructureComponentConfig, FieldMapping } from "./types";
import { applyLocationPattern, DEFAULT_CODE_PATTERN, DEFAULT_NAME_PATTERN, PATTERN_VARIABLES } from "./patternUtils";
// 패턴 미리보기 서브 컴포넌트
const PatternPreview: React.FC<{
codePattern?: string;
namePattern?: string;
}> = ({ codePattern, namePattern }) => {
const sampleVars = {
warehouse: "WH002",
warehouseName: "2창고",
floor: "2층",
zone: "A구역",
row: 1,
level: 3,
};
const previewCode = useMemo(
() => applyLocationPattern(codePattern || DEFAULT_CODE_PATTERN, sampleVars),
[codePattern],
);
const previewName = useMemo(
() => applyLocationPattern(namePattern || DEFAULT_NAME_PATTERN, sampleVars),
[namePattern],
);
return (
<div className="rounded-md border border-primary/20 bg-primary/5 p-2.5">
<div className="mb-1.5 text-[10px] font-medium text-primary"> (2 / 2 / A구역 / 1 / 3)</div>
<div className="space-y-1">
<div className="flex items-center gap-2 text-xs">
<span className="w-14 shrink-0 text-muted-foreground">:</span>
<code className="rounded bg-background px-1.5 py-0.5 font-mono text-foreground">{previewCode}</code>
</div>
<div className="flex items-center gap-2 text-xs">
<span className="w-14 shrink-0 text-muted-foreground">:</span>
<code className="rounded bg-background px-1.5 py-0.5 font-mono text-foreground">{previewName}</code>
</div>
</div>
</div>
);
};
interface RackStructureConfigPanelProps {
config: RackStructureComponentConfig;
@@ -205,6 +246,61 @@ export const RackStructureConfigPanel: React.FC<RackStructureConfigPanelProps> =
</div>
</div>
{/* 위치코드 패턴 설정 */}
<div className="space-y-3 border-t pt-3">
<div className="text-sm font-medium text-foreground">/ </div>
<p className="text-xs text-muted-foreground">
</p>
{/* 위치코드 패턴 */}
<div>
<Label className="text-xs"> </Label>
<Input
value={config.codePattern || ""}
onChange={(e) => handleChange("codePattern", e.target.value || undefined)}
placeholder="{warehouse}-{floor}{zone}-{row:02}-{level}"
className="h-8 font-mono text-xs"
/>
<p className="mt-1 text-[10px] text-muted-foreground">
: {"{warehouse}-{floor}{zone}-{row:02}-{level}"}
</p>
</div>
{/* 위치명 패턴 */}
<div>
<Label className="text-xs"> </Label>
<Input
value={config.namePattern || ""}
onChange={(e) => handleChange("namePattern", e.target.value || undefined)}
placeholder="{zone}-{row:02}열-{level}단"
className="h-8 font-mono text-xs"
/>
<p className="mt-1 text-[10px] text-muted-foreground">
: {"{zone}-{row:02}열-{level}단"}
</p>
</div>
{/* 실시간 미리보기 */}
<PatternPreview
codePattern={config.codePattern}
namePattern={config.namePattern}
/>
{/* 사용 가능한 변수 목록 */}
<div className="rounded-md border bg-muted/50 p-2">
<div className="mb-1 text-[10px] font-medium text-foreground"> </div>
<div className="grid grid-cols-2 gap-x-3 gap-y-0.5">
{PATTERN_VARIABLES.map((v) => (
<div key={v.token} className="flex items-center gap-1 text-[10px]">
<code className="rounded bg-primary/10 px-1 font-mono text-primary">{v.token}</code>
<span className="text-muted-foreground">{v.description}</span>
</div>
))}
</div>
</div>
</div>
{/* 제한 설정 */}
<div className="space-y-3 border-t pt-3">
<div className="text-sm font-medium text-foreground"> </div>

View File

@@ -0,0 +1,7 @@
// rack-structure는 v2-rack-structure의 patternUtils를 재사용
export {
applyLocationPattern,
DEFAULT_CODE_PATTERN,
DEFAULT_NAME_PATTERN,
PATTERN_VARIABLES,
} from "../v2-rack-structure/patternUtils";

View File

@@ -20,6 +20,7 @@ import {
GeneratedLocation,
RackStructureContext,
} from "./types";
import { applyLocationPattern, DEFAULT_CODE_PATTERN, DEFAULT_NAME_PATTERN } from "./patternUtils";
// 기존 위치 데이터 타입
interface ExistingLocation {
@@ -493,23 +494,27 @@ export const RackStructureComponent: React.FC<RackStructureComponentProps> = ({
return { totalLocations, totalRows, maxLevel };
}, [conditions]);
// 위치 코드 생성
// 위치 코드 생성 (패턴 기반)
const generateLocationCode = useCallback(
(row: number, level: number): { code: string; name: string } => {
const warehouseCode = context?.warehouseCode || "WH001";
const floor = context?.floor || "1";
const zone = context?.zone || "A";
const vars = {
warehouse: context?.warehouseCode || "WH001",
warehouseName: context?.warehouseName || "",
floor: context?.floor || "1",
zone: context?.zone || "A",
row,
level,
};
// 코드 생성 (예: WH001-1층D구역-01-1)
const code = `${warehouseCode}-${floor}${zone}-${row.toString().padStart(2, "0")}-${level}`;
const codePattern = config.codePattern || DEFAULT_CODE_PATTERN;
const namePattern = config.namePattern || DEFAULT_NAME_PATTERN;
// 이름 생성 - zone에 이미 "구역"이 포함되어 있으면 그대로 사용
const zoneName = zone.includes("구역") ? zone : `${zone}구역`;
const name = `${zoneName}-${row.toString().padStart(2, "0")}열-${level}`;
return { code, name };
return {
code: applyLocationPattern(codePattern, vars),
name: applyLocationPattern(namePattern, vars),
};
},
[context],
[context, config.codePattern, config.namePattern],
);
// 미리보기 생성

View File

@@ -1,6 +1,6 @@
"use client";
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useMemo } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
@@ -12,6 +12,47 @@ import {
SelectValue,
} from "@/components/ui/select";
import { RackStructureComponentConfig, FieldMapping } from "./types";
import { applyLocationPattern, DEFAULT_CODE_PATTERN, DEFAULT_NAME_PATTERN, PATTERN_VARIABLES } from "./patternUtils";
// 패턴 미리보기 서브 컴포넌트
const PatternPreview: React.FC<{
codePattern?: string;
namePattern?: string;
}> = ({ codePattern, namePattern }) => {
const sampleVars = {
warehouse: "WH002",
warehouseName: "2창고",
floor: "2층",
zone: "A구역",
row: 1,
level: 3,
};
const previewCode = useMemo(
() => applyLocationPattern(codePattern || DEFAULT_CODE_PATTERN, sampleVars),
[codePattern],
);
const previewName = useMemo(
() => applyLocationPattern(namePattern || DEFAULT_NAME_PATTERN, sampleVars),
[namePattern],
);
return (
<div className="rounded-md border border-primary/20 bg-primary/5 p-2.5">
<div className="mb-1.5 text-[10px] font-medium text-primary"> (2 / 2 / A구역 / 1 / 3)</div>
<div className="space-y-1">
<div className="flex items-center gap-2 text-xs">
<span className="w-14 shrink-0 text-muted-foreground">:</span>
<code className="rounded bg-background px-1.5 py-0.5 font-mono text-foreground">{previewCode}</code>
</div>
<div className="flex items-center gap-2 text-xs">
<span className="w-14 shrink-0 text-muted-foreground">:</span>
<code className="rounded bg-background px-1.5 py-0.5 font-mono text-foreground">{previewName}</code>
</div>
</div>
</div>
);
};
interface RackStructureConfigPanelProps {
config: RackStructureComponentConfig;
@@ -205,6 +246,61 @@ export const RackStructureConfigPanel: React.FC<RackStructureConfigPanelProps> =
</div>
</div>
{/* 위치코드 패턴 설정 */}
<div className="space-y-3 border-t pt-3">
<div className="text-sm font-medium text-foreground">/ </div>
<p className="text-xs text-muted-foreground">
</p>
{/* 위치코드 패턴 */}
<div>
<Label className="text-xs"> </Label>
<Input
value={config.codePattern || ""}
onChange={(e) => handleChange("codePattern", e.target.value || undefined)}
placeholder="{warehouse}-{floor}{zone}-{row:02}-{level}"
className="h-8 font-mono text-xs"
/>
<p className="mt-1 text-[10px] text-muted-foreground">
: {"{warehouse}-{floor}{zone}-{row:02}-{level}"}
</p>
</div>
{/* 위치명 패턴 */}
<div>
<Label className="text-xs"> </Label>
<Input
value={config.namePattern || ""}
onChange={(e) => handleChange("namePattern", e.target.value || undefined)}
placeholder="{zone}-{row:02}열-{level}단"
className="h-8 font-mono text-xs"
/>
<p className="mt-1 text-[10px] text-muted-foreground">
: {"{zone}-{row:02}열-{level}단"}
</p>
</div>
{/* 실시간 미리보기 */}
<PatternPreview
codePattern={config.codePattern}
namePattern={config.namePattern}
/>
{/* 사용 가능한 변수 목록 */}
<div className="rounded-md border bg-muted/50 p-2">
<div className="mb-1 text-[10px] font-medium text-foreground"> </div>
<div className="grid grid-cols-2 gap-x-3 gap-y-0.5">
{PATTERN_VARIABLES.map((v) => (
<div key={v.token} className="flex items-center gap-1 text-[10px]">
<code className="rounded bg-primary/10 px-1 font-mono text-primary">{v.token}</code>
<span className="text-muted-foreground">{v.description}</span>
</div>
))}
</div>
</div>
</div>
{/* 제한 설정 */}
<div className="space-y-3 border-t pt-3">
<div className="text-sm font-medium text-foreground"> </div>

View File

@@ -0,0 +1,81 @@
/**
* 위치코드/위치명 패턴 변환 유틸리티
*
* 사용 가능한 변수:
* {warehouse} - 창고 코드 (예: WH002)
* {warehouseName} - 창고명 (예: 2창고)
* {floor} - 층 (예: 2층)
* {zone} - 구역 (예: A구역)
* {row} - 열 번호 (예: 1)
* {row:02} - 열 번호 2자리 (예: 01)
* {row:03} - 열 번호 3자리 (예: 001)
* {level} - 단 번호 (예: 1)
* {level:02} - 단 번호 2자리 (예: 01)
* {level:03} - 단 번호 3자리 (예: 001)
*/
interface PatternVariables {
warehouse?: string;
warehouseName?: string;
floor?: string;
zone?: string;
row: number;
level: number;
}
// 기본 패턴 (하드코딩 대체)
export const DEFAULT_CODE_PATTERN = "{warehouse}-{floor}{zone}-{row:02}-{level}";
export const DEFAULT_NAME_PATTERN = "{zone}-{row:02}열-{level}단";
/**
* 패턴 문자열에서 변수를 치환하여 결과 문자열 반환
*/
export function applyLocationPattern(pattern: string, vars: PatternVariables): string {
let result = pattern;
// zone에 "구역" 포함 여부에 따른 처리 없이 있는 그대로 치환
const simpleVars: Record<string, string | undefined> = {
warehouse: vars.warehouse,
warehouseName: vars.warehouseName,
floor: vars.floor,
zone: vars.zone,
};
// 단순 문자열 변수 치환
for (const [key, value] of Object.entries(simpleVars)) {
result = result.replace(new RegExp(`\\{${key}\\}`, "g"), value || "");
}
// 숫자 변수 (row, level) - zero-pad 지원
const numericVars: Record<string, number> = {
row: vars.row,
level: vars.level,
};
for (const [key, value] of Object.entries(numericVars)) {
// {row:02}, {level:03} 같은 zero-pad 패턴
const padRegex = new RegExp(`\\{${key}:(\\d+)\\}`, "g");
result = result.replace(padRegex, (_, padWidth) => {
return value.toString().padStart(parseInt(padWidth), "0");
});
// {row}, {level} 같은 단순 패턴
result = result.replace(new RegExp(`\\{${key}\\}`, "g"), value.toString());
}
return result;
}
// 패턴에서 사용 가능한 변수 목록
export const PATTERN_VARIABLES = [
{ token: "{warehouse}", description: "창고 코드", example: "WH002" },
{ token: "{warehouseName}", description: "창고명", example: "2창고" },
{ token: "{floor}", description: "층", example: "2층" },
{ token: "{zone}", description: "구역", example: "A구역" },
{ token: "{row}", description: "열 번호", example: "1" },
{ token: "{row:02}", description: "열 번호 (2자리)", example: "01" },
{ token: "{row:03}", description: "열 번호 (3자리)", example: "001" },
{ token: "{level}", description: "단 번호", example: "1" },
{ token: "{level:02}", description: "단 번호 (2자리)", example: "01" },
{ token: "{level:03}", description: "단 번호 (3자리)", example: "001" },
];