배포 준비 — 자격증명 분리, 산출물 축소, 로깅·전역 예외

[자격증명] 접속 문자열 우선순위를 환경변수 > 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 <noreply@anthropic.com>
This commit is contained in:
Msystech
2026-08-11 19:23:27 +09:00
co-authored by Claude Fable 5
parent 0f77cb717a
commit 71862c2986
20 changed files with 754 additions and 71 deletions
+108
View File
@@ -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` 규격을 조사해 편입한다.
- **단일 인스턴스 제어** — 같은 서식을 두 프로세스에서 열어 각각 저장하면 뒤에 저장한 쪽이 앞 내용을 이력으로 밀어낸다. 프로세스 내부는 막혀 있지만 프로세스 간에는 무방비다. 다중 사용자 병행 운영 전에 처리한다.
+21
View File
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
사내 M.Framework.* 패키지 피드.
현재 이 피드는 개발자 개인 OneDrive 동기화 폴더에 있어 다른 머신·CI 에서는 경로가 다르다.
경로를 하드코딩하면 남의 머신에서 "패키지를 찾을 수 없음"만 나와 원인을 알기 어려우므로,
환경변수 MSYS_NUGET_FEED 로 받게 하고 문서에 명시한다.
setx MSYS_NUGET_FEED "C:\...\기술연구소-Msystech Nuget Package - 문서"
설정하지 않으면 복원이 실패하지만, 실패 메시지에 이 키 이름이 드러나 조치가 자명해진다.
장기적으로는 사내 NuGet 서버로 옮기는 것이 맞다.
-->
<configuration>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
<add key="MSYSTECH_Nuget" value="%MSYS_NUGET_FEED%" />
<add key="MSYS_Framework" value="%MSYS_NUGET_FEED%\[00]Framework" />
</packageSources>
</configuration>
+12 -3
View File
@@ -7,11 +7,20 @@ namespace SheetMe.Data.Config;
public sealed class DataConfig
{
#region Properties
/// <summary>Oracle 접속 문자열 (ConnectionStrings:His)</summary>
/// <summary>Oracle 접속 문자열 — 어느 원천에서도 못 찾으면 빈 문자열(파일 전용 모드)</summary>
public string ConnectionString { get; set; } = string.Empty;
/// <summary>DB 프로바이더 — 기본 Oracle</summary>
public string Provider { get; set; } = "Oracle";
/// <summary>
/// 접속 정보의 출처 — "appsettings" 또는 "ServerInfo(병원라벨)". 진단·상태 표시용이며
/// <b>자격증명은 담지 않는다</b>(어느 DB 에 붙었는지만 알려준다).
/// </summary>
public string ConnectionSource { get; set; } = "없음";
/// <summary>캔버스 그리드 간격(px)</summary>
public int GridSize { get; set; } = 4;
/// <summary>정렬 가이드 스냅 임계값(px)</summary>
public int SnapThreshold { get; set; } = 6;
/// <summary>저장 모드 — File(기본, .xml 파일) | Db(E_SdgMst/E_SctMst 저장)</summary>
public string SaveMode { get; set; } = "File";
+174
View File
@@ -0,0 +1,174 @@
using System.Security.Cryptography;
using System.Text;
namespace SheetMe.Data.Config;
/// <summary>
/// 병원 설치본의 HIS DB 연결을 레거시 <b>ServerInfo</b>(<c>MSYSTECH_ServerInfo.ini</c>, M.CMM.ServerInfo 가 기록)에서
/// 읽어 Oracle 연결 문자열로 구성한다. 각 병원 설치본은 이 ini 의 <c>CurrentServer</c> 가 가리키는 DB 로 고정된다.
///
/// ini 값은 레거시 <c>bzStringCryptography</c>(MD5 → 2-key 3DES-ECB, 기본키 "Msystech")로 암호화되어 있어 동일 규칙으로 복호한다.
/// 섹션 <c>[MSYSTECHHIS\ServerList\DBServer]</c> 의 CurrentServer → 엔트리
/// <c>[MSYSTECHHIS\ServerList\DBServer\{name}]</c> 의 HOST/PORT/SERVICE_NAME/UserID/Password 를 EZConnect 로 조립한다.
/// 읽기 전용 — 레거시 산출물을 변경하지 않는다.
///
/// <para>
/// <b>출처</b>: <c>[200]SheetMe\src\Data\SheetMe.Data\ServerInfoReader.cs</c> 에서 복사 이식(vendoring).
/// 두 저장소가 물리적으로 분리돼 ProjectReference 가 불가능하고, 레거시 암복호 규약은 변할 이유가 없는 고정 자산이다.
/// 원본이 바뀌면 여기도 함께 갱신해야 한다.
/// </para>
///
/// <para>
/// <b>보안</b>: 복호 결과와 완성된 연결 문자열은 어떤 로그·화면에도 기록하지 않는다.
/// 복호 키가 상수인 것은 레거시와 동일하다 — 이 키는 이미 [000]bin 의 모든 실행 파일에 들어 있어 비밀이 아니며,
/// 설정 필수로 만들면 "ini 는 있는데 못 읽는" 새로운 실패 모드만 생긴다.
/// </para>
/// </summary>
public static class ServerInfoReader
{
#region Member Fields
/// <summary>기본 복호 키 — 레거시 HKLM\SOFTWARE\MSYSTECH\Company\Name 기본값</summary>
public const string DefaultKey = "Msystech";
private const string IniName = "MSYSTECH_ServerInfo.ini";
private const string DbSection = @"MSYSTECHHIS\ServerList\DBServer";
/// <summary>ini 탐색 상한 — 배포 폴더에서 [000]bin 까지 거슬러 올라가기에 충분한 깊이</summary>
private const int MaxParentProbe = 9;
#endregion
#region Methods
/// <summary>ServerInfo ini(명시 경로 또는 BaseDirectory→상위 탐색)에서 현재 서버의 연결 문자열 구성 — 실패 시 null</summary>
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;
}
}
/// <summary>현재 서버 라벨(CurrentServer) — 어느 병원 DB 에 붙었는지 로그·상태 표시용(자격증명 아님)</summary>
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;
}
}
/// <summary>ini 경로 결정 — 명시 경로 우선, 없으면 실행 폴더에서 상위로 탐색([000]bin\SheetMe → [000]bin)</summary>
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;
}
/// <summary>INI 의 [section] 내 key 값(대소문자 무시, 첫 매치). 백슬래시를 포함한 섹션명을 그대로 비교한다</summary>
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;
}
/// <summary>레거시 bzStringCryptography.Decrypt 복제 — MD5(Unicode(key)) 16바이트 → 3DES-ECB(PKCS7) → Unicode 문자열</summary>
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
}
@@ -1,31 +0,0 @@
using M.Framework.TableFramework;
using M.MW.Data.EMR;
namespace SheetMe.Data.DataContexts;
/// <summary>
/// EMR 서식 마스터 데이터 컨텍스트 — M.MW.Data.EMR 기존 엔티티 재사용(레거시 테이블과 1:1).
/// 목록성 LINQ 조회 기반. CLOB/채번/트랜잭션 저장은 raw ODP.NET 스토어가 담당([200]SheetMe 검증 분업).
/// </summary>
public sealed class EmrDataContext : AbstractDataContext
{
#region Properties
/// <summary>서식 마스터 (E_ShtMst)</summary>
public DataSet<E_ShtMst> SheetMasters { get; }
/// <summary>서식 디자인 버전 (E_SdgMst)</summary>
public DataSet<E_SdgMst> SheetDesigns { get; }
/// <summary>서식 컨트롤 마스터 (E_SctMst)</summary>
public DataSet<E_SctMst> SheetControls { get; }
#endregion
#region Constructors
public EmrDataContext(string provider, string connectionString) : base(provider, connectionString)
{
SheetMasters = new(access);
SheetDesigns = new(access);
SheetControls = new(access);
}
#endregion
}
+6 -4
View File
@@ -11,10 +11,12 @@
</ItemGroup>
<ItemGroup>
<!-- [200]SheetMe에서 검증된 조합과 동일 버전 고정 (M.MW.Data.EMR: E_ShtMst/E_SdgMst/E_SctMst 엔티티) -->
<PackageReference Include="M.Framework.DBAccess" Version="9.3.15.1" />
<PackageReference Include="M.Framework.TableFramework" Version="6.0.13.2" />
<PackageReference Include="M.MW.Data.EMR" Version="3.0.0.3" />
<!--
DB 접근은 전부 raw Oracle.ManagedDataAccess.Client 로 한다.
M.Framework.DBAccess / TableFramework / M.MW.Data.EMR 은 EmrDataContext 하나만 참조했는데
그 클래스가 어디서도 인스턴스화되지 않아 함께 제거했다(DB 프로바이더 5종 + Azure/MSAL 전이 의존 약 9MB 감소).
TableFramework 기반 엔티티 접근이 다시 필요해지면 그때 되살린다.
-->
<PackageReference Include="Oracle.ManagedDataAccess.Core" Version="23.26.100" />
</ItemGroup>
+46 -2
View File
@@ -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 기동 규약: <c>SheetMe.Designer.exe "UidCod,ComNum,ShtCod"</c> (콤마 구분 단일 인자).
/// </summary>
/// <summary>Dispatcher 예외 폭주 차단용 — 10초 내 5회면 강제 종료(무한 팝업 루프 방지)</summary>
private readonly Queue<DateTime> 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();
}
/// <summary>
/// UI 스레드 미처리 예외 — 기록 후 계속 진행한다. 편집 중 문서를 예외 하나로 잃지 않게 하려는 것이며,
/// 같은 예외가 반복되면(10초 내 5회) 무한 팝업 루프이므로 강제 종료한다.
/// </summary>
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;
}
/// <summary>진단 플래그 분기 — 종료 코드를 반환한다(호출부가 Shutdown 처리)</summary>
private int RunDiagnostic(string[] args)
{
@@ -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
/// <summary>데이터 설정(appsettings.json)</summary>
public DataConfig Config { get; } = ConfigLoader.Load();
public DataConfig Config { get; } = ConfigService.Current;
/// <summary>DB 사용 가능 여부 — 접속 문자열 존재</summary>
public bool CanUseDb => Config.ConnectionString.Length > 0;
+10 -10
View File
@@ -20,7 +20,7 @@ public static class DbSmoke
var lines = new List<string>();
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<string>();
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<string>();
try
{
var config = ConfigLoader.Load();
var config = ConfigService.Current;
var store = new OracleLegacyFormStore(config.ConnectionString);
var serializer = new LegacyXmlSerializer();
+30 -2
View File
@@ -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<string>()) is null);
// 25) 더티 추적 — 종료 가드의 판단 근거(별도 문서로 격리해 위 단계와 간섭 없게)
// 26) 더티 추적 — 종료 가드의 판단 근거(별도 문서로 격리해 위 단계와 간섭 없게)
var dirtyDoc = new DesignerViewModel(business.CreateNew());
Check("더티: 초기 상태는 clean", !dirtyDoc.Undo.IsDirty);
dirtyDoc.AddControlAt("Label", new Point(50, 50));
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
병원 배포용 퍼블리시 프로필.
RuntimeIdentifier 를 csproj 가 아니라 여기 두는 이유: csproj 에 넣으면 dotnet build/test 까지
RID 별 복원을 타서 개발 루프가 느려지고 산출물 경로가 바뀐다.
배포 형태 = FDD(SelfContained=false). [000]bin\OCR서식생성기 가 이미 net10.0 FDD 로 돌고 있어
Microsoft.WindowsDesktop.App 10.x 런타임이 단말에 있다는 선례가 있다. 런타임이 없는 단말이
발견되면 SelfContained 를 true 로 바꾸기만 하면 된다(그 대신 산출물이 약 150MB 가 된다).
배치 위치는 반드시 [000]bin\SheetMe\ 하위 폴더다 — docs/DEPLOYMENT.md 참조.
-->
<Project>
<PropertyGroup>
<Configuration>Release</Configuration>
<TargetFramework>net10.0-windows</TargetFramework>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>false</SelfContained>
<PublishSingleFile>false</PublishSingleFile>
<PublishTrimmed>false</PublishTrimmed>
<PublishReadyToRun>false</PublishReadyToRun>
<SatelliteResourceLanguages>ko</SatelliteResourceLanguages>
<DebugType>none</DebugType>
</PropertyGroup>
</Project>
+121
View File
@@ -0,0 +1,121 @@
using System.IO;
using System.Text.RegularExpressions;
using LM = M.Framework.LogManager.LogManager;
namespace SheetMe.Designer.Services;
/// <summary>
/// 애플리케이션 로그 — M.Framework.LogManager 배선.
///
/// 원칙 셋:
/// <list type="number">
/// <item>모든 호출을 try/catch 로 감싼다 — 로깅 실패가 업무를 막으면 안 된다.</item>
/// <item>기록 직전 <see cref="Redact"/> 를 1회 통과시킨다 — 접속 문자열을 값으로 들고 다니므로
/// 예외 메시지에 자격증명이 섞일 수 있다.</item>
/// <item>로그 경로 확보에 실패하면 %LocalAppData% 로 폴백한다(ThemeManager 가 이미 쓰는 경로).</item>
/// </list>
/// </summary>
public static class AppLog
{
#region Member Fields
private static readonly Lazy<string> logPath = new(ResolveLogPath);
/// <summary>접속 문자열 자격증명 — 로그·화면 어디에도 남기지 않는다</summary>
private static readonly Regex SecretPattern = new(
@"(?<key>Password|Pwd|User\s*Id|UserId)\s*=\s*[^;""\r\n]*",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
#endregion
#region Properties
/// <summary>로그 폴더 — 실행 폴더\logs\Designer, 쓰기 불가 시 %LocalAppData%\SheetMe\logs\Designer</summary>
public static string LogPath => logPath.Value;
#endregion
#region Methods
/// <summary>정보 기록</summary>
public static void Info(string message) => Write(LM.LogType.INFO, message);
/// <summary>경고 기록</summary>
public static void Warn(string message) => Write(LM.LogType.WARNING, message);
/// <summary>오류 기록 — 오류 코드를 돌려주고, 같은 코드가 로그 줄머리에 찍힌다</summary>
/// <returns>사용자에게 안내할 짧은 오류 코드(전화 한 통으로 로그를 특정할 수 있게)</returns>
public static string Error(string context, Exception exception)
{
var code = NewErrorCode();
Write(LM.LogType.ERROR, $"[{code}] {context}{Environment.NewLine}{exception}");
return code;
}
/// <summary>보안·감사성 이벤트(로그인, 권한 거부, DB 쓰기)</summary>
public static void Audit(string message) => Write(LM.LogType.FIXED, message);
/// <summary>
/// 기동 시 1회 — 기록할 최소 로그 레벨을 지정한다.
/// LogLevel 은 누적형이다(DEBUG ⊃ INFO ⊃ WARNING ⊃ ERROR). FIXED 는 어느 레벨에서도 남는다.
///
/// <b>함정</b>: LogLevel 은 열거형이 아니라 <b>문자열</b> 속성이라 오타를 컴파일러가 못 잡는다.
/// 유효하지 않은 값이 들어가면 FIXED 만 기록되고 나머지가 전부 조용히 사라진다.
/// 그래서 LogType 열거값의 이름으로만 지정한다.
/// </summary>
public static void Initialize(bool verbose = false)
{
try
{
LM.LogLevel = (verbose ? LM.LogType.DEBUG : LM.LogType.INFO).ToString();
}
catch
{
// 레벨 설정 실패는 기본 동작으로 진행
}
}
/// <summary>자격증명 마스킹 — 예외 메시지·설정 덤프가 로그로 나가기 전 마지막 방어선</summary>
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
{
// 로깅 실패는 업무를 막지 않는다
}
}
/// <summary>오류 코드 — 사용자가 읽어줄 수 있을 만큼 짧게(시각 기반이라 로그에서 바로 찾힌다)</summary>
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
}
+60 -12
View File
@@ -5,41 +5,89 @@ using SheetMe.Data.Config;
namespace SheetMe.Designer.Services;
/// <summary>
/// appsettings → DataConfig 바인딩 로더.
/// 우선순위: 환경변수 &gt; appsettings.Development.json &gt; appsettings.json.
/// 커밋되는 appsettings.json 은 <c>__HOST__</c> 류 플레이스홀더만 담으며, 실접속 정보는
/// appsettings.Development.json(개발, Debug 빌드에서만 산출물 복사) 또는 환경변수로 주입한다.
/// 설정 로더 — 접속 문자열 우선순위:
/// <c>환경변수</c> &gt; <c>appsettings.Development.json</c> &gt; <c>appsettings.json</c> &gt; <c>ServerInfo ini</c>.
///
/// 커밋되는 appsettings.json 은 <c>__HOST__</c> 류 플레이스홀더만 담는다. 개발 단말은
/// appsettings.Development.json(Debug 빌드에서만 산출물 복사, .gitignore 대상)으로 덮고,
/// 병원 설치본은 아무 설정 없이 [000]bin 의 ServerInfo ini 를 자동으로 찾아 붙는다.
///
/// <b>주의</b>: ini 의 CurrentServer 는 운영 병원 DB 를 가리킨다. 그래서 SaveMode 기본값은 File 이며,
/// DB 쓰기는 명시적으로 켠 단말에서만 활성화된다.
/// </summary>
public static class ConfigLoader
{
#region Methods
/// <summary>설정 로드 — 설정이 없으면 기본값(File 모드)</summary>
/// <summary>설정 로드 — 어느 원천에서도 접속 정보를 못 찾으면 ConnectionString 이 빈 문자열(파일 전용 모드)</summary>
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;
}
/// <summary>
/// 커밋본의 미치환 플레이스홀더인지 — 이 경우 '설정'으로 간주해 DB 기능을 끈다.
/// 커밋본의 미치환 플레이스홀더인지 — 이 경우 '설정 없음'으로 간주해 다음 우선순위로 넘어간다.
/// 플레이스홀더를 그대로 접속에 쓰면 무의미한 연결 실패 예외가 사용자에게 노출된다.
/// </summary>
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
}
/// <summary>설정 단일 소유 — ServerInfo ini 파싱과 3DES 복호가 호출마다 반복되지 않도록 1회만 로드한다</summary>
public static class ConfigService
{
#region Member Fields
private static DataConfig? current;
private static readonly object gate = new();
#endregion
#region Properties
/// <summary>현재 설정 — 최초 접근 시 1회 로드</summary>
public static DataConfig Current
{
get
{
if (current is not null)
{
return current;
}
lock (gate)
{
return current ??= ConfigLoader.Load();
}
}
}
#endregion
}
@@ -28,5 +28,23 @@ public sealed class DialogService
};
return dialog.ShowDialog() == true ? dialog.FileName : null;
}
/// <summary>
/// 오류 안내 — 상세는 로그로, 화면에는 조치 가능한 내용만.
///
/// 우리가 던진 안내성 예외(<see cref="InvalidOperationException"/> 등)는 메시지 자체에 조치가
/// 담겨 있으므로 그대로 보여준다. 그 외(Oracle 오류·NRE 등)는 SQL 조각이나 접속 단서가 섞일 수 있어
/// 일반화 문구 + 오류 코드만 노출한다 — 코드는 로그 줄머리와 같아서 전화 한 통으로 특정된다.
/// </summary>
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
}
@@ -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;
}
@@ -20,7 +20,13 @@
</ItemGroup>
<ItemGroup>
<!--
WebView2 는 M.Framework.WPF 의 전이 의존일 뿐 소스 참조가 0건이다(grep 확인).
런타임 자산을 빼면 약 0.9MB 가 줄고, 무엇보다 [000]bin 의 기존 WebView2 DLL 3개와의
이름 충돌이 사라진다. 나중에 WebView2 를 실제로 쓰게 되면 이 항목을 제거한다.
-->
<PackageReference Include="M.Framework.WPF" Version="6.5.3" />
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.2592.51" ExcludeAssets="runtime" PrivateAssets="all" />
<PackageReference Include="M.Framework.LogManager" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.0" />
@@ -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);
@@ -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();
+3 -1
View File
@@ -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"
},
+99
View File
@@ -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