diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md
new file mode 100644
index 0000000..2757b23
--- /dev/null
+++ b/docs/DEPLOYMENT.md
@@ -0,0 +1,108 @@
+# SheetMe 배포 가이드
+
+## 요약
+
+```bash
+pwsh tools/publish.ps1
+```
+
+산출물을 각 병원의 **`[000]bin\SheetMe\`** 하위 폴더에 푼다. 접속 정보는 코드나 설정에 넣지 않는다 — `[000]bin\MSYSTECH_ServerInfo.ini`에서 자동으로 읽는다.
+
+---
+
+## 🔴 `[000]bin` 최상위에 평면 복사 금지
+
+`[000]bin`은 **DLL 1,772개 / EXE 187개**를 공유하는 폴더다. 신규 산출물과 이름이 겹치는 DLL이 실측 기준 아래와 같다.
+
+| DLL | 신규(.NET 10) | 기존(.NET FW) |
+|---|---|---|
+| `Oracle.ManagedDataAccess.dll` | 5,434 KB | 4,602 KB |
+| `Microsoft.Web.WebView2.*.dll` ×3 | — | — (`ExcludeAssets="runtime"` 로 제외됨) |
+
+평면 복사하면 `Oracle.ManagedDataAccess.dll`이 덮여 **레거시 EXE 187개 전부가 Oracle 접속 불능**이 된다. 반드시 하위 폴더에 둔다. `Information`, `Log`, `OCR서식생성기`, `SpreadDesign` 등 기존 앱들도 같은 방식이다.
+
+---
+
+## 런타임 요구사항
+
+FDD(framework-dependent)로 배포한다. 단말에 **.NET 10 Desktop Runtime**(`Microsoft.WindowsDesktop.App 10.x`)이 필요하다.
+
+```bash
+dotnet --list-runtimes | findstr WindowsDesktop.App
+```
+
+선례: `[000]bin\OCR서식생성기\WindowsOCR.runtimeconfig.json`이 이미 `net10.0` + `Microsoft.WindowsDesktop.App 10.0.0`을 요구하며 운영 중이다. 런타임이 없는 단말이 발견되면 자체 포함으로 전환한다(산출물 8.6MB → 약 150MB).
+
+```bash
+pwsh tools/publish.ps1 -SelfContained
+```
+
+---
+
+## 접속 정보 우선순위
+
+```
+환경변수 > appsettings.Development.json > appsettings.json > ServerInfo ini
+```
+
+- **병원 설치본**: 위 세 가지가 모두 없거나 플레이스홀더(`__HOST__`)이므로 `ServerInfo ini`로 떨어진다. `ResolveIniPath`가 실행 폴더에서 상위로 최대 9단계 탐색하므로 `[000]bin\SheetMe\` → `[000]bin\MSYSTECH_ServerInfo.ini`를 찾는다.
+- **개발 단말**: `appsettings.Development.json`(`.gitignore` 대상, Debug 빌드에서만 산출물 복사).
+- **일회성 오버라이드**: 환경변수 `ConnectionStrings__His`.
+
+ini의 `CurrentServer`가 가리키는 DB로 고정 연결되며, 값은 레거시와 동일한 MD5 → 3DES-ECB 규약으로 복호한다(`ServerInfoReader`). **복호 결과와 완성된 접속 문자열은 로그·화면에 절대 기록하지 않는다.**
+
+### ⚠ `SaveMode` 기본값은 `File`
+
+ini의 `CurrentServer`는 **운영 병원 DB**를 가리킨다. 그래서 DB 쓰기는 `FormStore:SaveMode`를 `Db`로 명시한 단말에서만 활성화된다. 이 기본값을 바꾸지 말 것.
+
+---
+
+## 기동 규약
+
+```
+SheetMe.Designer.exe "UidCod,ComNum,ShtCod"
+```
+
+콤마 구분 단일 인자다(레거시 `M.EMR.SheetDesigner.exe`와 동일). 필드가 모자라도 동작한다.
+
+| 인자 | 의미 |
+|---|---|
+| `UidCod` | HIS 사용자 코드 — **감사 컬럼(`SdgUidCod` 등)의 출처.** 없으면 DB 쓰기가 차단된다 |
+| `ComNum` | 내원번호 — 레거시에서도 쓰이지 않는 죽은 인자. 형상 보존용 |
+| `ShtCod` | 기동 시 자동으로 열 서식 코드. 비우면 서식 목록만 표시 |
+
+예: `SheetMe.Designer.exe "011825,,P163"`
+
+### 바로가기 배포
+
+현재는 기록지정보 화면의 '서식생성기 연동' 버튼이 레거시를 띄운다(레거시 저장소 무수정 결정). SheetMe는 **사용자별 바로가기**로 기동한다 — `UidCod`가 감사 컬럼의 출처이므로 사용자마다 다른 인자가 필요하다.
+
+```
+대상: C:\MsystechHIS_Ver.2\[000]Bin\SheetMe\SheetMe.Designer.exe "011825,,"
+시작 위치: C:\MsystechHIS_Ver.2\[000]Bin\SheetMe
+```
+
+인자 없이 실행하면 미인증 상태로 열리며, **DB 읽기와 파일 저장은 되지만 DB 쓰기는 차단**된다.
+
+---
+
+## 배포 후 확인
+
+1. `SheetMe.Designer.exe "<유효한UidCod>,,"` 실행 → 서식 목록이 뜨는지
+2. 서식을 열고 **저장하지 말고** 닫기 → 정상 종료되는지
+3. 로그 폴더(`[000]bin\SheetMe\logs\Designer\`)에 파일이 생기는지
+4. 화면 어디에도 접속 문자열·비밀번호가 노출되지 않는지
+5. **레거시 EXE가 멀쩡한지** — 기존 HIS 프로그램 하나를 띄워 DB 조회가 되는지 확인(평면 복사 사고 감지)
+
+---
+
+## 롤백
+
+`[000]bin\SheetMe` 폴더 이름을 바꾸거나 지우면 끝난다. 레거시 산출물은 하나도 건드리지 않았으므로 다른 조치가 필요 없다.
+
+---
+
+## 아직 하지 않은 것
+
+- **LiveUpdate 편입** — 하위 폴더 지원 여부와 삭제 파일 정리 동작을 확인하지 못했다. 모르는 상태로 태우면 구 DLL이 남는 반쪽 갱신이 진단 불가 장애가 된다. 수동 zip 배포로 시작하고, 안정화 후 `M.CMM.UpdateInfoFileGenerator.exe` 규격을 조사해 편입한다.
+- **단일 인스턴스 제어** — 같은 서식을 두 프로세스에서 열어 각각 저장하면 뒤에 저장한 쪽이 앞 내용을 이력으로 밀어낸다. 프로세스 내부는 막혀 있지만 프로세스 간에는 무방비다. 다중 사용자 병행 운영 전에 처리한다.
diff --git a/nuget.config b/nuget.config
new file mode 100644
index 0000000..3d3a563
--- /dev/null
+++ b/nuget.config
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/SheetMe.Data/Config/DataConfig.cs b/src/SheetMe.Data/Config/DataConfig.cs
index 78c9343..ad2db69 100644
--- a/src/SheetMe.Data/Config/DataConfig.cs
+++ b/src/SheetMe.Data/Config/DataConfig.cs
@@ -7,11 +7,20 @@ namespace SheetMe.Data.Config;
public sealed class DataConfig
{
#region Properties
- /// Oracle 접속 문자열 (ConnectionStrings:His)
+ /// Oracle 접속 문자열 — 어느 원천에서도 못 찾으면 빈 문자열(파일 전용 모드)
public string ConnectionString { get; set; } = string.Empty;
- /// DB 프로바이더 — 기본 Oracle
- public string Provider { get; set; } = "Oracle";
+ ///
+ /// 접속 정보의 출처 — "appsettings" 또는 "ServerInfo(병원라벨)". 진단·상태 표시용이며
+ /// 자격증명은 담지 않는다(어느 DB 에 붙었는지만 알려준다).
+ ///
+ public string ConnectionSource { get; set; } = "없음";
+
+ /// 캔버스 그리드 간격(px)
+ public int GridSize { get; set; } = 4;
+
+ /// 정렬 가이드 스냅 임계값(px)
+ public int SnapThreshold { get; set; } = 6;
/// 저장 모드 — File(기본, .xml 파일) | Db(E_SdgMst/E_SctMst 저장)
public string SaveMode { get; set; } = "File";
diff --git a/src/SheetMe.Data/Config/ServerInfoReader.cs b/src/SheetMe.Data/Config/ServerInfoReader.cs
new file mode 100644
index 0000000..fe85eed
--- /dev/null
+++ b/src/SheetMe.Data/Config/ServerInfoReader.cs
@@ -0,0 +1,174 @@
+using System.Security.Cryptography;
+using System.Text;
+
+namespace SheetMe.Data.Config;
+
+///
+/// 병원 설치본의 HIS DB 연결을 레거시 ServerInfo(MSYSTECH_ServerInfo.ini, M.CMM.ServerInfo 가 기록)에서
+/// 읽어 Oracle 연결 문자열로 구성한다. 각 병원 설치본은 이 ini 의 CurrentServer 가 가리키는 DB 로 고정된다.
+///
+/// ini 값은 레거시 bzStringCryptography(MD5 → 2-key 3DES-ECB, 기본키 "Msystech")로 암호화되어 있어 동일 규칙으로 복호한다.
+/// 섹션 [MSYSTECHHIS\ServerList\DBServer] 의 CurrentServer → 엔트리
+/// [MSYSTECHHIS\ServerList\DBServer\{name}] 의 HOST/PORT/SERVICE_NAME/UserID/Password 를 EZConnect 로 조립한다.
+/// 읽기 전용 — 레거시 산출물을 변경하지 않는다.
+///
+///
+/// 출처: [200]SheetMe\src\Data\SheetMe.Data\ServerInfoReader.cs 에서 복사 이식(vendoring).
+/// 두 저장소가 물리적으로 분리돼 ProjectReference 가 불가능하고, 레거시 암복호 규약은 변할 이유가 없는 고정 자산이다.
+/// 원본이 바뀌면 여기도 함께 갱신해야 한다.
+///
+///
+///
+/// 보안: 복호 결과와 완성된 연결 문자열은 어떤 로그·화면에도 기록하지 않는다.
+/// 복호 키가 상수인 것은 레거시와 동일하다 — 이 키는 이미 [000]bin 의 모든 실행 파일에 들어 있어 비밀이 아니며,
+/// 설정 필수로 만들면 "ini 는 있는데 못 읽는" 새로운 실패 모드만 생긴다.
+///
+///
+public static class ServerInfoReader
+{
+ #region Member Fields
+ /// 기본 복호 키 — 레거시 HKLM\SOFTWARE\MSYSTECH\Company\Name 기본값
+ public const string DefaultKey = "Msystech";
+
+ private const string IniName = "MSYSTECH_ServerInfo.ini";
+ private const string DbSection = @"MSYSTECHHIS\ServerList\DBServer";
+
+ /// ini 탐색 상한 — 배포 폴더에서 [000]bin 까지 거슬러 올라가기에 충분한 깊이
+ private const int MaxParentProbe = 9;
+ #endregion
+
+ #region Methods
+ /// ServerInfo ini(명시 경로 또는 BaseDirectory→상위 탐색)에서 현재 서버의 연결 문자열 구성 — 실패 시 null
+ public static string? TryBuildConnectionString(string? iniPath = null, string? key = null)
+ {
+ var path = ResolveIniPath(iniPath);
+ if (path is null)
+ {
+ return null;
+ }
+
+ try
+ {
+ var lines = File.ReadAllLines(path);
+ var current = ReadValue(lines, DbSection, "CurrentServer");
+ if (string.IsNullOrWhiteSpace(current))
+ {
+ return null;
+ }
+
+ var entry = DbSection + "\\" + current.Trim();
+ var decryptKey = string.IsNullOrEmpty(key) ? DefaultKey : key;
+ string? Decode(string param)
+ {
+ var raw = ReadValue(lines, entry, param);
+ return string.IsNullOrEmpty(raw) ? null : Decrypt(raw, decryptKey);
+ }
+
+ var host = Decode("HOST");
+ var port = Decode("PORT");
+ var service = Decode("SERVICE_NAME");
+ var user = Decode("UserID");
+ var password = Decode("Password");
+ if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(service) || string.IsNullOrWhiteSpace(user))
+ {
+ return null;
+ }
+
+ var resolvedPort = string.IsNullOrWhiteSpace(port) ? "1521" : port!.Trim();
+ return $"User Id={user};Password={password};Data Source={host!.Trim()}:{resolvedPort}/{service!.Trim()}";
+ }
+ catch
+ {
+ // ini 손상·권한 오류 등은 '설정 없음'과 동일하게 다룬다 — 상위가 다음 우선순위로 넘어간다
+ return null;
+ }
+ }
+
+ /// 현재 서버 라벨(CurrentServer) — 어느 병원 DB 에 붙었는지 로그·상태 표시용(자격증명 아님)
+ public static string? CurrentServerName(string? iniPath = null)
+ {
+ var path = ResolveIniPath(iniPath);
+ if (path is null)
+ {
+ return null;
+ }
+ try
+ {
+ var value = ReadValue(File.ReadAllLines(path), DbSection, "CurrentServer");
+ return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ /// ini 경로 결정 — 명시 경로 우선, 없으면 실행 폴더에서 상위로 탐색([000]bin\SheetMe → [000]bin)
+ private static string? ResolveIniPath(string? iniPath)
+ {
+ if (!string.IsNullOrWhiteSpace(iniPath))
+ {
+ return File.Exists(iniPath) ? iniPath : null;
+ }
+
+ var dir = AppContext.BaseDirectory;
+ for (var i = 0; i < MaxParentProbe && !string.IsNullOrEmpty(dir); i++)
+ {
+ var candidate = Path.Combine(dir, IniName);
+ if (File.Exists(candidate))
+ {
+ return candidate;
+ }
+ dir = Path.GetDirectoryName(dir.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
+ }
+ return null;
+ }
+
+ /// INI 의 [section] 내 key 값(대소문자 무시, 첫 매치). 백슬래시를 포함한 섹션명을 그대로 비교한다
+ private static string? ReadValue(string[] lines, string section, string param)
+ {
+ var inSection = false;
+ foreach (var raw in lines)
+ {
+ var line = raw.Trim();
+ if (line.Length == 0 || line[0] == ';')
+ {
+ continue;
+ }
+ if (line[0] == '[' && line[^1] == ']')
+ {
+ inSection = string.Equals(line[1..^1], section, StringComparison.OrdinalIgnoreCase);
+ continue;
+ }
+ if (!inSection)
+ {
+ continue;
+ }
+ var eq = line.IndexOf('=');
+ if (eq <= 0)
+ {
+ continue;
+ }
+ if (string.Equals(line[..eq].Trim(), param, StringComparison.OrdinalIgnoreCase))
+ {
+ return line[(eq + 1)..].Trim();
+ }
+ }
+ return null;
+ }
+
+ /// 레거시 bzStringCryptography.Decrypt 복제 — MD5(Unicode(key)) 16바이트 → 3DES-ECB(PKCS7) → Unicode 문자열
+ public static string Decrypt(string base64, string key)
+ {
+ using var md5 = MD5.Create();
+ var keyBytes = md5.ComputeHash(Encoding.Unicode.GetBytes(key)); // 16바이트 = 2-key 3DES
+ using var tripleDes = TripleDES.Create();
+ tripleDes.Key = keyBytes;
+ tripleDes.Mode = CipherMode.ECB;
+ tripleDes.Padding = PaddingMode.PKCS7;
+ var cipher = Convert.FromBase64String(base64);
+ using var decryptor = tripleDes.CreateDecryptor();
+ return Encoding.Unicode.GetString(decryptor.TransformFinalBlock(cipher, 0, cipher.Length));
+ }
+ #endregion
+}
diff --git a/src/SheetMe.Data/DataContexts/EmrDataContext.cs b/src/SheetMe.Data/DataContexts/EmrDataContext.cs
deleted file mode 100644
index 8a5d0aa..0000000
--- a/src/SheetMe.Data/DataContexts/EmrDataContext.cs
+++ /dev/null
@@ -1,31 +0,0 @@
-using M.Framework.TableFramework;
-using M.MW.Data.EMR;
-
-namespace SheetMe.Data.DataContexts;
-
-///
-/// EMR 서식 마스터 데이터 컨텍스트 — M.MW.Data.EMR 기존 엔티티 재사용(레거시 테이블과 1:1).
-/// 목록성 LINQ 조회 기반. CLOB/채번/트랜잭션 저장은 raw ODP.NET 스토어가 담당([200]SheetMe 검증 분업).
-///
-public sealed class EmrDataContext : AbstractDataContext
-{
- #region Properties
- /// 서식 마스터 (E_ShtMst)
- public DataSet SheetMasters { get; }
-
- /// 서식 디자인 버전 (E_SdgMst)
- public DataSet SheetDesigns { get; }
-
- /// 서식 컨트롤 마스터 (E_SctMst)
- public DataSet SheetControls { get; }
- #endregion
-
- #region Constructors
- public EmrDataContext(string provider, string connectionString) : base(provider, connectionString)
- {
- SheetMasters = new(access);
- SheetDesigns = new(access);
- SheetControls = new(access);
- }
- #endregion
-}
diff --git a/src/SheetMe.Data/SheetMe.Data.csproj b/src/SheetMe.Data/SheetMe.Data.csproj
index f448341..76bfce0 100644
--- a/src/SheetMe.Data/SheetMe.Data.csproj
+++ b/src/SheetMe.Data/SheetMe.Data.csproj
@@ -11,10 +11,12 @@
-
-
-
-
+
diff --git a/src/SheetMe.Designer/App.xaml.cs b/src/SheetMe.Designer/App.xaml.cs
index 94e323a..0ec30cf 100644
--- a/src/SheetMe.Designer/App.xaml.cs
+++ b/src/SheetMe.Designer/App.xaml.cs
@@ -1,4 +1,4 @@
-using System.IO;
+using System.IO;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
@@ -17,10 +17,24 @@ public partial class App : Application
/// 기동 — 진단 모드 분기, 아니면 HIS 세션을 확정한 뒤 메인 셸 표시.
/// HIS 기동 규약: SheetMe.Designer.exe "UidCod,ComNum,ShtCod" (콤마 구분 단일 인자).
///
+ /// Dispatcher 예외 폭주 차단용 — 10초 내 5회면 강제 종료(무한 팝업 루프 방지)
+ private readonly Queue recentCrashes = new();
+
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
+ // 진단 분기보다 먼저 등록한다 — 스모크 중 발생한 예외도 로그에 남아야 한다
+ Services.AppLog.Initialize();
+ DispatcherUnhandledException += OnDispatcherUnhandledException;
+ AppDomain.CurrentDomain.UnhandledException += (_, args) =>
+ Services.AppLog.Error("처리되지 않은 예외(도메인)", args.ExceptionObject as Exception ?? new Exception(args.ExceptionObject?.ToString()));
+ System.Threading.Tasks.TaskScheduler.UnobservedTaskException += (_, args) =>
+ {
+ Services.AppLog.Error("관측되지 않은 Task 예외", args.Exception);
+ args.SetObserved();
+ };
+
if (Services.StartupArguments.IsDiagnostic(e.Args))
{
Services.UserSession.InitializeDiagnostic();
@@ -29,7 +43,7 @@ public partial class App : Application
}
var launch = Services.StartupArguments.Parse(e.Args);
- var config = Services.ConfigLoader.Load();
+ var config = Services.ConfigService.Current;
if (!Services.SessionBootstrap.TryInitialize(launch, config.ConnectionString, config.DevUidCod, out var error))
{
MessageBox.Show(error, "서식생성기", MessageBoxButton.OK, MessageBoxImage.Error);
@@ -48,6 +62,36 @@ public partial class App : Application
main.Show();
}
+ ///
+ /// UI 스레드 미처리 예외 — 기록 후 계속 진행한다. 편집 중 문서를 예외 하나로 잃지 않게 하려는 것이며,
+ /// 같은 예외가 반복되면(10초 내 5회) 무한 팝업 루프이므로 강제 종료한다.
+ ///
+ private void OnDispatcherUnhandledException(object sender,
+ System.Windows.Threading.DispatcherUnhandledExceptionEventArgs e)
+ {
+ var code = Services.AppLog.Error("처리되지 않은 예외(UI)", e.Exception);
+
+ var now = DateTime.UtcNow;
+ recentCrashes.Enqueue(now);
+ while (recentCrashes.Count > 0 && (now - recentCrashes.Peek()).TotalSeconds > 10)
+ {
+ recentCrashes.Dequeue();
+ }
+ if (recentCrashes.Count >= 5)
+ {
+ MessageBox.Show("반복되는 오류로 프로그램을 종료합니다.\n로그를 확인해 주세요.\n오류 코드: " + code,
+ "서식생성기", MessageBoxButton.OK, MessageBoxImage.Error);
+ e.Handled = true;
+ Shutdown(3);
+ return;
+ }
+
+ MessageBox.Show(
+ $"오류가 발생했지만 작업은 계속할 수 있습니다.\n저장하지 않은 내용이 있으면 먼저 저장해 주세요.\n\n오류 코드: {code}",
+ "서식생성기", MessageBoxButton.OK, MessageBoxImage.Warning);
+ e.Handled = true;
+ }
+
/// 진단 플래그 분기 — 종료 코드를 반환한다(호출부가 Shutdown 처리)
private int RunDiagnostic(string[] args)
{
diff --git a/src/SheetMe.Designer/DataBusiness/FormDesignDataBusiness.cs b/src/SheetMe.Designer/DataBusiness/FormDesignDataBusiness.cs
index 67cfad5..96ad490 100644
--- a/src/SheetMe.Designer/DataBusiness/FormDesignDataBusiness.cs
+++ b/src/SheetMe.Designer/DataBusiness/FormDesignDataBusiness.cs
@@ -1,4 +1,4 @@
-using SheetMe.Core.Models;
+using SheetMe.Core.Models;
using SheetMe.Data.Config;
using SheetMe.Data.Stores;
using SheetMe.Designer.Services;
@@ -19,7 +19,7 @@ internal sealed class FormDesignDataBusiness
#region Properties
/// 데이터 설정(appsettings.json)
- public DataConfig Config { get; } = ConfigLoader.Load();
+ public DataConfig Config { get; } = ConfigService.Current;
/// DB 사용 가능 여부 — 접속 문자열 존재
public bool CanUseDb => Config.ConnectionString.Length > 0;
diff --git a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs
index b4c723f..fce8df5 100644
--- a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs
+++ b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs
@@ -20,7 +20,7 @@ public static class DbSmoke
var lines = new List();
try
{
- var config = ConfigLoader.Load();
+ var config = ConfigService.Current;
var store = new OracleLegacyFormStore(config.ConnectionString);
var serializer = new LegacyXmlSerializer();
@@ -108,7 +108,7 @@ public static class DbSmoke
{
try
{
- var config = ConfigLoader.Load();
+ var config = ConfigService.Current;
using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(config.ConnectionString);
connection.Open();
using var command = connection.CreateCommand();
@@ -153,7 +153,7 @@ public static class DbSmoke
{
try
{
- var config = ConfigLoader.Load();
+ var config = ConfigService.Current;
using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(config.ConnectionString);
connection.Open();
using var command = connection.CreateCommand();
@@ -184,7 +184,7 @@ public static class DbSmoke
{
try
{
- var config = ConfigLoader.Load();
+ var config = ConfigService.Current;
using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(config.ConnectionString);
connection.Open();
using var command = connection.CreateCommand();
@@ -223,7 +223,7 @@ public static class DbSmoke
{
throw new ArgumentException("유효하지 않은 테이블명");
}
- var config = ConfigLoader.Load();
+ var config = ConfigService.Current;
using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(config.ConnectionString);
connection.Open();
using var command = connection.CreateCommand();
@@ -259,7 +259,7 @@ public static class DbSmoke
{
try
{
- var config = ConfigLoader.Load();
+ var config = ConfigService.Current;
using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(config.ConnectionString);
connection.Open();
using var command = connection.CreateCommand();
@@ -297,7 +297,7 @@ public static class DbSmoke
{
try
{
- var config = ConfigLoader.Load();
+ var config = ConfigService.Current;
var store = new OracleLegacyFormStore(config.ConnectionString);
var raw = store.LoadActiveDesignRaw(shtCod);
File.WriteAllText(reportPath,
@@ -316,7 +316,7 @@ public static class DbSmoke
{
try
{
- var config = ConfigLoader.Load();
+ var config = ConfigService.Current;
var store = new OracleLegacyFormStore(config.ConnectionString);
var sheets = store.ListSheets(keyword, max: 50);
File.WriteAllText(reportPath, string.Join(Environment.NewLine,
@@ -336,7 +336,7 @@ public static class DbSmoke
var lines = new List();
try
{
- var config = ConfigLoader.Load();
+ var config = ConfigService.Current;
var store = new RecordWordStore(config.ConnectionString);
// 이전 스모크 잔여 정리
@@ -493,7 +493,7 @@ public static class DbSmoke
var lines = new List();
try
{
- var config = ConfigLoader.Load();
+ var config = ConfigService.Current;
var store = new OracleLegacyFormStore(config.ConnectionString);
var serializer = new LegacyXmlSerializer();
diff --git a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs
index 2485893..c6f0812 100644
--- a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs
+++ b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs
@@ -425,7 +425,35 @@ public static class EditSmoke
Check("상용구 모드: 제목에 '쿼리' 없음", !wordEditor.Title.Contains("쿼리"), wordEditor.Title);
wordEditor.Close();
- // 24) 기동 인자 파싱 — 레거시 관용 파싱(PadRight 후 split) 동형
+ // 24) 로그 마스킹 — 접속 문자열이 예외 메시지에 섞여 로그로 나가는 것을 막는 마지막 방어선
+ // 픽스처에는 절대 실제 자격증명을 쓰지 않는다 — 소스에 박히면 비밀 스캔이 영구히 걸리고,
+ // 저장소 자체가 유출 경로가 된다.
+ const string fakeUser = "SAMPLE_ACCOUNT";
+ const string fakePassword = "SAMPLE_SECRET_VALUE";
+ var secretish = $"ORA-12545: Data Source=(HOST=1.2.3.4);User Id={fakeUser};Password={fakePassword};";
+ var redacted = AppLog.Redact(secretish);
+ Check("마스킹: 비밀번호 제거", !redacted.Contains(fakePassword, StringComparison.Ordinal), redacted);
+ Check("마스킹: 계정 제거", !redacted.Contains(fakeUser, StringComparison.Ordinal), redacted);
+ Check("마스킹: 진단 문맥은 보존", redacted.Contains("ORA-12545", StringComparison.Ordinal), redacted);
+ Check("마스킹: 키 이름은 남김(형태 파악용)", redacted.Contains("Password=", StringComparison.Ordinal), redacted);
+ Check("마스킹: 빈 문자열 안전", AppLog.Redact(string.Empty) == string.Empty);
+
+ // LogManager 는 ConcurrentQueue 배치 기록이고 flush API 가 없다(6.0.0 에 HandleShutdown 미존재).
+ // 기록 직후에는 파일이 아직 없을 수 있으므로 잠시 폴링한다.
+ AppLog.Info("edit-smoke 로그 배선 확인");
+ var logWritten = false;
+ for (var wait = 0; wait < 30 && !logWritten; wait++)
+ {
+ logWritten = Directory.Exists(AppLog.LogPath)
+ && Directory.GetFiles(AppLog.LogPath, "*", SearchOption.AllDirectories).Length > 0;
+ if (!logWritten)
+ {
+ System.Threading.Thread.Sleep(100);
+ }
+ }
+ Check("로그: 파일 생성됨", logWritten, AppLog.LogPath);
+
+ // 25) 기동 인자 파싱 — 레거시 관용 파싱(PadRight 후 split) 동형
Check("인자: 표준 3필드",
StartupArguments.ParseRaw("MSYS,,S999") is { UidCod: "MSYS", ShtCod: "S999", ComNum: null });
Check("인자: ComNum 포함",
@@ -443,7 +471,7 @@ public static class EditSmoke
Check("인자: 인자 없음",
StartupArguments.Parse(Array.Empty()) is null);
- // 25) 더티 추적 — 종료 가드의 판단 근거(별도 문서로 격리해 위 단계와 간섭 없게)
+ // 26) 더티 추적 — 종료 가드의 판단 근거(별도 문서로 격리해 위 단계와 간섭 없게)
var dirtyDoc = new DesignerViewModel(business.CreateNew());
Check("더티: 초기 상태는 clean", !dirtyDoc.Undo.IsDirty);
dirtyDoc.AddControlAt("Label", new Point(50, 50));
diff --git a/src/SheetMe.Designer/Properties/PublishProfiles/Production.pubxml b/src/SheetMe.Designer/Properties/PublishProfiles/Production.pubxml
new file mode 100644
index 0000000..5629876
--- /dev/null
+++ b/src/SheetMe.Designer/Properties/PublishProfiles/Production.pubxml
@@ -0,0 +1,26 @@
+
+
+
+
+ Release
+ net10.0-windows
+ win-x64
+ false
+ false
+ false
+ false
+ ko
+ none
+
+
diff --git a/src/SheetMe.Designer/Services/AppLog.cs b/src/SheetMe.Designer/Services/AppLog.cs
new file mode 100644
index 0000000..0518dcc
--- /dev/null
+++ b/src/SheetMe.Designer/Services/AppLog.cs
@@ -0,0 +1,121 @@
+using System.IO;
+using System.Text.RegularExpressions;
+using LM = M.Framework.LogManager.LogManager;
+
+namespace SheetMe.Designer.Services;
+
+///
+/// 애플리케이션 로그 — M.Framework.LogManager 배선.
+///
+/// 원칙 셋:
+///
+/// - 모든 호출을 try/catch 로 감싼다 — 로깅 실패가 업무를 막으면 안 된다.
+/// - 기록 직전 를 1회 통과시킨다 — 접속 문자열을 값으로 들고 다니므로
+/// 예외 메시지에 자격증명이 섞일 수 있다.
+/// - 로그 경로 확보에 실패하면 %LocalAppData% 로 폴백한다(ThemeManager 가 이미 쓰는 경로).
+///
+///
+public static class AppLog
+{
+ #region Member Fields
+ private static readonly Lazy logPath = new(ResolveLogPath);
+
+ /// 접속 문자열 자격증명 — 로그·화면 어디에도 남기지 않는다
+ private static readonly Regex SecretPattern = new(
+ @"(?Password|Pwd|User\s*Id|UserId)\s*=\s*[^;""\r\n]*",
+ RegexOptions.IgnoreCase | RegexOptions.Compiled);
+ #endregion
+
+ #region Properties
+ /// 로그 폴더 — 실행 폴더\logs\Designer, 쓰기 불가 시 %LocalAppData%\SheetMe\logs\Designer
+ public static string LogPath => logPath.Value;
+ #endregion
+
+ #region Methods
+ /// 정보 기록
+ public static void Info(string message) => Write(LM.LogType.INFO, message);
+
+ /// 경고 기록
+ public static void Warn(string message) => Write(LM.LogType.WARNING, message);
+
+ /// 오류 기록 — 오류 코드를 돌려주고, 같은 코드가 로그 줄머리에 찍힌다
+ /// 사용자에게 안내할 짧은 오류 코드(전화 한 통으로 로그를 특정할 수 있게)
+ public static string Error(string context, Exception exception)
+ {
+ var code = NewErrorCode();
+ Write(LM.LogType.ERROR, $"[{code}] {context}{Environment.NewLine}{exception}");
+ return code;
+ }
+
+ /// 보안·감사성 이벤트(로그인, 권한 거부, DB 쓰기)
+ public static void Audit(string message) => Write(LM.LogType.FIXED, message);
+
+ ///
+ /// 기동 시 1회 — 기록할 최소 로그 레벨을 지정한다.
+ /// LogLevel 은 누적형이다(DEBUG ⊃ INFO ⊃ WARNING ⊃ ERROR). FIXED 는 어느 레벨에서도 남는다.
+ ///
+ /// 함정: LogLevel 은 열거형이 아니라 문자열 속성이라 오타를 컴파일러가 못 잡는다.
+ /// 유효하지 않은 값이 들어가면 FIXED 만 기록되고 나머지가 전부 조용히 사라진다.
+ /// 그래서 LogType 열거값의 이름으로만 지정한다.
+ ///
+ public static void Initialize(bool verbose = false)
+ {
+ try
+ {
+ LM.LogLevel = (verbose ? LM.LogType.DEBUG : LM.LogType.INFO).ToString();
+ }
+ catch
+ {
+ // 레벨 설정 실패는 기본 동작으로 진행
+ }
+ }
+
+ /// 자격증명 마스킹 — 예외 메시지·설정 덤프가 로그로 나가기 전 마지막 방어선
+ public static string Redact(string text)
+ => string.IsNullOrEmpty(text) ? text : SecretPattern.Replace(text, "${key}=***");
+
+ private static void Write(LM.LogType level, string message)
+ {
+ try
+ {
+ LM.LogWrite(level, LogPath, Redact(message));
+ }
+ catch
+ {
+ // 로깅 실패는 업무를 막지 않는다
+ }
+ }
+
+ /// 오류 코드 — 사용자가 읽어줄 수 있을 만큼 짧게(시각 기반이라 로그에서 바로 찾힌다)
+ private static string NewErrorCode()
+ => DateTime.Now.ToString("MMddHHmmss");
+
+ private static string ResolveLogPath()
+ {
+ var candidates = new[]
+ {
+ Path.Combine(AppContext.BaseDirectory, "logs", "Designer"),
+ Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "SheetMe", "logs", "Designer"),
+ };
+
+ foreach (var candidate in candidates)
+ {
+ try
+ {
+ Directory.CreateDirectory(candidate);
+ // 실제로 쓸 수 있는지까지 확인한다 — Program Files 하위는 만들어져도 쓰기가 막힐 수 있다
+ var probe = Path.Combine(candidate, ".writeprobe");
+ File.WriteAllText(probe, string.Empty);
+ File.Delete(probe);
+ return candidate;
+ }
+ catch
+ {
+ // 다음 후보로
+ }
+ }
+ return Path.GetTempPath();
+ }
+ #endregion
+}
diff --git a/src/SheetMe.Designer/Services/ConfigLoader.cs b/src/SheetMe.Designer/Services/ConfigLoader.cs
index a522e91..f25af14 100644
--- a/src/SheetMe.Designer/Services/ConfigLoader.cs
+++ b/src/SheetMe.Designer/Services/ConfigLoader.cs
@@ -5,41 +5,89 @@ using SheetMe.Data.Config;
namespace SheetMe.Designer.Services;
///
-/// appsettings → DataConfig 바인딩 로더.
-/// 우선순위: 환경변수 > appsettings.Development.json > appsettings.json.
-/// 커밋되는 appsettings.json 은 __HOST__ 류 플레이스홀더만 담으며, 실접속 정보는
-/// appsettings.Development.json(개발, Debug 빌드에서만 산출물 복사) 또는 환경변수로 주입한다.
+/// 설정 로더 — 접속 문자열 우선순위:
+/// 환경변수 > appsettings.Development.json > appsettings.json > ServerInfo ini.
+///
+/// 커밋되는 appsettings.json 은 __HOST__ 류 플레이스홀더만 담는다. 개발 단말은
+/// appsettings.Development.json(Debug 빌드에서만 산출물 복사, .gitignore 대상)으로 덮고,
+/// 병원 설치본은 아무 설정 없이 [000]bin 의 ServerInfo ini 를 자동으로 찾아 붙는다.
+///
+/// 주의: ini 의 CurrentServer 는 운영 병원 DB 를 가리킨다. 그래서 SaveMode 기본값은 File 이며,
+/// DB 쓰기는 명시적으로 켠 단말에서만 활성화된다.
///
public static class ConfigLoader
{
#region Methods
- /// 설정 로드 — 설정이 없으면 기본값(File 모드)
+ /// 설정 로드 — 어느 원천에서도 접속 정보를 못 찾으면 ConnectionString 이 빈 문자열(파일 전용 모드)
public static DataConfig Load()
{
var config = new DataConfig();
- var basePath = AppContext.BaseDirectory;
-
var root = new ConfigurationBuilder()
- .SetBasePath(basePath)
+ .SetBasePath(AppContext.BaseDirectory)
.AddJsonFile("appsettings.json", optional: true)
.AddJsonFile("appsettings.Development.json", optional: true)
.AddEnvironmentVariables()
.Build();
- var connectionString = root.GetConnectionString("His") ?? string.Empty;
- config.ConnectionString = IsPlaceholder(connectionString) ? string.Empty : connectionString;
- config.Provider = root["His:Provider"] ?? "Oracle";
+ var configured = root.GetConnectionString("His") ?? string.Empty;
+ if (IsPlaceholder(configured))
+ {
+ // 설정 파일에 실접속 정보가 없다 → 병원 설치본 경로: ServerInfo ini 에서 구성
+ config.ConnectionString = ServerInfoReader.TryBuildConnectionString(root["Server:InfoPath"], root["Server:InfoKey"])
+ ?? string.Empty;
+ config.ConnectionSource = config.ConnectionString.Length > 0
+ ? $"ServerInfo({ServerInfoReader.CurrentServerName(root["Server:InfoPath"]) ?? "?"})"
+ : "없음";
+ }
+ else
+ {
+ config.ConnectionString = configured;
+ config.ConnectionSource = "appsettings";
+ }
+
config.SaveMode = root["FormStore:SaveMode"] ?? "File";
config.XmlFolder = root["FormStore:XmlFolder"] ?? "forms";
config.DevUidCod = root["His:DevUidCod"] ?? string.Empty;
+ config.GridSize = ReadInt(root["Designer:GridSize"], 4);
+ config.SnapThreshold = ReadInt(root["Designer:SnapThreshold"], 6);
return config;
}
///
- /// 커밋본의 미치환 플레이스홀더인지 — 이 경우 '미설정'으로 간주해 DB 기능을 끈다.
+ /// 커밋본의 미치환 플레이스홀더인지 — 이 경우 '설정 없음'으로 간주해 다음 우선순위로 넘어간다.
/// 플레이스홀더를 그대로 접속에 쓰면 무의미한 연결 실패 예외가 사용자에게 노출된다.
///
private static bool IsPlaceholder(string connectionString) =>
connectionString.Length == 0 || connectionString.Contains("__", StringComparison.Ordinal);
+
+ private static int ReadInt(string? value, int fallback)
+ => int.TryParse(value, out var parsed) && parsed > 0 ? parsed : fallback;
+ #endregion
+}
+
+/// 설정 단일 소유 — ServerInfo ini 파싱과 3DES 복호가 호출마다 반복되지 않도록 1회만 로드한다
+public static class ConfigService
+{
+ #region Member Fields
+ private static DataConfig? current;
+ private static readonly object gate = new();
+ #endregion
+
+ #region Properties
+ /// 현재 설정 — 최초 접근 시 1회 로드
+ public static DataConfig Current
+ {
+ get
+ {
+ if (current is not null)
+ {
+ return current;
+ }
+ lock (gate)
+ {
+ return current ??= ConfigLoader.Load();
+ }
+ }
+ }
#endregion
}
diff --git a/src/SheetMe.Designer/Services/DialogService.cs b/src/SheetMe.Designer/Services/DialogService.cs
index a0a0c66..083ed66 100644
--- a/src/SheetMe.Designer/Services/DialogService.cs
+++ b/src/SheetMe.Designer/Services/DialogService.cs
@@ -28,5 +28,23 @@ public sealed class DialogService
};
return dialog.ShowDialog() == true ? dialog.FileName : null;
}
+
+ ///
+ /// 오류 안내 — 상세는 로그로, 화면에는 조치 가능한 내용만.
+ ///
+ /// 우리가 던진 안내성 예외( 등)는 메시지 자체에 조치가
+ /// 담겨 있으므로 그대로 보여준다. 그 외(Oracle 오류·NRE 등)는 SQL 조각이나 접속 단서가 섞일 수 있어
+ /// 일반화 문구 + 오류 코드만 노출한다 — 코드는 로그 줄머리와 같아서 전화 한 통으로 특정된다.
+ ///
+ public static void ShowError(string action, Exception exception)
+ {
+ var code = AppLog.Error(action, exception);
+ var detail = exception is InvalidOperationException or ArgumentException
+ ? exception.Message
+ : $"{exception.GetType().Name} — 자세한 내용은 로그를 확인해 주세요.\n오류 코드: {code}";
+
+ System.Windows.MessageBox.Show($"{action} 중 오류가 발생했습니다.\n\n{AppLog.Redact(detail)}",
+ "오류", System.Windows.MessageBoxButton.OK, System.Windows.MessageBoxImage.Error);
+ }
#endregion
}
diff --git a/src/SheetMe.Designer/Services/SessionBootstrap.cs b/src/SheetMe.Designer/Services/SessionBootstrap.cs
index d9aaaf2..4630ad5 100644
--- a/src/SheetMe.Designer/Services/SessionBootstrap.cs
+++ b/src/SheetMe.Designer/Services/SessionBootstrap.cs
@@ -46,12 +46,14 @@ public static class SessionBootstrap
var user = SafeResolve(connectionString, requestedUid);
if (user is null)
{
+ AppLog.Audit($"[로그인 실패] UidCod={requestedUid}");
error = $"사용자 [{requestedUid}] 를 확인할 수 없습니다.\n" +
"HIS 사용자 코드가 올바른지, 사용 기간이 유효한지 확인해 주세요.";
return false;
}
UserSession.Initialize(user);
+ AppLog.Audit($"[로그인] {user.Display} 부서={user.DepNam} 병원={user.HspNam}");
return true;
}
diff --git a/src/SheetMe.Designer/SheetMe.Designer.csproj b/src/SheetMe.Designer/SheetMe.Designer.csproj
index c39294d..fb5e898 100644
--- a/src/SheetMe.Designer/SheetMe.Designer.csproj
+++ b/src/SheetMe.Designer/SheetMe.Designer.csproj
@@ -20,7 +20,13 @@
+
+
diff --git a/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs b/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs
index 2048424..8da199c 100644
--- a/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs
+++ b/src/SheetMe.Designer/ViewModels/DesignerViewModel.cs
@@ -195,7 +195,12 @@ public sealed class DesignerViewModel : ViewModelBase
this.spreadDesigns = spreadDesigns;
this.document = document;
Selection = new SelectionService();
- Snap = new SnapEngine();
+ // appsettings 의 Designer 섹션을 실제로 반영한다(그동안 읽히지 않는 죽은 설정이었다)
+ Snap = new SnapEngine
+ {
+ GridSize = ConfigService.Current.GridSize,
+ Tolerance = ConfigService.Current.SnapThreshold,
+ };
Undo = new UndoService(() => Document, ReplaceDocument);
Interaction = new InteractionController(this);
Inspector = new InspectorViewModel(this);
diff --git a/src/SheetMe.Designer/ViewModels/MainViewModel.cs b/src/SheetMe.Designer/ViewModels/MainViewModel.cs
index a0d946b..04a27a4 100644
--- a/src/SheetMe.Designer/ViewModels/MainViewModel.cs
+++ b/src/SheetMe.Designer/ViewModels/MainViewModel.cs
@@ -303,6 +303,7 @@ internal sealed class MainViewModel : ViewModelBase
MessageBoxButton.OK, MessageBoxImage.Information);
return false;
default:
+ Services.AppLog.Audit($"[권한거부] {shtCod} — ShtUsrDesYon 차단 (by {dataBusiness.User.Display})");
MessageBox.Show("서식생성기를 사용하지 않는 서식지입니다.\n기록지 정보를 확인해주세요.", "확인",
MessageBoxButton.OK, MessageBoxImage.Information);
return false;
@@ -350,7 +351,7 @@ internal sealed class MainViewModel : ViewModelBase
}
catch (Exception ex)
{
- MessageBox.Show(ex.ToString());
+ DialogService.ShowError("새 서식 만들기", ex);
}
}
@@ -370,8 +371,7 @@ internal sealed class MainViewModel : ViewModelBase
}
catch (Exception ex)
{
- MessageBox.Show($"서식을 여는 중 오류가 발생했습니다.\n\n{ex}", "오류",
- MessageBoxButton.OK, MessageBoxImage.Error);
+ DialogService.ShowError("서식 열기", ex);
}
}
@@ -547,6 +547,7 @@ internal sealed class MainViewModel : ViewModelBase
}
var sdgKey = dataBusiness.SaveToDb(document);
+ Services.AppLog.Audit($"[DB저장] {document.FormId} → SdgKey {sdgKey} (by {dataBusiness.User.Display})");
CurrentDesigner.IsFromDb = true;
CurrentDesigner.Undo.MarkSaved();
CurrentDesigner.NotifyDisplayNameChanged();
diff --git a/src/SheetMe.Designer/appsettings.json b/src/SheetMe.Designer/appsettings.json
index 2213f28..1a5d925 100644
--- a/src/SheetMe.Designer/appsettings.json
+++ b/src/SheetMe.Designer/appsettings.json
@@ -1,8 +1,10 @@
-{
+{
+ "//": "커밋본에는 실접속 정보를 두지 않는다. 개발 단말은 appsettings.Development.json(.gitignore 대상, Debug 빌드에서만 복사)으로 덮고, 병원 설치본은 [000]bin 의 MSYSTECH_ServerInfo.ini 를 자동으로 찾는다.",
"ConnectionStrings": {
"His": "Data Source=(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=__HOST__)(PORT=1521))(CONNECT_DATA=(SERVICE_NAME=__SERVICE__)));User Id=__USER__;Password=__PASSWORD__;"
},
"FormStore": {
+ "//": "SaveMode 기본값은 File. ServerInfo ini 는 운영 병원 DB 를 가리키므로 DB 쓰기는 명시적으로 켠 단말에서만 활성화한다.",
"SaveMode": "File",
"XmlFolder": "forms"
},
diff --git a/tools/publish.ps1 b/tools/publish.ps1
new file mode 100644
index 0000000..4f6dc15
--- /dev/null
+++ b/tools/publish.ps1
@@ -0,0 +1,99 @@
+#requires -Version 5.1
+<#
+.SYNOPSIS
+ 병원 배포용 산출물 생성 — 비밀값 하드 게이트 + SHA256 매니페스트 + zip.
+
+.DESCRIPTION
+ 산출물에 실접속 정보가 섞이면 60여 개 병원 배포본에 자격증명이 평문으로 나간다.
+ .gitignore 는 커밋만 막을 뿐 퍼블리시 산출물은 못 막으므로, 여기서 실패시켜 차단한다.
+
+.EXAMPLE
+ pwsh tools/publish.ps1
+ pwsh tools/publish.ps1 -SelfContained # .NET 10 Desktop 런타임이 없는 단말용
+#>
+[CmdletBinding()]
+param(
+ [string]$OutputRoot = "$PSScriptRoot\..\publish",
+ [switch]$SelfContained
+)
+
+$ErrorActionPreference = 'Stop'
+$repo = Resolve-Path "$PSScriptRoot\.."
+$project = Join-Path $repo 'src\SheetMe.Designer\SheetMe.Designer.csproj'
+$stage = Join-Path $OutputRoot 'SheetMe'
+
+Write-Host "== SheetMe 배포 산출물 생성 ==" -ForegroundColor Cyan
+
+if (Test-Path $stage) { Remove-Item -Recurse -Force -LiteralPath $stage }
+New-Item -ItemType Directory -Force -Path $stage | Out-Null
+
+$args = @(
+ 'publish', $project,
+ '-c', 'Release',
+ '-p:PublishProfile=Production',
+ '-o', $stage,
+ '--nologo'
+)
+if ($SelfContained) { $args += @('-p:SelfContained=true') }
+
+& dotnet @args
+if ($LASTEXITCODE -ne 0) { throw "퍼블리시 실패 (exit $LASTEXITCODE)" }
+
+# ---- 하드 게이트: 비밀값이 산출물에 섞였으면 여기서 중단 ----
+Write-Host "`n-- 비밀값 검사 --" -ForegroundColor Cyan
+$violations = @()
+
+$devConfig = Join-Path $stage 'appsettings.Development.json'
+if (Test-Path $devConfig) { $violations += "appsettings.Development.json 이 산출물에 포함됨" }
+
+Get-ChildItem -LiteralPath $stage -Recurse -File -Include *.json, *.config, *.ini, *.txt |
+ ForEach-Object {
+ $text = Get-Content -LiteralPath $_.FullName -Raw -ErrorAction SilentlyContinue
+ if ($null -eq $text) { return }
+ # 플레이스홀더(__PASSWORD__)는 통과, 실제 값이 채워진 것만 잡는다
+ if ($text -match 'Password\s*=\s*(?!__)[^;"\s]{1,}') {
+ $violations += "$($_.Name): Password= 에 실제 값이 들어 있음"
+ }
+ if ($text -match 'User\s*Id\s*=\s*(?!__)[^;"\s]{1,}') {
+ $violations += "$($_.Name): User Id= 에 실제 값이 들어 있음"
+ }
+ }
+
+if ($violations.Count -gt 0) {
+ Write-Host "산출물에 자격증명이 포함되어 있습니다:" -ForegroundColor Red
+ $violations | ForEach-Object { Write-Host " - $_" -ForegroundColor Red }
+ throw "비밀값 게이트 실패 — 배포를 중단합니다."
+}
+Write-Host " OK — 자격증명 없음(접속 정보는 [000]bin 의 ServerInfo ini 에서 읽습니다)" -ForegroundColor Green
+
+# ---- 충돌 경고: [000]bin 평면 배치 금지 확인용 ----
+$binRoot = 'C:\MsystechHIS_Ver.2\[000]Bin'
+if (Test-Path -LiteralPath $binRoot) {
+ $existing = @{}
+ Get-ChildItem -LiteralPath $binRoot -File -Filter *.dll | ForEach-Object { $existing[$_.Name] = $true }
+ $clash = Get-ChildItem -LiteralPath $stage -File -Filter *.dll | Where-Object { $existing.ContainsKey($_.Name) }
+ if ($clash) {
+ Write-Host "`n-- 참고: [000]bin 최상위와 이름이 겹치는 DLL $($clash.Count)건 --" -ForegroundColor Yellow
+ $clash | ForEach-Object { Write-Host " $($_.Name)" -ForegroundColor Yellow }
+ Write-Host " → 반드시 [000]bin\SheetMe\ 하위 폴더에 배치하세요(평면 복사 금지)." -ForegroundColor Yellow
+ }
+}
+
+# ---- 매니페스트 ----
+$manifest = Join-Path $stage 'MANIFEST.sha256'
+Get-ChildItem -LiteralPath $stage -Recurse -File |
+ Sort-Object FullName |
+ ForEach-Object {
+ "{0} {1}" -f (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash,
+ $_.FullName.Substring($stage.Length + 1)
+ } | Set-Content -LiteralPath $manifest -Encoding utf8
+
+$files = Get-ChildItem -LiteralPath $stage -Recurse -File
+Write-Host ("`n산출물: {0}개 파일 / {1:N1} MB" -f $files.Count, (($files | Measure-Object Length -Sum).Sum / 1MB))
+
+# ---- zip ----
+$zip = Join-Path $OutputRoot 'SheetMe.zip'
+if (Test-Path $zip) { Remove-Item -Force -LiteralPath $zip }
+Compress-Archive -Path $stage -DestinationPath $zip
+Write-Host "패키지: $zip" -ForegroundColor Green
+Write-Host "`n배포 절차는 docs/DEPLOYMENT.md 를 따르세요." -ForegroundColor Cyan