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
+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)