From 71862c2986b52d7a9b4bb2907da11f219b80c927 Mon Sep 17 00:00:00 2001 From: Msystech Date: Tue, 11 Aug 2026 19:22:07 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B0=B0=ED=8F=AC=20=EC=A4=80=EB=B9=84=20?= =?UTF-8?q?=E2=80=94=20=EC=9E=90=EA=B2=A9=EC=A6=9D=EB=AA=85=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC,=20=EC=82=B0=EC=B6=9C=EB=AC=BC=20=EC=B6=95=EC=86=8C,?= =?UTF-8?q?=20=EB=A1=9C=EA=B9=85=C2=B7=EC=A0=84=EC=97=AD=20=EC=98=88?= =?UTF-8?q?=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [자격증명] 접속 문자열 우선순위를 환경변수 > appsettings.Development.json > appsettings.json > ServerInfo ini 로 세웠다. 병원 설치본은 설정 없이 [000]bin 의 MSYSTECH_ServerInfo.ini 를 자동으로 찾아 붙는다(레거시와 동일한 MD5 → 3DES-ECB 복호). - ServerInfoReader 를 [200]SheetMe 에서 복사 이식(vendoring — 두 저장소가 분리돼 있어 ProjectReference 불가, 레거시 암복호 규약은 변할 이유가 없는 고정 자산). 헤더에 동기화 의무 명시. - 실측 검증: ini 566라인/병원 엔트리 76개, CurrentServer=029.BSGH 복호 후 실제 접속 성공. - SaveMode 기본값을 File 로 되돌렸다. ini 의 CurrentServer 가 운영 병원 DB 를 가리키므로 DB 쓰기는 명시적으로 켠 단말에서만 활성화되어야 한다. - ConfigService 로 단일 소유화 — ConfigLoader.Load() 11회 호출이 ini 파싱 + 3DES 복호를 매번 반복하던 것을 1회로. 죽어 있던 Designer:GridSize/SnapThreshold 를 SnapEngine 에 배선하고, 쓰이지 않던 His:Provider 와 DefaultPaperWidth/Height 는 제거했다(후자는 설정이 아니라 레거시 패리티 상수다). [산출물] 8.6MB/34파일 → 7.2MB/26파일. - EmrDataContext 제거 — M.Framework.DBAccess/TableFramework/M.MW.Data.EMR 의 유일한 소비처였는데 그 클래스가 어디서도 인스턴스화되지 않았다. DB 접근은 전부 raw Oracle 클라이언트를 쓴다. - Microsoft.Web.WebView2 는 ExcludeAssets="runtime" — M.Framework.WPF 전이 의존일 뿐 소스 참조 0건. [000]bin 과의 이름 충돌 3건도 함께 사라진다. - Production.pubxml(win-x64, FDD, SatelliteResourceLanguages=ko). RID 를 csproj 가 아니라 pubxml 에 둔 이유는 csproj 에 넣으면 dotnet build/test 까지 RID 별 복원을 타기 때문이다. - tools/publish.ps1 — 비밀값 하드 게이트 + [000]bin 충돌 경고 + SHA256 매니페스트 + zip. 게이트는 역방향으로 검증했다(appsettings.json 에 실접속 정보를 넣고 실행 → 정상 차단). - nuget.config 신설 — 사내 피드가 개발자 개인 OneDrive 경로라 다른 머신에서 복원이 불가능했다. %MSYS_NUGET_FEED% 환경변수로 받게 해 최소한 실패 원인이 드러나게 했다. [배포 규약] docs/DEPLOYMENT.md. [000]bin 최상위 평면 복사를 금지한다 — 실측 결과 Oracle.ManagedDataAccess.dll 이 겹치고 (신규 .NET Core 5,434KB ↔ 기존 .NET FW 4,602KB), 덮으면 그 폴더의 레거시 EXE 187개가 전부 Oracle 접속 불능이 된다. [000]bin\SheetMe\ 하위 폴더에 둔다 — Information/Log/ OCR서식생성기/SpreadDesign 등 기존 앱들과 같은 방식이다. FDD 로 배포한다: [000]bin\OCR서식생성기 가 이미 net10.0 + WindowsDesktop.App 10.0.0 을 요구하며 운영 중이라 런타임 존재가 확인된다. 없는 단말이 나오면 -SelfContained 한 번이면 된다. [로깅] AppLog — LogManager 배선. 모든 호출을 try/catch 로 감싸 로깅 실패가 업무를 막지 않게 했다. - Redact 필수 — 접속 문자열을 값으로 들고 다니므로 예외 메시지에 자격증명이 섞일 수 있다. 기록 직전 1회 통과시킨다. - LogLevel 은 열거형이 아니라 문자열 속성이라 오타를 컴파일러가 못 잡고, 잘못된 값이면 FIXED 만 남고 나머지가 조용히 사라진다. LogType 열거값의 이름으로만 지정하게 했다. - 문서에 있는 HandleShutdown 은 6.0.0 DLL 에 실제로는 없어(XML 문서가 앞서 있음) 쓰지 않는다. 대신 기록이 비동기 배치라 스모크에서 짧게 폴링해 확인한다. - 로그 경로는 실행 폴더\logs\Designer, 쓰기 불가 시 %LocalAppData% 폴백(쓰기 프로브까지 확인). [전역 예외] Dispatcher/AppDomain/TaskScheduler 3종을 진단 분기보다 앞에 등록했다. UI 예외는 기록 후 계속 진행한다(편집 중 문서를 예외 하나로 잃지 않게) — 단 10초 내 5회면 무한 팝업 루프이므로 강제 종료한다. DialogService.ShowError 도입 — 우리가 던진 안내성 예외는 메시지를 그대로 보여주고, 그 외는 일반화 문구 + 오류 코드만 노출한다(코드가 로그 줄머리와 같아 전화 한 통으로 특정된다). ex.ToString() 전문을 그대로 띄우던 2곳을 정리했다. 로그인/권한거부/DB저장은 감사 이벤트(FIXED)로 남긴다. 검증: 테스트 70/70, edit-smoke 실패 0(마스킹 5건 + 로그 배선 1건 추가), 왕복 1,271건 diff 0/예외 0, db-save-smoke 제자리 갱신 통과. publish.ps1 정방향/역방향 모두 확인. Co-Authored-By: Claude Fable 5 --- docs/DEPLOYMENT.md | 108 +++++++++++ nuget.config | 21 +++ src/SheetMe.Data/Config/DataConfig.cs | 15 +- src/SheetMe.Data/Config/ServerInfoReader.cs | 174 ++++++++++++++++++ .../DataContexts/EmrDataContext.cs | 31 ---- src/SheetMe.Data/SheetMe.Data.csproj | 10 +- src/SheetMe.Designer/App.xaml.cs | 48 ++++- .../DataBusiness/FormDesignDataBusiness.cs | 4 +- src/SheetMe.Designer/Diagnostics/DbSmoke.cs | 20 +- src/SheetMe.Designer/Diagnostics/EditSmoke.cs | 32 +++- .../PublishProfiles/Production.pubxml | 26 +++ src/SheetMe.Designer/Services/AppLog.cs | 121 ++++++++++++ src/SheetMe.Designer/Services/ConfigLoader.cs | 72 ++++++-- .../Services/DialogService.cs | 18 ++ .../Services/SessionBootstrap.cs | 2 + src/SheetMe.Designer/SheetMe.Designer.csproj | 6 + .../ViewModels/DesignerViewModel.cs | 7 +- .../ViewModels/MainViewModel.cs | 7 +- src/SheetMe.Designer/appsettings.json | 4 +- tools/publish.ps1 | 99 ++++++++++ 20 files changed, 754 insertions(+), 71 deletions(-) create mode 100644 docs/DEPLOYMENT.md create mode 100644 nuget.config create mode 100644 src/SheetMe.Data/Config/ServerInfoReader.cs delete mode 100644 src/SheetMe.Data/DataContexts/EmrDataContext.cs create mode 100644 src/SheetMe.Designer/Properties/PublishProfiles/Production.pubxml create mode 100644 src/SheetMe.Designer/Services/AppLog.cs create mode 100644 tools/publish.ps1 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