// ═════════════════════════════════════════════════════════════════════════════ // [진단계측 2026-08-04] Laser Recipe Save 교차 오염 — "언제 어긋나는지" 추적. // 레시피 신원(대상/내용)이 바뀌는 지점마다 흔적을 남기고, // Save 전/후를 비교해 무엇이 무엇으로 바뀌었는지 보여준다. // 저장을 막지 않는다. // // ※ 이 파일 전체가 진단 전용이다. 원인 확정 후 파일을 삭제하고 // WeldingCommon.csproj 의 Compile Include 1줄과 // RecipeHelper.cs 의 [진단계측 2026-08-04] 블록 3곳을 원복할 것. // 원복 절차 : docs/작업지시서/2026-08-04_Laser_Recipe_Save_진단계측_원복.md // ═════════════════════════════════════════════════════════════════════════════ using Aurora; using CoreUnit.Control.UserControl.Canvas; using CoreUnit.Scanner.UserControl.ScannerCanvas; using DB; using System; using System.Collections.Generic; using System.Configuration; using System.Diagnostics; using System.IO; using System.Text; using System.Windows; namespace WeldingCommon.Utils { public static class RecipeTrace { // 흐름을 눈으로 다 보고 싶으면 true (저장할 때마다 팝업). // 평소엔 false 로 두고 불일치가 감지될 때만 뜨게 한다. public const bool ALWAYS_SHOW_ON_SAVE = false; // Auto 중 모달은 시퀀스를 세운다. 기본은 파일에만 기록. public const bool SHOW_IN_AUTO = false; const int KEEP = 40; static readonly List _trail = new List(); static readonly object _lock = new object(); // ── 훅 연결 ───────────────────────────────────────────────────────── // MainPage(LASAGUI)는 WeldingCommon 을 참조할 수 없다(참조 방향이 반대). // 델리게이트를 꽂아 Page(FieldHelper.json) 기록을 같은 이력에 남긴다. // CommonHelper.SetMainPage 에서 호출된다. public static void Attach() { try { GUI.MainPage.Tracer = Mark; } catch { } } // ── 흔적 남기기 ────────────────────────────────────────────────────── public static void Mark(string evt, string detail = "") { string line; try { string actName; int actId; GetActive(out actName, out actId); line = $"{DateTime.Now:HH:mm:ss.fff} T{System.Threading.Thread.CurrentThread.ManagedThreadId,-3} " + $"{evt,-24} 선택={Sel()} 메모리={Short(Work())} LASA={Short(Lasa())} " + $"활성=[{actId}][{actName}] {Status.EquipmentStatus} | {detail} | ← {Caller()}"; } catch (Exception ex) { line = $"{DateTime.Now:HH:mm:ss.fff} {evt} (기록실패 {ex.Message})"; } lock (_lock) { _trail.Add(line); if (_trail.Count > KEEP) _trail.RemoveAt(0); } AppendFile(line); } // ── Save 전/후 스냅샷 ──────────────────────────────────────────────── public class SaveSnap { public string RecipeName, TargetPath, ActivePath, ActiveName; public string Selected, Work, Lasa, Eqp; public string MemFp, TgtFp, ActFp; public string TgtHash, ActHash, TgtField, ActField; public bool BadArg, BadPath, BadMarker; } // 저장 직전 — "왜 어긋났나" public static SaveSnap BeforeSave(string recipeName, string targetPath) { var s = new SaveSnap { RecipeName = recipeName, TargetPath = targetPath }; try { string actName; int actId; GetActive(out actName, out actId); s.ActiveName = actName; s.ActivePath = string.IsNullOrWhiteSpace(actName) ? "" : TeachingPath(actName); s.Selected = Sel(); s.Work = Work(); s.Lasa = Lasa(); s.Eqp = Status.EquipmentStatus.ToString(); s.MemFp = MemFingerprint(); // 메모리 = 저장될 내용 s.TgtFp = FileFingerprint(targetPath); // 저장될 자리에 원래 있던 내용 s.ActFp = FileFingerprint(s.ActivePath); // 활성 레시피 내용 s.TgtHash = Hash(targetPath); s.ActHash = Hash(s.ActivePath); s.TgtField = Hash(FieldPath(targetPath)); // Page 데이터(FieldHelper.json) s.ActField = Hash(FieldPath(s.ActivePath)); // 호출 인자와 전역 대상이 다르다 (OnSaved 가 인자를 버리는 경로) s.BadArg = !string.Equals(recipeName, RecipeHelper.SelectedRecipe.name, StringComparison.OrdinalIgnoreCase); // 저장 대상과 메모리에 로드된 파일이 다르다 s.BadPath = !string.IsNullOrWhiteSpace(s.Work) && !SamePath(s.Work, targetPath); // 두 신원 마커가 서로 다르다 (MainPage 를 안 거친 Load 존재) s.BadMarker = !string.IsNullOrWhiteSpace(s.Work) && !string.IsNullOrWhiteSpace(s.Lasa) && !SamePath(s.Work, s.Lasa); Mark("SAVE 직전", $"요청=[{recipeName}] arg={s.BadArg} path={s.BadPath} marker={s.BadMarker} " + $"메모리={s.MemFp} 대상파일={s.TgtFp}"); } catch { } return s; } // 저장 직후 — "무엇이 무엇으로 바뀌었나" public static void AfterSave(SaveSnap s) { try { if (s == null) return; string tgtFpAfter = FileFingerprint(s.TargetPath); string actFpAfter = FileFingerprint(s.ActivePath); string tgtHashA = Hash(s.TargetPath); string actHashA = Hash(s.ActivePath); string tgtFieldA = Hash(FieldPath(s.TargetPath)); string actFieldA = Hash(FieldPath(s.ActivePath)); bool targetIsActive = SamePath(s.TargetPath, s.ActivePath); bool tgtChanged = s.TgtHash != tgtHashA; bool actChanged = s.ActHash != actHashA; bool tgtFieldChg = s.TgtField != tgtFieldA; bool actFieldChg = s.ActField != actFieldA; // 핵심 : 대상 파일이 활성 레시피 내용으로 바뀌었다 = 신고된 증상 bool becameActive = !targetIsActive && tgtChanged && tgtFpAfter == actFpAfter && s.TgtFp != s.ActFp; // 역방향 : 엉뚱하게 활성 파일이 바뀌었다 (선행문서 원인 2) bool activeHit = !targetIsActive && (actChanged || actFieldChg); bool bad = becameActive || activeHit || s.BadArg || s.BadPath || s.BadMarker; string conclusion = becameActive ? $"★ 대상 [{s.RecipeName}] 이 활성 레시피 [{s.ActiveName}] 내용으로 덮어써짐" : activeHit ? $"★ 대상은 [{s.RecipeName}] 인데 활성 파일 [{s.ActiveName}] 이 함께 변경됨" : (s.BadArg || s.BadPath || s.BadMarker) ? "△ 저장은 대상 파일에만 됐지만 신원이 어긋난 상태였음" : "정상 (대상 파일만 변경)"; Mark(bad ? "*** SAVE 직후 (의심) ***" : "SAVE 직후", $"{conclusion} | 대상변경={tgtChanged} 활성변경={actChanged} " + $"페이지변경(대상)={tgtFieldChg} 페이지변경(활성)={actFieldChg}"); if (!bad && !ALWAYS_SHOW_ON_SAVE) return; if (s.Eqp != eEqpStatus.Manual.ToString() && !SHOW_IN_AUTO) return; var sb = new StringBuilder(); sb.AppendLine("[Recipe Save 진단] 이 화면을 촬영해 주세요. 저장은 그대로 진행되었습니다."); sb.AppendLine(); sb.AppendLine($"결론 : {conclusion}"); sb.AppendLine(); sb.AppendLine($"저장 요청 : [{s.RecipeName}]"); sb.AppendLine($"SelectedRecipe : {s.Selected}"); sb.AppendLine($"활성 레시피 : [{s.ActiveName}]"); sb.AppendLine($"설비상태 : {s.Eqp}"); sb.AppendLine($"신원 어긋남 : arg={s.BadArg} path={s.BadPath} marker={s.BadMarker}"); sb.AppendLine($" 메모리 WorkRecipeFile : {s.Work}"); sb.AppendLine($" 메모리 SelectedRecipeFile : {s.Lasa}"); sb.AppendLine($" 저장대상 target : {s.TargetPath}"); sb.AppendLine(); sb.AppendLine("──────── 무엇이 무엇으로 바뀌었나 ────────"); sb.AppendLine($"저장된 내용(메모리) : {s.MemFp}"); sb.AppendLine(); sb.AppendLine($"대상파일 전 : {s.TgtFp}"); sb.AppendLine($"대상파일 후 : {tgtFpAfter} 변경={tgtChanged}"); sb.AppendLine($"활성파일 전 : {s.ActFp}"); sb.AppendLine($"활성파일 후 : {actFpAfter} 변경={actChanged}"); sb.AppendLine(); sb.AppendLine($"Page(FieldHelper) 대상 변경 : {tgtFieldChg} 활성 변경 : {actFieldChg}"); sb.AppendLine(); sb.AppendLine("──────── 직전 이력 (오래된 것 → 최근) ────────"); lock (_lock) { foreach (var l in _trail) sb.AppendLine(l); } MessageBox.Show(sb.ToString(), "Recipe Save 진단", MessageBoxButton.OK, MessageBoxImage.Warning); } catch { } } // ── 지문 ──────────────────────────────────────────────────────────── // 메모리의 실제 용접 파라미터. ScannerCanvasUserCtrl.ScannerParams 가 실체다. public static string MemFingerprint() { try { return Fp(ScannerCanvasUserCtrl.ScannerParams); } catch { return "?"; } } public static string FileFingerprint(string fullPath) { try { if (string.IsNullOrWhiteSpace(fullPath) || !File.Exists(fullPath)) return "(파일없음)"; return Fp(CommonHelper.GetMainPage().getRecipeInfo(fullPath).ReadOnlyScanParams); } catch (Exception ex) { return "(읽기실패)" + ex.Message; } } // OnSaved 가 비교하는 필드와 동일한 6개, 앞 3개 파라미터 static string Fp(List p) { if (p == null) return "null"; var sb = new StringBuilder($"n={p.Count}"); for (int i = 0; i < 3 && i < p.Count; i++) sb.Append($"|{p[i].MarkDelay},{p[i].JumpDelay},{p[i].LaserOnDelay}," + $"{p[i].LaserOffDelay},{p[i].LaserFrequency},{p[i].NumberOfCount}"); return sb.ToString(); } // 파일↔파일 비교는 파싱 없이 해시로 (빠름) static string Hash(string path) { try { if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) return "(없음)"; using (var md5 = System.Security.Cryptography.MD5.Create()) using (var fs = File.OpenRead(path)) { var h = BitConverter.ToString(md5.ComputeHash(fs)).Replace("-", ""); return h.Substring(0, 8) + "/" + new FileInfo(path).Length; } } catch { return "(해시실패)"; } } // ── 잡동사니 ──────────────────────────────────────────────────────── static string Sel() { var s = RecipeHelper.SelectedRecipe; return $"[{s.id}][{s.name}]"; } static string Work() { try { return DB.Dao.SystemRecipe.WorkRecipeFile; } catch { return ""; } } static string Lasa() { try { return DB.Recipe.Recipe.SelectedRecipeFile; } catch { return ""; } } // Aurora 바이너리 타입명을 쓰지 않기 위해 out 으로 받는다 static void GetActive(out string name, out int id) { name = ""; id = -1; try { var a = Application.Current.AuroraEnv().RecipeManager.ActivatedRecipe(); if (a != null) { name = a.Name; id = a.Id; } } catch { } } // 같은 폴더의 Page 데이터 static string FieldPath(string recipeFullPath) { try { return string.IsNullOrWhiteSpace(recipeFullPath) ? "" : recipeFullPath.Replace(Path.GetFileName(recipeFullPath), "FieldHelper.json"); } catch { return ""; } } static string TeachingPath(string recipeName) { var root = ConfigurationManager.AppSettings.Get("DATA_PATH"); return Path.Combine(root, RecipeExIds.RECIPE_DIR, recipeName, RecipeExIds.TEACHING_EDITOR_RECIPE); } // 전체 경로 대신 레시피 폴더명만 (…/_config_recipe/<레시피명>/TeachingEditor…) static string Short(string p) { try { return string.IsNullOrWhiteSpace(p) ? "(비어있음)" : Path.GetFileName(Path.GetDirectoryName(p)); } catch { return p; } } static bool SamePath(string a, string b) { if (string.IsNullOrWhiteSpace(a) || string.IsNullOrWhiteSpace(b)) return false; try { return string.Equals(Path.GetFullPath(a), Path.GetFullPath(b), StringComparison.OrdinalIgnoreCase); } catch { return string.Equals(a, b, StringComparison.OrdinalIgnoreCase); } } // RecipeTrace 프레임을 건너뛰고 실제 호출자 2단계 static string Caller() { try { var st = new StackTrace(true); var sb = new StringBuilder(); int found = 0; for (int i = 1; i < st.FrameCount && found < 2; i++) { var m = st.GetFrame(i).GetMethod(); var t = m.DeclaringType == null ? "?" : m.DeclaringType.Name; if (t == "RecipeTrace") continue; if (found > 0) sb.Append(" ← "); sb.Append($"{t}.{m.Name}:{st.GetFrame(i).GetFileLineNumber()}"); found++; } return sb.ToString(); } catch { return "?"; } } static void AppendFile(string line) { try { var root = ConfigurationManager.AppSettings.Get("DATA_PATH"); File.AppendAllText(Path.Combine(root, "RecipeTrace.txt"), line + "\r\n", Encoding.UTF8); } catch { } } } }