배포 준비 — 자격증명 분리, 산출물 축소, 로깅·전역 예외
[자격증명] 접속 문자열 우선순위를 환경변수 > 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:
co-authored by
Claude Fable 5
parent
0f77cb717a
commit
71862c2986
@@ -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";
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -5,41 +5,89 @@ using SheetMe.Data.Config;
|
||||
namespace SheetMe.Designer.Services;
|
||||
|
||||
/// <summary>
|
||||
/// appsettings → DataConfig 바인딩 로더.
|
||||
/// 우선순위: 환경변수 > appsettings.Development.json > appsettings.json.
|
||||
/// 커밋되는 appsettings.json 은 <c>__HOST__</c> 류 플레이스홀더만 담으며, 실접속 정보는
|
||||
/// appsettings.Development.json(개발, Debug 빌드에서만 산출물 복사) 또는 환경변수로 주입한다.
|
||||
/// 설정 로더 — 접속 문자열 우선순위:
|
||||
/// <c>환경변수</c> > <c>appsettings.Development.json</c> > <c>appsettings.json</c> > <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();
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user