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:
co-authored by
Claude Fable 5
parent
5c97bf1440
commit
0f77cb717a
@@ -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("저장 후 활성 디자인 조회 실패");
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user