배포 준비 — 자격증명 분리, 산출물 축소, 로깅·전역 예외
[자격증명] 접속 문자열 우선순위를 환경변수 > 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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user