2026-08-06 11:29:00 +09:00

308 lines
16 KiB
C#

// ═════════════════════════════════════════════════════════════════════════════
// [진단계측 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<string> _trail = new List<string>();
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<ScannerParam> 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 { }
}
}
}