Files
pipeline/backend-node/src/routes/pipelineDeviceConnectionRoutes.ts
T
chpark 77d35220b1
Build and Push Images / build-and-push (push) Has been cancelled
feat(edge): 디지털 트윈용 장비 메타(IP/Protocol) IDC TimescaleDB 적재 + 송신 페이로드 보강
- edgeDeviceConfigReporter 신규: pipeline_device_connections + pipeline_equipment 조인해
  IDC TimescaleDB의 edge_device_config_1 에 5분 주기로 적재 (DISTINCT ON 으로 최신값 조회용)
- app.ts: 부팅 시 startEdgeDeviceConfigReporter, SIGTERM/SIGINT 시 graceful stop 추가
- CollectedData 에 host/port 필드 추가, collectDevice 에서 device row 값 채움
- centralMqttForwarder.buildPayload 에 protocol/host/port 포함 (IDC 컨슈머가 활용)
- 312 마이그레이션(fleet_edge_raw_data host/port 컬럼) 등록 (sql 파일은 .gitignore 로 미동봉, OPS 절차로 적용)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:38:38 +09:00

396 lines
13 KiB
TypeScript

import { Router, Response } from "express";
import { PipelineDeviceConnectionService } from "../services/pipelineDeviceConnectionService";
import { PROTOCOL_OPTIONS, PROTOCOL_DEFAULTS, TAG_DATA_TYPE_OPTIONS, ADDRESS_TYPE_OPTIONS, BYTE_ORDER_OPTIONS } from "../types/pipelineDeviceTypes";
import { authenticateToken } from "../middleware/authMiddleware";
import { AuthenticatedRequest } from "../types/auth";
import { query } from "../database/db";
const router = Router();
// 모든 라우트 인증 필요
router.use(authenticateToken);
// ===== 프로토콜 목록 (정적 경로 우선) =====
router.get("/protocols", async (req: AuthenticatedRequest, res: Response) => {
res.json({
success: true,
data: {
protocols: PROTOCOL_OPTIONS,
defaults: PROTOCOL_DEFAULTS,
tagDataTypes: TAG_DATA_TYPE_OPTIONS,
addressTypes: ADDRESS_TYPE_OPTIONS,
byteOrders: BYTE_ORDER_OPTIONS,
},
});
});
// ===== 장비 목록 (pipeline_equipment) - 드롭다운용 =====
router.get("/equipment-list", async (req: AuthenticatedRequest, res: Response) => {
try {
const userCompanyCode = req.user?.companyCode;
const search = (req.query.search as string) || "";
const params: any[] = [];
let where = "WHERE is_active = 'Y'";
if (userCompanyCode && userCompanyCode !== "*") {
params.push(userCompanyCode);
where += ` AND (company_code = $${params.length} OR company_code = '공통')`;
}
if (search.trim()) {
params.push(`%${search.trim()}%`);
where += ` AND (equipment_name ILIKE $${params.length} OR equipment_code ILIKE $${params.length})`;
}
const rows = await query<any>(
`SELECT id, equipment_code, equipment_name, equipment_type, manufacturer, model, serial_number
FROM pipeline_equipment ${where}
ORDER BY equipment_name LIMIT 500`,
params,
);
res.json({ success: true, data: rows });
} catch (e: any) {
res.status(500).json({ success: false, message: e.message });
}
});
// ===== 태그 수정/삭제 (정적 경로 우선) =====
router.put("/tags/:tagId", async (req: AuthenticatedRequest, res: Response) => {
try {
const result = await PipelineDeviceConnectionService.updateTagMapping(parseInt(req.params.tagId), req.body);
res.status(result.success ? 200 : 400).json(result);
} catch (e: any) {
res.status(500).json({ success: false, message: e.message });
}
});
router.delete("/tags/:tagId", async (req: AuthenticatedRequest, res: Response) => {
try {
const result = await PipelineDeviceConnectionService.deleteTagMapping(parseInt(req.params.tagId));
res.json(result);
} catch (e: any) {
res.status(500).json({ success: false, message: e.message });
}
});
// ===== 연결 CRUD =====
router.get("/", async (req: AuthenticatedRequest, res: Response) => {
try {
const userCompanyCode = req.user?.companyCode;
let companyCodeFilter: string | undefined;
if (userCompanyCode === "*") {
companyCodeFilter = req.query.company_code as string;
} else {
companyCodeFilter = userCompanyCode;
}
const filter = {
protocol: req.query.protocol as string,
is_active: req.query.is_active as string,
company_code: companyCodeFilter,
search: req.query.search as string,
status: req.query.status as string,
};
Object.keys(filter).forEach((key) => {
if (!filter[key as keyof typeof filter]) delete filter[key as keyof typeof filter];
});
const result = await PipelineDeviceConnectionService.getConnections(filter, userCompanyCode);
res.json(result);
} catch (e: any) {
res.status(500).json({ success: false, message: e.message });
}
});
// ===== Target DB 조회 (정적 경로는 반드시 /:id 위에) =====
router.get("/target-databases", async (req: AuthenticatedRequest, res: Response) => {
try {
const { listTargetDatabases } = await import("../services/targetDbIntrospection");
const companyCode =
req.user?.companyCode === "*" ? (req.query.company_code as string | undefined) : req.user?.companyCode;
const data = await listTargetDatabases(companyCode);
res.json({ success: true, data });
} catch (e: any) {
res.status(500).json({ success: false, message: e.message });
}
});
router.get("/target-databases/:dbId/tables", async (req: AuthenticatedRequest, res: Response) => {
try {
const { listTables } = await import("../services/targetDbIntrospection");
const data = await listTables(Number(req.params.dbId));
res.json({ success: true, data });
} catch (e: any) {
res.status(500).json({ success: false, message: e.message });
}
});
router.get(
"/target-databases/:dbId/tables/:tableName/columns",
async (req: AuthenticatedRequest, res: Response) => {
try {
const { listColumns } = await import("../services/targetDbIntrospection");
const data = await listColumns(Number(req.params.dbId), req.params.tableName);
res.json({ success: true, data });
} catch (e: any) {
res.status(500).json({ success: false, message: e.message });
}
}
);
router.get("/:id", async (req: AuthenticatedRequest, res: Response) => {
try {
const result = await PipelineDeviceConnectionService.getConnectionById(parseInt(req.params.id));
res.status(result.success ? 200 : 404).json(result);
} catch (e: any) {
res.status(500).json({ success: false, message: e.message });
}
});
router.post("/", async (req: AuthenticatedRequest, res: Response) => {
try {
const data = {
...req.body,
company_code: req.body.company_code || req.user?.companyCode,
created_by: req.user?.userId,
};
const result = await PipelineDeviceConnectionService.createConnection(data);
res.status(result.success ? 201 : 400).json(result);
} catch (e: any) {
if (e.message?.includes("duplicate") || e.code === "23505") {
res.status(409).json({ success: false, message: "동일한 연결명이 이미 존재합니다." });
} else {
res.status(500).json({ success: false, message: e.message });
}
}
});
router.put("/:id", async (req: AuthenticatedRequest, res: Response) => {
try {
const result = await PipelineDeviceConnectionService.updateConnection(parseInt(req.params.id), req.body);
res.status(result.success ? 200 : 404).json(result);
} catch (e: any) {
res.status(500).json({ success: false, message: e.message });
}
});
router.delete("/:id", async (req: AuthenticatedRequest, res: Response) => {
try {
const result = await PipelineDeviceConnectionService.deleteConnection(parseInt(req.params.id));
res.json(result);
} catch (e: any) {
res.status(500).json({ success: false, message: e.message });
}
});
// ===== 연결 테스트 =====
router.post("/:id/test", async (req: AuthenticatedRequest, res: Response) => {
try {
const result = await PipelineDeviceConnectionService.testConnection(parseInt(req.params.id));
res.json({ success: result.success, data: result });
} catch (e: any) {
res.status(500).json({ success: false, message: e.message });
}
});
// ===== 태그 매핑 =====
router.get("/:id/tags", async (req: AuthenticatedRequest, res: Response) => {
try {
const result = await PipelineDeviceConnectionService.getTagMappings(parseInt(req.params.id));
res.json(result);
} catch (e: any) {
res.status(500).json({ success: false, message: e.message });
}
});
router.post("/:id/tags", async (req: AuthenticatedRequest, res: Response) => {
try {
const result = await PipelineDeviceConnectionService.createTagMapping(parseInt(req.params.id), req.body);
res.status(result.success ? 201 : 400).json(result);
} catch (e: any) {
if (e.message?.includes("duplicate") || e.code === "23505") {
res.status(409).json({ success: false, message: "동일한 태그명이 이미 존재합니다." });
} else {
res.status(500).json({ success: false, message: e.message });
}
}
});
// ===== 태그 컬럼 매핑 일괄 업데이트 =====
router.put("/:id/tag-column-mapping", async (req: AuthenticatedRequest, res: Response) => {
try {
const connectionId = parseInt(req.params.id);
const { mapping } = req.body as { mapping: Array<{ tag_id: number; target_column_name: string | null }> };
if (!Array.isArray(mapping)) {
return res.status(400).json({ success: false, message: "mapping 배열 필요" });
}
const { query } = await import("../database/db");
for (const m of mapping) {
await query(
`UPDATE pipeline_tag_mappings SET target_column_name = $1, updated_at = NOW()
WHERE id = $2 AND connection_id = $3`,
[m.target_column_name, m.tag_id, connectionId]
);
}
res.json({ success: true, updated: mapping.length });
} catch (e: any) {
res.status(500).json({ success: false, message: e.message });
}
});
// ===== 수동 1회 수집 트리거 (변경 사항 즉시 검증용) =====
router.post("/:id/collect-once", async (req: AuthenticatedRequest, res: Response) => {
try {
const connectionId = parseInt(req.params.id);
const { collectDevice, writeToTargetDb } = await import(
"../services/collector/deviceCollectorService"
);
const { upsertEquipmentState } = await import(
"../services/collector/equipmentStateService"
);
const data = await collectDevice(connectionId);
// 수집 성공 시 DB 저장 (Long/Wide 자동 감지)
if (data.plcState === "connected" && Object.keys(data.tags).length > 0) {
await upsertEquipmentState(data).catch(() => {});
await writeToTargetDb(data).catch(err =>
res.locals.targetDbError = (err as Error).message
);
}
return res.json({
success: true,
data: {
plc_state: data.plcState,
error_message: data.errorMessage,
tags_count: Object.keys(data.tags).length,
tags: data.tags,
target_db_error: res.locals.targetDbError,
timestamp: data.timestamp,
},
});
} catch (e: any) {
return res.status(500).json({ success: false, message: e.message });
}
});
// ===== 훅 체인 테스트 (수정 화면에서 값 넣고 실행) =====
router.post("/:id/test-chain", async (req: AuthenticatedRequest, res: Response) => {
try {
const connectionId = parseInt(req.params.id);
const { tag_name, raw_value, save_to_db } = req.body as {
tag_name: string;
raw_value: unknown;
save_to_db?: boolean;
};
if (!tag_name) {
return res.status(400).json({ success: false, message: "tag_name 필수" });
}
const { getHooksForConnection } = await import(
"../services/collector/scriptCache"
);
const { executeHook } = await import("../services/collector/pythonHookRunner");
const steps: Array<{
stage: string;
hook_id?: number;
hook_name?: string;
input: unknown;
output: unknown;
error?: string;
duration_ms?: number;
}> = [];
// 1. transform 체인
let value = raw_value;
const transforms = await getHooksForConnection(connectionId, "transform");
for (const h of transforms) {
const r = await executeHook({
hook_type: "transform",
code: h.code,
tag_name,
raw_value: value,
context: { hook_id: h.id, hook_name: h.script_name },
timeout_ms: h.timeout_ms,
});
steps.push({
stage: "transform",
hook_id: h.id,
hook_name: h.script_name,
input: value,
output: r.value,
error: r.error,
duration_ms: r.duration_ms,
});
if (!r.success) break;
value = r.value;
}
// 2. filter
let kept = true;
const filters = await getHooksForConnection(connectionId, "filter");
for (const h of filters) {
const r = await executeHook({
hook_type: "filter",
code: h.code,
tag_name,
value,
context: { hook_id: h.id },
timeout_ms: h.timeout_ms,
});
steps.push({
stage: "filter",
hook_id: h.id,
hook_name: h.script_name,
input: value,
output: r.skip ? "DROP" : "PASS",
error: r.error,
duration_ms: r.duration_ms,
});
if (r.success && r.skip) {
kept = false;
}
}
// 3. DB 저장 옵션
let saved = false;
let saved_where: string | null = null;
if (save_to_db && kept) {
const { upsertEquipmentState } = await import(
"../services/collector/equipmentStateService"
);
await upsertEquipmentState({
connectionId,
connectionName: "test",
protocol: "",
companyCode: "*",
host: null,
port: null,
timestamp: new Date().toISOString(),
plcState: "connected",
errorMessage: null,
tags: { [tag_name]: value as any },
});
saved = true;
saved_where = "equipment_current_state";
}
return res.json({
success: true,
data: {
steps,
final_value: value,
filter_kept: kept,
saved,
saved_where,
},
});
} catch (e: any) {
return res.status(500).json({ success: false, message: e.message });
}
});
export default router;