HIS 사용자 컨텍스트 + 서식생성기 권한 게이트 + 서버 시각 전환

[사용자 컨텍스트] 감사 컬럼에 Windows 계정명이 들어가던 6지점을 HIS UidCod 로 교체했다.
레거시 이력 화면(ucSheetHistory.vb:51)이 SdgUidCod 를 수정자로 표시하므로 지금까지는
감사 추적이 끊겨 있었다.
- UserContextStore — M_UidMst 단독 조회로 인증 판정(레거시 bzLogIn 자동로그인과 동일 의미론,
  비밀번호를 보지 않고 행 존재 + 유효기간만 확인). 부서/병원은 LEFT JOIN best-effort 표시용.
  적용일자는 서버 시각을 쓴다 — 단말 시계가 밀리면 유효 계정이 거부될 수 있다.
- UserSession — 정적 set-once. 생성자 주입을 택하지 않은 이유는 진단 모드(--db-*)가
  ViewModel 그래프 밖이라 주입으로는 감사 기록 지점에 닿을 수 없기 때문이다. 대신 읽는 곳을
  App 초기화 / FormDesignDataBusiness / MainViewModel 표시 세 군데로 제한했고, 저장소는
  지금처럼 uid 를 파라미터로만 받는다. 진단 모드는 "SMOKE" 식별자를 쓴다.
- StartupArguments — 레거시 규약 "UidCod,ComNum,ShtCod" 관용 파싱(PadRight 후 split) 이식.
  ComNum 은 레거시에서도 선언·대입뿐인 죽은 인자라 보존만 하고 쓰지 않는다.
- App.OnStartup 재구성 — 진단 플래그 12종을 RunDiagnostic 으로 분리(각 분기가 종료 코드를
  반환하도록 정리), 세션 확정 후 MainView 표시. 기동 인자에 ShtCod 가 있으면 PendingSheetCode
  로 넘겨 빈 새 문서를 만들지 않고 그 서식만 연다(열기 실패 시에는 빈 문서로 폴백).
- 인자로 받은 UidCod 조회에 실패하면 기동을 중단한다(레거시 :892 와 동형). 인자 없이 단독
  실행하면 미인증으로 계속하되 DB 쓰기를 차단하고, 개발 편의는 His:DevUidCod 설정으로 뺐다.

[권한 게이트] E_ShtMst.ShtUsrDesYon — 'Y' 또는 미설정이면 허용, 그 외만 거부(레거시
frmSheetDesigner.vb:71-84 규칙, 거부 문구도 그대로). 레거시는 게이트가 열기 진입점마다
흩어져 있어 이력 노드 클릭·MessageQueue 두 경로로 우회가 가능했는데, SheetMe 는 모든 열기가
OpenDbSheet 로 수렴하므로 거기 한 곳이면 구조적으로 우회가 불가능하다. 추가로 이력 열람과
DB 저장에도 넣었다 — 후자는 레거시에 아예 없던 구멍이다(열기 이후 플래그가 꺼질 수 있고,
파일에서 연 문서를 DB 에 저장하는 경로는 열기 게이트를 거치지 않는다).
목록에서 거르지 않고 잠금 아이콘으로 표시한다(안 보이면 사용자가 진단할 수 없다).
--db-gate 진단 신설 — 실측 결과 차단되는 디자인 보유 서식 0건(N 은 1건뿐이고 디자인 없음).

[서버 시각] ServerClock 신설(SELECT SYSTIMESTAMP 왕복 1회로 8/12/17자리 확보, 포맷은
invariant 로 C# 에서 — NLS 의존 제거). 감사 정확성뿐 아니라 ResolveActiveSdgKey 가
ORDER BY SdgUpdDtm 으로 활성 버전을 고르기 때문에, 시계가 뒤로 밀린 단말이 저장하면
활성 버전 판정이 뒤집힐 수 있었다. Reorder 는 루프 밖 1회로 바꿔 한 번의 순서 변경이
행마다 다른 시각을 남기지 않게 했다. 실패 시 폴백하지 않는다.

RecordWordStore.Remove 에 auditUid 인자 추가 — 남길 컬럼은 없지만(런타임 조회 쿼리에
ShtStt 필터가 없어 논리 삭제로 바꾸면 지운 문구가 계속 노출된다) "모든 쓰기 API 는 uid 를
명시한다"는 규약을 컴파일 타임에 강제한다. 상용구 대화상자는 uid 를 생성자로 받고,
미인증이면 읽기 전용으로 연다.

검증: 사전 실측으로 M_UidMst/M_DepMst/M_HspMst 접근 확인(2단계 최대 리스크 해소, 유효
사용자 539명). 테스트 70/70, edit-smoke 실패 0(기동 인자 파싱 8건 추가), 왕복 1,271건
diff 0/예외 0, db-save-smoke 제자리 갱신 통과, db-word-smoke 통과.
DB 직접 확인: P163 저장분 SdgUidCod='SMOKE', SdgUpdDtm 이 서버 시각과 일치
(변경 전 저장분 S999 는 'Msystech' 로 대비됨).

남은 항목: 단일 인스턴스 + Named Pipe IPC 는 미구현. 기동 인자·게이트가 먼저 필요했고,
바로가기 운영에서는 중복 실행 억제가 덜 급하다. 다중 사용자 병행 운영 전에 처리한다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Msystech
2026-08-11 18:37:46 +09:00
co-authored by Claude Fable 5
parent 5c97bf1440
commit 0f77cb717a
16 changed files with 695 additions and 111 deletions
+94 -68
View File
@@ -13,87 +13,113 @@ namespace SheetMe.Designer;
/// </summary>
public partial class App : Application
{
/// <summary>기동 — 스모크 모드 분기 또는 메인 셸 표시</summary>
/// <summary>
/// 기동 — 진단 모드 분기, 아니면 HIS 세션을 확정한 뒤 메인 셸 표시.
/// HIS 기동 규약: <c>SheetMe.Designer.exe "UidCod,ComNum,ShtCod"</c> (콤마 구분 단일 인자).
/// </summary>
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
if (e.Args.Length >= 3 && e.Args[0] == "--render-smoke")
if (Services.StartupArguments.IsDiagnostic(e.Args))
{
var exitCode = RunRenderSmoke(e.Args[1], e.Args[2]);
Shutdown(exitCode);
Services.UserSession.InitializeDiagnostic();
Shutdown(RunDiagnostic(e.Args));
return;
}
if (e.Args.Length >= 2 && e.Args[0] == "--edit-smoke")
var launch = Services.StartupArguments.Parse(e.Args);
var config = Services.ConfigLoader.Load();
if (!Services.SessionBootstrap.TryInitialize(launch, config.ConnectionString, config.DevUidCod, out var error))
{
Shutdown(Diagnostics.EditSmoke.Run(e.Args[1]));
return;
}
if (e.Args.Length >= 2 && e.Args[0] == "--db-smoke")
{
var max = e.Args.Length >= 3 && int.TryParse(e.Args[2], out var n) ? n : 30;
Shutdown(Diagnostics.DbSmoke.RunReadSmoke(e.Args[1], max));
return;
}
if (e.Args.Length >= 3 && e.Args[0] == "--db-save-smoke")
{
Shutdown(Diagnostics.DbSmoke.RunSaveSmoke(e.Args[1], e.Args[2]));
return;
}
if (e.Args.Length >= 3 && e.Args[0] == "--db-find")
{
Shutdown(Diagnostics.DbSmoke.RunFind(e.Args[1], e.Args[2]));
return;
}
if (e.Args.Length >= 3 && e.Args[0] == "--db-columns")
{
Shutdown(Diagnostics.DbSmoke.RunColumns(e.Args[1], e.Args[2]));
return;
}
if (e.Args.Length >= 3 && e.Args[0] == "--db-row")
{
Shutdown(Diagnostics.DbSmoke.RunRow(e.Args[1], e.Args[2]));
return;
}
if (e.Args.Length >= 3 && e.Args[0] == "--db-sample")
{
Shutdown(Diagnostics.DbSmoke.RunSample(e.Args[1], e.Args[2]));
return;
}
if (e.Args.Length >= 3 && e.Args[0] == "--db-word-smoke")
{
Shutdown(Diagnostics.DbSmoke.RunWordSmoke(e.Args[1], e.Args[2]));
return;
}
if (e.Args.Length >= 3 && e.Args[0] == "--db-spd")
{
Shutdown(Diagnostics.DbSmoke.RunSpreadDump(e.Args[1], e.Args[2]));
return;
}
if (e.Args.Length >= 3 && e.Args[0] == "--db-xml")
{
Shutdown(Diagnostics.DbSmoke.RunDesignXml(e.Args[1], e.Args[2]));
return;
}
if (e.Args.Length >= 3 && e.Args[0] == "--db-render")
{
Shutdown(RunDbRenderSmoke(e.Args[1], e.Args[2]));
MessageBox.Show(error, "서식생성기", MessageBoxButton.OK, MessageBoxImage.Error);
Shutdown(2);
return;
}
Services.ThemeManager.LoadSaved();
new MainView().Show();
var main = new MainView();
if (launch?.ShtCod.Length > 0 && main.DataContext is ViewModels.MainViewModel viewModel)
{
// XAML DataContext 는 InitializeComponent 안에서 이미 생성되므로 Show 전에 넣을 수 있다.
// MainView/MainViewModel 생성자를 건드리지 않는 최소 경로.
viewModel.PendingSheetCode = launch.ShtCod;
}
main.Show();
}
/// <summary>진단 플래그 분기 — 종료 코드를 반환한다(호출부가 Shutdown 처리)</summary>
private int RunDiagnostic(string[] args)
{
if (args.Length >= 3 && args[0] == "--render-smoke")
{
return RunRenderSmoke(args[1], args[2]);
}
if (args.Length >= 2 && args[0] == "--edit-smoke")
{
return Diagnostics.EditSmoke.Run(args[1]);
}
if (args.Length >= 2 && args[0] == "--db-smoke")
{
var max = args.Length >= 3 && int.TryParse(args[2], out var n) ? n : 30;
return Diagnostics.DbSmoke.RunReadSmoke(args[1], max);
}
if (args.Length >= 3 && args[0] == "--db-save-smoke")
{
return Diagnostics.DbSmoke.RunSaveSmoke(args[1], args[2]);
}
if (args.Length >= 3 && args[0] == "--db-find")
{
return Diagnostics.DbSmoke.RunFind(args[1], args[2]);
}
if (args.Length >= 3 && args[0] == "--db-columns")
{
return Diagnostics.DbSmoke.RunColumns(args[1], args[2]);
}
if (args.Length >= 3 && args[0] == "--db-row")
{
return Diagnostics.DbSmoke.RunRow(args[1], args[2]);
}
if (args.Length >= 3 && args[0] == "--db-sample")
{
return Diagnostics.DbSmoke.RunSample(args[1], args[2]);
}
if (args.Length >= 3 && args[0] == "--db-word-smoke")
{
return Diagnostics.DbSmoke.RunWordSmoke(args[1], args[2]);
}
if (args.Length >= 3 && args[0] == "--db-spd")
{
return Diagnostics.DbSmoke.RunSpreadDump(args[1], args[2]);
}
if (args.Length >= 3 && args[0] == "--db-xml")
{
return Diagnostics.DbSmoke.RunDesignXml(args[1], args[2]);
}
if (args.Length >= 3 && args[0] == "--db-render")
{
return RunDbRenderSmoke(args[1], args[2]);
}
if (args.Length >= 2 && args[0] == "--db-gate")
{
return Diagnostics.DbSmoke.RunGateReport(args[1]);
}
MessageBox.Show($"알 수 없는 진단 옵션입니다: {args[0]}", "서식생성기",
MessageBoxButton.OK, MessageBoxImage.Warning);
return 2;
}
/// <summary>첫 페이지 오프스크린 렌더 → PNG (렌더 파이프라인 자동 검증)</summary>
@@ -26,6 +26,15 @@ internal sealed class FormDesignDataBusiness
/// <summary>DB 저장 허용 여부 — SaveMode='Db' 명시 설정</summary>
public bool CanSaveToDb => CanUseDb && Config.IsDbSaveMode();
/// <summary>현재 HIS 사용자 — 미인증이면 HisUser.Unknown</summary>
public HisUser User => UserSession.Current;
/// <summary>DB 쓰기 가능 — 접속 + SaveMode='Db' + 사용자 확인. 감사 컬럼에 남길 주체가 있어야 쓴다</summary>
public bool CanWriteDb => CanSaveToDb && UserSession.IsAuthenticated;
/// <summary>감사 컬럼 기록 값 — 쓰기 직전에만 접근한다(미인증이면 예외)</summary>
public string AuditUid => UserSession.AuditUid;
#endregion
#region Methods
@@ -85,15 +94,18 @@ internal sealed class FormDesignDataBusiness
throw new InvalidOperationException(
"DB 저장이 비활성화되어 있습니다. appsettings.json 의 FormStore:SaveMode 를 'Db' 로 설정하세요.");
}
return DbStore().SaveDesign(document, Environment.UserName);
return DbStore().SaveDesign(document, AuditUid);
}
/// <summary>서식 코드 등록 여부(E_ShtMst)</summary>
public bool SheetExists(string shtCod) => DbStore().SheetExists(shtCod);
/// <summary>서식생성기 사용 허용 여부(E_ShtMst.ShtUsrDesYon) — 레거시 열기 게이트</summary>
public SheetDesignPermission GetDesignPermission(string shtCod) => DbStore().GetDesignPermission(shtCod);
/// <summary>신규 서식 최소 등록 — 디자이너 서식 관행 기본값</summary>
public void RegisterSheet(string shtCod, string korName, string? clsCod)
=> DbStore().RegisterSheet(shtCod, korName, clsCod, Environment.UserName);
=> DbStore().RegisterSheet(shtCod, korName, clsCod, AuditUid);
/// <summary>상용구 저장소(E_SHTWRDMST) — DB 접속 필요</summary>
public RecordWordStore RecordWords()
+59 -11
View File
@@ -1,4 +1,4 @@
using System.IO;
using System.IO;
using SheetMe.Core.Models;
using SheetMe.Core.Serialization;
using SheetMe.Data.Stores;
@@ -100,6 +100,54 @@ public static class DbSmoke
}
}
/// <summary>
/// 권한 게이트 영향 범위(--db-gate) — ShtUsrDesYon 값별 서식 수를 집계한다.
/// 게이트 도입 전에 "갑자기 서식이 안 열린다"는 문의 규모를 미리 알기 위한 읽기 전용 진단.
/// </summary>
public static int RunGateReport(string reportPath)
{
try
{
var config = ConfigLoader.Load();
using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(config.ConnectionString);
connection.Open();
using var command = connection.CreateCommand();
command.CommandText =
"SELECT NVL(TRIM(s.ShtUsrDesYon),'(미설정)') AS Flag, COUNT(*) AS Total, " +
" SUM(CASE WHEN EXISTS (SELECT 1 FROM E_SdgMst d " +
" WHERE d.SdgShtCod = s.ShtCod AND NVL(d.SdgDelYon,' ') <> 'Y') " +
" THEN 1 ELSE 0 END) AS WithDesign " +
"FROM E_ShtMst s WHERE NVL(s.ShtTyp,' ') <> 'S' " +
"GROUP BY NVL(TRIM(s.ShtUsrDesYon),'(미설정)') ORDER BY 2 DESC";
var lines = new List<string> { "ShtUsrDesYon 분포 — 'Y' 와 (미설정)만 열기가 허용된다", string.Empty };
var blocked = 0m;
using var reader = command.ExecuteReader();
while (reader.Read())
{
var flag = reader.GetString(0);
var total = reader.GetDecimal(1);
var withDesign = reader.GetDecimal(2);
var allowed = flag is "Y" or "(미설정)";
if (!allowed)
{
blocked += withDesign;
}
lines.Add($" {flag,-10} 전체 {total,5}건 / 디자인 보유 {withDesign,5}건 → {(allowed ? "" : "")}");
}
lines.Add(string.Empty);
lines.Add($"게이트 도입 시 열기가 차단되는 디자인 보유 서식: {blocked}건");
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
return 0;
}
catch (Exception ex)
{
File.WriteAllText(reportPath, "실패: " + ex.Message);
return 1;
}
}
/// <summary>테이블 컬럼 스키마 조회(--db-columns) — NOT NULL/타입 확인(읽기 전용)</summary>
public static int RunColumns(string tableName, string reportPath)
{
@@ -294,33 +342,33 @@ public static class DbSmoke
// 이전 스모크 잔여 정리
foreach (var stale in store.List(shtCod).Where(w => w.Value.StartsWith("스모크 문구")))
{
store.Remove(stale.Key);
store.Remove(stale.Key, UserSession.AuditUid);
}
var before = store.List(shtCod).Count;
lines.Add($"[사전] {shtCod} 상용구 {before}건");
var key1 = store.Add(shtCod, "스모크 문구 1 (특이사항 없음)", Environment.UserName);
var key2 = store.Add(shtCod, "스모크 문구 2 (경과 양호)", Environment.UserName);
var key1 = store.Add(shtCod, "스모크 문구 1 (특이사항 없음)", UserSession.AuditUid);
var key2 = store.Add(shtCod, "스모크 문구 2 (경과 양호)", UserSession.AuditUid);
var afterAdd = store.List(shtCod);
var okAdd = afterAdd.Count == before + 2
&& afterAdd.Any(w => w.Key == key1) && afterAdd.Any(w => w.Key == key2);
lines.Add($"[추가] 2건 → {afterAdd.Count}건 (키 {key1}, {key2}) : {(okAdd ? "" : "")}");
store.Update(key1, "스모크 문구 1 (수정됨)", Environment.UserName);
store.Update(key1, "스모크 문구 1 (수정됨)", UserSession.AuditUid);
var updatedValue = store.List(shtCod).FirstOrDefault(w => w.Key == key1)?.Value;
var okUpdate = updatedValue == "스모크 문구 1 (수정됨)";
lines.Add($"[수정] 값=\"{updatedValue}\" : {(okUpdate ? "" : "")}");
var reordered = afterAdd.Select(w => w.Key).ToList();
reordered.Reverse();
store.Reorder(reordered, Environment.UserName);
store.Reorder(reordered, UserSession.AuditUid);
var firstKey = store.List(shtCod).FirstOrDefault()?.Key;
var okReorder = firstKey == reordered[0];
lines.Add($"[순서] 첫 키={firstKey} 기대={reordered[0]} : {(okReorder ? "" : "")}");
store.Remove(key1);
store.Remove(key2);
store.Remove(key1, UserSession.AuditUid);
store.Remove(key2, UserSession.AuditUid);
var afterRemove = store.List(shtCod).Count;
var okRemove = afterRemove == before;
lines.Add($"[삭제] 원상복구 → {afterRemove}건 : {(okRemove ? "" : "")}");
@@ -370,7 +418,7 @@ public static class DbSmoke
try
{
target.Props.SetText("Text", probeText);
store.SaveDesign(document, Environment.UserName);
store.SaveDesign(document, UserSession.AuditUid);
var (keyAfter, txtAfter) = ReadSctRow(connectionString, sdgKey, target.Id);
var textApplied = string.Equals(txtAfter, probeText, StringComparison.Ordinal);
var keyStable = keyAfter == keyBefore;
@@ -387,7 +435,7 @@ public static class DbSmoke
if (restoreTarget is not null)
{
restoreTarget.Props.SetText("Text", originalText);
store.SaveDesign(restore, Environment.UserName);
store.SaveDesign(restore, UserSession.AuditUid);
var (_, txtRestored) = ReadSctRow(connectionString, sdgKey, target.Id);
lines.Add($"[SctObjTxt] 원복 → '{Trim30(txtRestored)}' " +
$"{(string.Equals(txtRestored, originalText, StringComparison.Ordinal) ? "" : "!")}");
@@ -458,7 +506,7 @@ public static class DbSmoke
// 무변경 재저장(문서 그대로) — 레거시 저장 시와 동일하게 새 버전 생성(또는 제자리 갱신)
var document = serializer.Read(before.Xml);
document.FormId = shtCod;
var savedKey = store.SaveDesign(document, Environment.UserName);
var savedKey = store.SaveDesign(document, UserSession.AuditUid);
var after = store.LoadActiveDesignRaw(shtCod)
?? throw new InvalidOperationException("저장 후 활성 디자인 조회 실패");
+19 -1
View File
@@ -425,7 +425,25 @@ public static class EditSmoke
Check("상용구 모드: 제목에 '쿼리' 없음", !wordEditor.Title.Contains("쿼리"), wordEditor.Title);
wordEditor.Close();
// 24) 더티 추적 — 종료 가드의 판단 근거(별도 문서로 격리해 위 단계와 간섭 없게)
// 24) 기동 인자 파싱 — 레거시 관용 파싱(PadRight 후 split) 동형
Check("인자: 표준 3필드",
StartupArguments.ParseRaw("MSYS,,S999") is { UidCod: "MSYS", ShtCod: "S999", ComNum: null });
Check("인자: ComNum 포함",
StartupArguments.ParseRaw("MSYS,12345,S999") is { ComNum: 12345, ShtCod: "S999" });
Check("인자: UidCod 단독",
StartupArguments.ParseRaw("MSYS") is { UidCod: "MSYS", ShtCod: "" });
Check("인자: 빈 문자열",
StartupArguments.ParseRaw("") is { UidCod: "", ShtCod: "" });
Check("인자: 필드 초과해도 index 안전",
StartupArguments.ParseRaw("MSYS,,S999,X,Y") is { ShtCod: "S999" });
Check("인자: 비숫자 ComNum 관용 처리",
StartupArguments.ParseRaw("MSYS,abc,S999") is { ComNum: null, ShtCod: "S999" });
Check("인자: 진단 플래그는 HIS 인자가 아님",
StartupArguments.Parse(new[] { "--db-smoke", "r.txt" }) is null);
Check("인자: 인자 없음",
StartupArguments.Parse(Array.Empty<string>()) is null);
// 25) 더티 추적 — 종료 가드의 판단 근거(별도 문서로 격리해 위 단계와 간섭 없게)
var dirtyDoc = new DesignerViewModel(business.CreateNew());
Check("더티: 초기 상태는 clean", !dirtyDoc.Undo.IsDirty);
dirtyDoc.AddControlAt("Label", new Point(50, 50));
@@ -31,6 +31,7 @@ public static class ConfigLoader
config.Provider = root["His:Provider"] ?? "Oracle";
config.SaveMode = root["FormStore:SaveMode"] ?? "File";
config.XmlFolder = root["FormStore:XmlFolder"] ?? "forms";
config.DevUidCod = root["His:DevUidCod"] ?? string.Empty;
return config;
}
@@ -0,0 +1,71 @@
using SheetMe.Data.Stores;
namespace SheetMe.Designer.Services;
/// <summary>
/// 기동 시 사용자 세션 확정. 레거시 frmSheetDesigner.vb:892 와 동일하게
/// "인자로 받은 UidCod 가 유효하지 않으면 기동 중단" 이다 — 호출자가 HIS 이므로
/// 조용히 읽기 전용으로 떨어뜨리면 사용자가 저장이 안 되는 이유를 알 수 없다.
/// </summary>
public static class SessionBootstrap
{
#region Methods
/// <summary>
/// 세션 초기화. 진행 가능하면 true, 기동을 중단해야 하면 false 와 함께 사유를 돌려준다.
///
/// 인자에 UidCod 있음 → 조회 성공 시 정상 세션 / 실패 시 기동 중단.
/// 인자 없음 → 개발 폴백(His:DevUidCod)을 시도하되 실패해도 미인증으로 계속(DB 쓰기만 차단).
/// 접속 문자열이 없으면(파일 전용 모드) 조회 자체를 건너뛴다.
/// </summary>
public static bool TryInitialize(HisLaunch? launch, string connectionString, string devUidCod, out string error)
{
error = string.Empty;
var requestedUid = launch?.UidCod ?? string.Empty;
if (connectionString.Length == 0)
{
// DB 접속 자체가 설정되지 않음 — 파일 전용 모드. 사용자 확인은 의미가 없다.
UserSession.Initialize(HisUser.Unknown);
return true;
}
if (requestedUid.Length == 0)
{
// 단독 실행: 개발 폴백만 시도하고, 실패해도 기동은 계속한다(DB 쓰기는 차단됨)
if (devUidCod.Length > 0)
{
UserSession.Initialize(SafeResolve(connectionString, devUidCod) ?? HisUser.Unknown);
}
else
{
UserSession.Initialize(HisUser.Unknown);
}
return true;
}
var user = SafeResolve(connectionString, requestedUid);
if (user is null)
{
error = $"사용자 [{requestedUid}] 를 확인할 수 없습니다.\n" +
"HIS 사용자 코드가 올바른지, 사용 기간이 유효한지 확인해 주세요.";
return false;
}
UserSession.Initialize(user);
return true;
}
/// <summary>조회 실패(접속 오류 등)를 null 로 흡수 — 기동 경로에서 예외로 죽지 않게</summary>
private static HisUser? SafeResolve(string connectionString, string uidCod)
{
try
{
return new UserContextStore(connectionString).Resolve(uidCod);
}
catch
{
return null;
}
}
#endregion
}
@@ -0,0 +1,38 @@
namespace SheetMe.Designer.Services;
/// <summary>HIS 기동 인자 — 레거시 규약 <c>"UidCod,ComNum,ShtCod"</c> (콤마 구분 단일 argv)</summary>
/// <param name="UidCod">HIS 사용자 코드 — 감사 컬럼의 출처</param>
/// <param name="ComNum">내원번호 — 레거시에서도 선언·대입만 있고 사용처가 없는 죽은 인자. 형상 보존용</param>
/// <param name="ShtCod">기동 시 자동으로 열 서식 코드</param>
/// <param name="Raw">원문 — 단일 인스턴스 포워딩에 그대로 전달</param>
public sealed record HisLaunch(string UidCod, int? ComNum, string ShtCod, string Raw);
/// <summary>
/// 기동 인자 파싱. 레거시 frmSheetDesigner.vb:877 의 관용 파싱
/// (<c>(Command() &amp; "".PadRight(10, ",")).Split(",")</c>)을 이식했다 —
/// 필드가 모자라도 인덱스가 보장되므로 인자 형상이 변해도 깨지지 않는다.
/// </summary>
public static class StartupArguments
{
#region Methods
/// <summary>진단 플래그 기동인지 — 첫 인자가 "--" 로 시작</summary>
public static bool IsDiagnostic(string[] args)
=> args.Length > 0 && args[0].StartsWith("--", StringComparison.Ordinal);
/// <summary>HIS 기동 인자 — 진단 모드이거나 인자가 없으면 null</summary>
public static HisLaunch? Parse(string[] args)
=> IsDiagnostic(args) || args.Length == 0 ? null : ParseRaw(string.Join(" ", args));
/// <summary>
/// 원문 파싱 — 단일 인스턴스 포워딩 수신부와 공용.
/// ComNum 은 레거시가 CInt 로 변환하다 비숫자에서 예외를 냈지만, 죽은 값 때문에 기동이
/// 실패하면 안 되므로 여기서는 TryParse 로 관용 처리한다.
/// </summary>
public static HisLaunch ParseRaw(string raw)
{
var parts = ((raw ?? string.Empty) + new string(',', 10)).Split(',');
var comNum = int.TryParse(parts[1].Trim(), out var n) ? n : (int?)null;
return new HisLaunch(parts[0].Trim(), comNum, parts[2].Trim(), raw ?? string.Empty);
}
#endregion
}
@@ -0,0 +1,54 @@
using SheetMe.Data.Stores;
namespace SheetMe.Designer.Services;
/// <summary>
/// 프로세스 수명 사용자 세션 — 레거시 <c>clsCommonLib.GetInstance.UidMst</c> 싱글턴의 대응물.
///
/// 생성자 주입 대신 정적으로 둔 이유: MainViewModel 이 XAML DataContext 로 무인자 생성되고,
/// 무엇보다 진단 모드(--db-*)는 ViewModel 그래프 밖이라 주입으로는 감사 컬럼 기록 지점에
/// 도달할 수 없다. 대신 이 정적을 읽는 곳을 App 초기화 / FormDesignDataBusiness /
/// MainViewModel 표시 세 군데로 제한하고, 저장소는 지금처럼 uid 를 파라미터로만 받는다.
/// </summary>
public static class UserSession
{
#region Member Fields
/// <summary>진단 모드 감사 식별자 — DB 에서 즉시 구분되도록(개발자 윈도우 계정명 대신)</summary>
private const string DiagnosticUid = "SMOKE";
#endregion
#region Properties
/// <summary>현재 세션 사용자 — 초기화 전/미인증이면 <see cref="HisUser.Unknown"/></summary>
public static HisUser Current { get; private set; } = HisUser.Unknown;
/// <summary>HIS 사용자로 확인되었는지 — DB 쓰기의 전제</summary>
public static bool IsAuthenticated => Current.IsAuthenticated;
/// <summary>진단 모드 세션인지</summary>
public static bool IsDiagnostic { get; private set; }
/// <summary>
/// 감사 컬럼에 기록할 사용자 코드. 미인증 상태에서 접근하면 예외 —
/// DB 쓰기는 그 전에 <see cref="IsAuthenticated"/> 로 차단되어야 한다.
/// </summary>
public static string AuditUid => IsAuthenticated
? Current.UidCod
: throw new InvalidOperationException("사용자 컨텍스트가 없어 DB 쓰기를 수행할 수 없습니다.");
#endregion
#region Methods
/// <summary>세션 확정 — 기동 시 1회</summary>
public static void Initialize(HisUser user)
{
Current = user;
IsDiagnostic = false;
}
/// <summary>진단 모드 식별자 부여 — --db-* 스모크 전용</summary>
public static void InitializeDiagnostic()
{
Current = new HisUser(DiagnosticUid, "진단", string.Empty, string.Empty, string.Empty, string.Empty);
IsDiagnostic = true;
}
#endregion
}
@@ -285,14 +285,60 @@ internal sealed class MainViewModel : ViewModelBase
/// <summary>이미 열린 DB 서식이면 해당 탭 활성화 — 없으면 null</summary>
private DesignerViewModel? FindOpenDbDocument(string shtCod)
=> OpenDesigners.FirstOrDefault(d => d.IsFromDb && d.Document.FormId == shtCod);
/// <summary>
/// 서식생성기 사용 권한 확인(E_ShtMst.ShtUsrDesYon) — 레거시 DisplaySheetDesign 게이트 이식.
/// 거부 메시지는 레거시 문구를 그대로 재현한다(사용자 재교육 비용 0, 조치 안내가 이미 담겨 있다).
/// </summary>
private bool EnsureDesignAllowed(string shtCod)
{
try
{
switch (dataBusiness.GetDesignPermission(shtCod))
{
case SheetDesignPermission.Allowed:
return true;
case SheetDesignPermission.NotRegistered:
MessageBox.Show($"기록지 정보에 등록되지 않은 서식 코드입니다. ({shtCod})", "확인",
MessageBoxButton.OK, MessageBoxImage.Information);
return false;
default:
MessageBox.Show("서식생성기를 사용하지 않는 서식지입니다.\n기록지 정보를 확인해주세요.", "확인",
MessageBoxButton.OK, MessageBoxImage.Information);
return false;
}
}
catch (Exception ex)
{
MessageBox.Show($"서식 권한을 확인하지 못했습니다.\n\n{ex.Message}", "확인",
MessageBoxButton.OK, MessageBoxImage.Warning);
return false;
}
}
#endregion
#region Methods - /
/// <summary>기동 인자로 받은 서식 코드 — 창 로드 후 자동으로 연다(App.OnStartup 이 세팅)</summary>
public string? PendingSheetCode { get; set; }
private async Task OnLoadedAsync()
{
await LoadSheetListAsync();
// 기동 인자로 서식을 지정했으면 빈 새 문서를 만들지 않는다 — 레거시도 해당 서식만 연다.
// 열기에 실패하면(디자인 없음/권한 거부/코드 오타) 빈 문서로 폴백해 앱이 빈 껍데기가 되지 않게 한다.
if (PendingSheetCode is { Length: > 0 } shtCod)
{
PendingSheetCode = null;
OpenDbSheet(shtCod);
if (OpenDesigners.Count > 0)
{
return;
}
}
OnNewFile();
SelectedLeftTabIndex = 0; // 시작 화면은 서식 목록 탭(초기 빈 문서로 레이어 탭 전환되는 것 되돌림)
await LoadSheetListAsync();
}
private void OnNewFile()
@@ -404,7 +450,11 @@ internal sealed class MainViewModel : ViewModelBase
}
}
/// <summary>DB 서식 열기 공통 — 이미 열려 있으면 탭 활성화</summary>
/// <summary>
/// DB 서식 열기 공통 — 이미 열려 있으면 탭 활성화.
/// 모든 열기 경로(대화상자·목록 더블클릭·기동 인자)가 여기로 수렴하므로 권한 게이트를 여기 둔다.
/// 레거시는 게이트가 진입점마다 흩어져 있어 이력 노드 클릭·MessageQueue 두 경로로 우회가 가능했다.
/// </summary>
private void OpenDbSheet(string shtCod)
{
var existing = FindOpenDbDocument(shtCod);
@@ -415,6 +465,11 @@ internal sealed class MainViewModel : ViewModelBase
return;
}
if (!EnsureDesignAllowed(shtCod))
{
return;
}
var document = dataBusiness.OpenFromDb(shtCod);
if (document is null)
{
@@ -444,10 +499,25 @@ internal sealed class MainViewModel : ViewModelBase
"DB 저장", MessageBoxButton.OK, MessageBoxImage.Warning);
return false;
}
if (!dataBusiness.CanWriteDb)
{
MessageBox.Show("HIS 사용자가 확인되지 않아 DB에 저장할 수 없습니다.\n" +
"기록지정보 화면에서 서식생성기를 실행하거나, 사용자 코드를 인자로 전달해 주세요.\n" +
"(저장 이력에 남길 수정자를 특정할 수 없습니다)",
"DB 저장", MessageBoxButton.OK, MessageBoxImage.Warning);
return false;
}
CommitPendingEdits();
var document = CurrentDesigner.Document;
// 열기 이후 마스터에서 플래그가 꺼졌을 수 있고, 파일→DB 저장은 열기 게이트를 아예 거치지 않는다.
// 레거시는 저장 경로에 게이트가 없어 이 구멍이 열려 있었다.
if (dataBusiness.SheetExists(document.FormId) && !EnsureDesignAllowed(document.FormId))
{
return false;
}
// 미등록 서식이면 신규 등록 다이얼로그
if (!dataBusiness.SheetExists(document.FormId))
{
@@ -661,6 +731,13 @@ internal sealed class MainViewModel : ViewModelBase
return;
}
// 파일에서 연 문서의 FormId 가 우연히 거부 서식 코드와 같으면 이 경로로 이력 열람·복원이
// 가능해진다(레거시의 우회 경로 중 하나였다).
if (!EnsureDesignAllowed(document.FormId))
{
return;
}
var versions = dataBusiness.ListVersions(document.FormId);
if (versions.Count == 0)
{
@@ -729,7 +806,8 @@ internal sealed class MainViewModel : ViewModelBase
return;
}
new Views.RecordWordDialogView(dataBusiness.RecordWords(), document.FormId, document.Title)
new Views.RecordWordDialogView(dataBusiness.RecordWords(), document.FormId, document.Title,
dataBusiness.User.UidCod, dataBusiness.CanWriteDb)
{
Owner = Application.Current.MainWindow,
}.ShowDialog();
+20
View File
@@ -278,6 +278,22 @@
<StackPanel Orientation="Horizontal" Margin="1">
<TextBlock Text="{Binding ShtCod}" FontSize="11" Foreground="{DynamicResource B.Muted}"
Width="52" VerticalAlignment="Center"/>
<!-- 서식생성기 사용이 꺼진 서식(ShtUsrDesYon='N') — 열기 시 거부된다 -->
<TextBlock Text="&#xE72E;" FontFamily="Segoe MDL2 Assets" FontSize="10"
VerticalAlignment="Center" Margin="0,0,4,0"
Foreground="{DynamicResource B.Muted}"
ToolTip="기록지 정보에서 서식생성기 사용이 꺼져 있습니다">
<TextBlock.Style>
<Style TargetType="TextBlock" BasedOn="{StaticResource {x:Type TextBlock}}">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<DataTrigger Binding="{Binding DesignAllowed}" Value="False">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<TextBlock Text="{Binding Name}" FontSize="12" TextTrimming="CharacterEllipsis">
<TextBlock.Style>
<Style TargetType="TextBlock" BasedOn="{StaticResource {x:Type TextBlock}}">
@@ -286,6 +302,10 @@
<Setter Property="Foreground" Value="{DynamicResource B.Muted}"/>
<Setter Property="Opacity" Value="0.6"/>
</DataTrigger>
<DataTrigger Binding="{Binding DesignAllowed}" Value="False">
<Setter Property="Foreground" Value="{DynamicResource B.Muted}"/>
<Setter Property="Opacity" Value="0.6"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
@@ -13,23 +13,45 @@ public partial class RecordWordDialogView : Window
#region Member Fields
private readonly RecordWordStore store;
private readonly string shtCod;
private readonly string auditUid;
private readonly bool canWrite;
private readonly ObservableCollection<RecordWordInfo> words = new();
#endregion
#region Constructors
public RecordWordDialogView(RecordWordStore store, string shtCod, string sheetTitle)
/// <param name="auditUid">감사 컬럼(ShtUpdUid/ShtFstUid)에 기록할 HIS 사용자 코드</param>
/// <param name="canWrite">DB 쓰기 가능 여부 — 미인증/게이트 차단이면 읽기 전용으로 연다</param>
public RecordWordDialogView(RecordWordStore store, string shtCod, string sheetTitle, string auditUid, bool canWrite)
{
this.store = store;
this.shtCod = shtCod;
this.auditUid = auditUid;
this.canWrite = canWrite;
InitializeComponent();
HeaderText.Text = $"[{shtCod}] {sheetTitle} — 작성 화면의 상용구 팝업에 표시되는 문구입니다. " +
"(텍스트박스의 '상용구 사용'이 켜져 있어야 합니다)";
"(텍스트박스의 '상용구 사용'이 켜져 있어야 합니다)" +
(canWrite ? string.Empty : "\n※ HIS 사용자가 확인되지 않아 읽기 전용입니다.");
WordList.ItemsSource = words;
Loaded += (_, _) => Reload();
NewWordBox.Loaded += (_, _) => NewWordBox.Focus();
}
#endregion
#region Methods -
/// <summary>쓰기 전 확인 — 미인증이면 안내 후 차단(감사 컬럼에 남길 주체가 없다)</summary>
private bool EnsureWritable()
{
if (canWrite)
{
return true;
}
MessageBox.Show("HIS 사용자가 확인되지 않아 상용구를 변경할 수 없습니다.\n" +
"기록지정보 화면에서 서식생성기를 실행해 주세요.",
"상용구 관리", MessageBoxButton.OK, MessageBoxImage.Warning);
return false;
}
#endregion
#region Methods
private void Reload()
{
@@ -51,13 +73,13 @@ public partial class RecordWordDialogView : Window
private void OnAdd(object sender, RoutedEventArgs e)
{
var value = NewWordBox.Text.Trim();
if (value.Length == 0)
if (value.Length == 0 || !EnsureWritable())
{
return;
}
try
{
store.Add(shtCod, value, Environment.UserName);
store.Add(shtCod, value, auditUid);
NewWordBox.Clear();
NewWordBox.Focus();
Reload();
@@ -72,7 +94,7 @@ public partial class RecordWordDialogView : Window
private void OnEdit(object sender, RoutedEventArgs e)
{
if (WordList.SelectedItem is not RecordWordInfo selected)
if (WordList.SelectedItem is not RecordWordInfo selected || !EnsureWritable())
{
return;
}
@@ -89,7 +111,7 @@ public partial class RecordWordDialogView : Window
}
try
{
store.Update(selected.Key, newValue, Environment.UserName);
store.Update(selected.Key, newValue, auditUid);
Reload();
}
catch (Exception ex)
@@ -101,7 +123,7 @@ public partial class RecordWordDialogView : Window
private void OnRemove(object sender, RoutedEventArgs e)
{
if (WordList.SelectedItem is not RecordWordInfo selected)
if (WordList.SelectedItem is not RecordWordInfo selected || !EnsureWritable())
{
return;
}
@@ -112,7 +134,7 @@ public partial class RecordWordDialogView : Window
}
try
{
store.Remove(selected.Key);
store.Remove(selected.Key, auditUid);
Reload();
}
catch (Exception ex)
@@ -130,14 +152,14 @@ public partial class RecordWordDialogView : Window
{
var index = WordList.SelectedIndex;
var target = index + delta;
if (index < 0 || target < 0 || target >= words.Count)
if (index < 0 || target < 0 || target >= words.Count || !EnsureWritable())
{
return;
}
try
{
words.Move(index, target);
store.Reorder(words.Select(w => w.Key).ToList(), Environment.UserName);
store.Reorder(words.Select(w => w.Key).ToList(), auditUid);
WordList.SelectedIndex = target;
}
catch (Exception ex)