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
+7
View File
@@ -18,6 +18,13 @@ public sealed class DataConfig
/// <summary>파일 모드 서식 폴더</summary> /// <summary>파일 모드 서식 폴더</summary>
public string XmlFolder { get; set; } = "forms"; public string XmlFolder { get; set; } = "forms";
/// <summary>
/// 개발용 HIS 사용자 코드 — 기동 인자 없이 실행할 때만 시도하는 폴백.
/// 운영 배포본의 appsettings.json 에는 이 키가 없으므로 자동으로 비활성이다.
/// 여기 담는 것은 설정값(UidCod 문자열)뿐이며, 해석된 사용자 정보는 담지 않는다.
/// </summary>
public string DevUidCod { get; set; } = string.Empty;
#endregion #endregion
#region Methods #region Methods
@@ -5,7 +5,21 @@ using SheetMe.Core.Serialization;
namespace SheetMe.Data.Stores; namespace SheetMe.Data.Stores;
/// <summary>서식 목록 1건(열기 대화상자)</summary> /// <summary>서식 목록 1건(열기 대화상자)</summary>
public sealed record SheetSummary(string ShtCod, string Name, bool HasDesign); /// <param name="DesignAllowed">서식생성기 사용 허용(E_ShtMst.ShtUsrDesYon) — 목록 잠금 표시용</param>
public sealed record SheetSummary(string ShtCod, string Name, bool HasDesign, bool DesignAllowed);
/// <summary>서식생성기 사용 권한 판정 결과</summary>
public enum SheetDesignPermission
{
/// <summary>E_ShtMst 에 서식 코드가 없음</summary>
NotRegistered,
/// <summary>허용 — ShtUsrDesYon 이 'Y' 또는 미설정</summary>
Allowed,
/// <summary>거부 — ShtUsrDesYon 이 'N' 등</summary>
Denied,
}
/// <summary>디자인 버전 1건</summary> /// <summary>디자인 버전 1건</summary>
public sealed record DesignVersionInfo(decimal SdgKey, string UpdDtm, string UpdUid, bool Deleted); public sealed record DesignVersionInfo(decimal SdgKey, string UpdDtm, string UpdUid, bool Deleted);
@@ -54,7 +68,8 @@ public sealed class OracleLegacyFormStore
command.CommandText = command.CommandText =
"SELECT * FROM (" + "SELECT * FROM (" +
" SELECT s.ShtCod, s.ShtKorNam," + " SELECT s.ShtCod, s.ShtKorNam," +
" (SELECT COUNT(*) FROM E_SdgMst d WHERE d.SdgShtCod = s.ShtCod AND NVL(d.SdgDelYon,' ') <> 'Y') AS DesignCount" + " (SELECT COUNT(*) FROM E_SdgMst d WHERE d.SdgShtCod = s.ShtCod AND NVL(d.SdgDelYon,' ') <> 'Y') AS DesignCount," +
" NVL(TRIM(s.ShtUsrDesYon),' ') AS UsrDesYon" +
$" FROM E_ShtMst s {where} ORDER BY s.ShtCod" + $" FROM E_ShtMst s {where} ORDER BY s.ShtCod" +
$") WHERE ROWNUM <= {max}"; $") WHERE ROWNUM <= {max}";
@@ -65,11 +80,36 @@ public sealed class OracleLegacyFormStore
result.Add(new SheetSummary( result.Add(new SheetSummary(
reader.GetString(0), reader.GetString(0),
reader.IsDBNull(1) ? string.Empty : reader.GetString(1), reader.IsDBNull(1) ? string.Empty : reader.GetString(1),
reader.GetDecimal(2) > 0)); reader.GetDecimal(2) > 0,
IsDesignAllowed(reader.GetString(3))));
} }
return result; return result;
} }
/// <summary>
/// 서식생성기 사용 허용 여부(E_ShtMst.ShtUsrDesYon) — 레거시 frmSheetDesigner.vb:71-84 규칙.
/// 'Y' 또는 빈 문자열/NULL(미설정 = 레거시 데이터 호환)이면 허용, 그 외('N' 등)만 거부한다.
/// </summary>
public SheetDesignPermission GetDesignPermission(string shtCod)
{
using var connection = Open();
var value = Scalar(connection, null,
"SELECT NVL(TRIM(ShtUsrDesYon),' ') FROM E_ShtMst WHERE ShtCod = :s",
new OracleParameter("s", shtCod)) as string;
if (value is null)
{
return SheetDesignPermission.NotRegistered;
}
return IsDesignAllowed(value) ? SheetDesignPermission.Allowed : SheetDesignPermission.Denied;
}
/// <summary>ShtUsrDesYon 판정 — CHAR 패딩을 흡수하려 TRIM 후 비교(레거시보다 관대한 방향이라 오탐만 줄인다)</summary>
private static bool IsDesignAllowed(string flag)
{
var trimmed = flag.Trim();
return trimmed is "Y" or "";
}
/// <summary>활성(비삭제 최신) 디자인 로드 — 없으면 null. FormId/Title/SourceSdgKey 채움</summary> /// <summary>활성(비삭제 최신) 디자인 로드 — 없으면 null. FormId/Title/SourceSdgKey 채움</summary>
public FormDocument? LoadActiveDesign(string shtCod) public FormDocument? LoadActiveDesign(string shtCod)
{ {
@@ -214,12 +254,15 @@ public sealed class OracleLegacyFormStore
} }
var mergedXml = serializer.Write(document); var mergedXml = serializer.Write(document);
var now = DateTime.Now.ToString("yyyyMMddHHmm"); // 레거시 SystemDateTime 포맷(12자리)
using var connection = Open(); using var connection = Open();
using var transaction = connection.BeginTransaction(); using var transaction = connection.BeginTransaction();
try try
{ {
// 레거시 SystemDateTime 과 동일하게 DB 서버 시각을 쓴다 — 단말 시계가 밀리면
// ResolveActiveSdgKey 의 ORDER BY SdgUpdDtm 이 활성 버전을 잘못 고를 수 있다.
var now = ServerClock.Read(connection, transaction).Minute12;
var shtCneYon = Scalar(connection, transaction, var shtCneYon = Scalar(connection, transaction,
"SELECT NVL(ShtCneYon,' ') FROM E_ShtMst WHERE ShtCod = :s", "SELECT NVL(ShtCneYon,' ') FROM E_ShtMst WHERE ShtCod = :s",
new OracleParameter("s", document.FormId)) as string; new OracleParameter("s", document.FormId)) as string;
@@ -302,17 +345,19 @@ public sealed class OracleLegacyFormStore
using var transaction = connection.BeginTransaction(); using var transaction = connection.BeginTransaction();
try try
{ {
var stamp = ServerClock.Read(connection, transaction);
// ShtUsrDesYon='Y' 하드코딩 — 여기서 만든 서식은 서식생성기가 만든 것이므로 열기 게이트를 자동 통과한다
NonQuery(connection, transaction, NonQuery(connection, transaction,
"INSERT INTO E_ShtMst (ShtCod, ShtAdpDte, ShtKorNam, ShtClsCod, ShtTyp, ShtUseYon, " + "INSERT INTO E_ShtMst (ShtCod, ShtAdpDte, ShtKorNam, ShtClsCod, ShtTyp, ShtUseYon, " +
"ShtUsrDesYon, ShtUseSdg, ShtUseSct, ShtUseEmr, ShtRedAth, ShtWrtTyp, ShtCneYon, ShtTraSto, " + "ShtUsrDesYon, ShtUseSdg, ShtUseSct, ShtUseEmr, ShtRedAth, ShtWrtTyp, ShtCneYon, ShtTraSto, " +
"ShtEleSig, ShtUpdUid, ShtUpdDtm) " + "ShtEleSig, ShtUpdUid, ShtUpdDtm) " +
"VALUES (:cod, :adp, :nam, :cls, 'D', 'Y', 'Y', 'Y', 'Y', 'Y', 'A', '9A', 'N', 'N', 'N', :uid, :dtm)", "VALUES (:cod, :adp, :nam, :cls, 'D', 'Y', 'Y', 'Y', 'Y', 'Y', 'A', '9A', 'N', 'N', 'N', :uid, :dtm)",
new OracleParameter("cod", shtCod), new OracleParameter("cod", shtCod),
new OracleParameter("adp", DateTime.Now.ToString("yyyyMMdd")), new OracleParameter("adp", stamp.Date8),
new OracleParameter("nam", korName), new OracleParameter("nam", korName),
new OracleParameter("cls", string.IsNullOrWhiteSpace(clsCod) ? (object)DBNull.Value : clsCod), new OracleParameter("cls", string.IsNullOrWhiteSpace(clsCod) ? (object)DBNull.Value : clsCod),
new OracleParameter("uid", uid), new OracleParameter("uid", uid),
new OracleParameter("dtm", DateTime.Now.ToString("yyyyMMddHHmm"))); new OracleParameter("dtm", stamp.Minute12));
transaction.Commit(); transaction.Commit();
} }
catch catch
+19 -10
View File
@@ -1,4 +1,4 @@
using Oracle.ManagedDataAccess.Client; using Oracle.ManagedDataAccess.Client;
namespace SheetMe.Data.Stores; namespace SheetMe.Data.Stores;
@@ -54,6 +54,8 @@ public sealed class RecordWordStore
using var transaction = connection.BeginTransaction(); using var transaction = connection.BeginTransaction();
try try
{ {
// 서버 시각 1회 — RETURNING 경로와 폴백 경로가 같은 값을 쓰게 한다
var stamp = ServerClock.Read(connection, transaction).Milli17;
var seq = Convert.ToDecimal(Scalar(connection, transaction, var seq = Convert.ToDecimal(Scalar(connection, transaction,
"SELECT NVL(MAX(ShtDspSeq),0)+1 FROM E_SHTWRDMST WHERE ShtCod = :s AND ShtStpLvl = 1", "SELECT NVL(MAX(ShtDspSeq),0)+1 FROM E_SHTWRDMST WHERE ShtCod = :s AND ShtStpLvl = 1",
new OracleParameter("s", shtCod))!); new OracleParameter("s", shtCod))!);
@@ -74,7 +76,7 @@ public sealed class RecordWordStore
command.Parameters.Add(new OracleParameter("v", value)); command.Parameters.Add(new OracleParameter("v", value));
command.Parameters.Add(new OracleParameter("q", seq)); command.Parameters.Add(new OracleParameter("q", seq));
command.Parameters.Add(new OracleParameter("u", uid)); command.Parameters.Add(new OracleParameter("u", uid));
command.Parameters.Add(new OracleParameter("d", Now17())); command.Parameters.Add(new OracleParameter("d", stamp));
var outKey = new OracleParameter("outKey", OracleDbType.Decimal, System.Data.ParameterDirection.Output); var outKey = new OracleParameter("outKey", OracleDbType.Decimal, System.Data.ParameterDirection.Output);
command.Parameters.Add(outKey); command.Parameters.Add(outKey);
command.ExecuteNonQuery(); command.ExecuteNonQuery();
@@ -94,7 +96,7 @@ public sealed class RecordWordStore
new OracleParameter("v", value), new OracleParameter("v", value),
new OracleParameter("q", seq), new OracleParameter("q", seq),
new OracleParameter("u", uid), new OracleParameter("u", uid),
new OracleParameter("d", Now17())); new OracleParameter("d", stamp));
} }
transaction.Commit(); transaction.Commit();
@@ -111,20 +113,28 @@ public sealed class RecordWordStore
public void Update(decimal key, string value, string uid) public void Update(decimal key, string value, string uid)
{ {
using var connection = Open(); using var connection = Open();
var stamp = ServerClock.Read(connection, null).Milli17;
using var command = connection.CreateCommand(); using var command = connection.CreateCommand();
command.BindByName = true; command.BindByName = true;
command.CommandText = command.CommandText =
"UPDATE E_SHTWRDMST SET ShtWrdVal = :v, ShtUpdUid = :u, ShtUpdDtf = :d WHERE ShtWrdKey = :k"; "UPDATE E_SHTWRDMST SET ShtWrdVal = :v, ShtUpdUid = :u, ShtUpdDtf = :d WHERE ShtWrdKey = :k";
command.Parameters.Add(new OracleParameter("v", value)); command.Parameters.Add(new OracleParameter("v", value));
command.Parameters.Add(new OracleParameter("u", uid)); command.Parameters.Add(new OracleParameter("u", uid));
command.Parameters.Add(new OracleParameter("d", Now17())); command.Parameters.Add(new OracleParameter("d", stamp));
command.Parameters.Add(new OracleParameter("k", key)); command.Parameters.Add(new OracleParameter("k", key));
command.ExecuteNonQuery(); command.ExecuteNonQuery();
} }
/// <summary>상용구 삭제(하위 단계 포함)</summary> /// <summary>
public void Remove(decimal key) /// 상용구 삭제(하위 단계 포함) — 물리 DELETE.
/// auditUid 를 DB 에 남길 곳은 없다: 레거시 런타임 조회 쿼리(dtRecWordMst.vb)에 ShtStt 필터가 없어
/// 논리 삭제로 바꾸면 지운 문구가 EMR 작성 화면에 계속 노출된다. 그럼에도 인자로 받는 이유는
/// "모든 쓰기 API 는 uid 를 명시한다"는 규약을 컴파일 타임에 강제해, 나중에 uid 없는 경로가
/// 새로 생기지 않게 하기 위해서다.
/// </summary>
public void Remove(decimal key, string auditUid)
{ {
_ = auditUid;
using var connection = Open(); using var connection = Open();
using var transaction = connection.BeginTransaction(); using var transaction = connection.BeginTransaction();
try try
@@ -149,13 +159,15 @@ public sealed class RecordWordStore
using var transaction = connection.BeginTransaction(); using var transaction = connection.BeginTransaction();
try try
{ {
// 루프 밖에서 1회 — 한 번의 순서 변경이 행마다 다른 시각을 남기지 않게 한다
var stamp = ServerClock.Read(connection, transaction).Milli17;
for (var i = 0; i < orderedKeys.Count; i++) for (var i = 0; i < orderedKeys.Count; i++)
{ {
NonQuery(connection, transaction, NonQuery(connection, transaction,
"UPDATE E_SHTWRDMST SET ShtDspSeq = :q, ShtUpdUid = :u, ShtUpdDtf = :d WHERE ShtWrdKey = :k", "UPDATE E_SHTWRDMST SET ShtDspSeq = :q, ShtUpdUid = :u, ShtUpdDtf = :d WHERE ShtWrdKey = :k",
new OracleParameter("q", (decimal)(i + 1)), new OracleParameter("q", (decimal)(i + 1)),
new OracleParameter("u", uid), new OracleParameter("u", uid),
new OracleParameter("d", Now17()), new OracleParameter("d", stamp),
new OracleParameter("k", orderedKeys[i])); new OracleParameter("k", orderedKeys[i]));
} }
transaction.Commit(); transaction.Commit();
@@ -174,9 +186,6 @@ public sealed class RecordWordStore
return connection; return connection;
} }
/// <summary>SHTUPDDTF 17자리(yyyyMMddHHmmssfff)</summary>
private static string Now17() => DateTime.Now.ToString("yyyyMMddHHmmssfff");
private static object? Scalar(OracleConnection connection, OracleTransaction transaction, string sql, params OracleParameter[] parameters) private static object? Scalar(OracleConnection connection, OracleTransaction transaction, string sql, params OracleParameter[] parameters)
{ {
using var command = connection.CreateCommand(); using var command = connection.CreateCommand();
+50
View File
@@ -0,0 +1,50 @@
using System.Globalization;
using Oracle.ManagedDataAccess.Client;
namespace SheetMe.Data.Stores;
/// <summary>
/// 감사 컬럼 시각의 유일한 출처 = DB 서버 시각(레거시 <c>clsCommonLib.SystemDateTime</c> 동형 —
/// 레거시도 <c>SELECT Sysdate FROM DUAL</c> 로 서버 시각을 받아 클라이언트에서 포맷한다).
///
/// 단순한 감사 정확성 문제가 아니다. <c>ResolveActiveSdgKey</c> 가
/// <c>ORDER BY SdgUpdDtm DESC, SdgKey DESC</c> 로 활성 버전을 고르므로,
/// PC 시계가 뒤로 밀린 단말에서 저장하면 활성 버전 판정이 뒤집힐 수 있다.
/// </summary>
internal static class ServerClock
{
#region Types
/// <summary>서버 시각 1회 조회 결과 — 레거시가 쓰는 3가지 포맷을 함께 제공</summary>
internal readonly record struct Stamp(DateTime Value)
{
/// <summary>yyyyMMdd (8자리) — ShtAdpDte 류</summary>
public string Date8 => Value.ToString("yyyyMMdd", CultureInfo.InvariantCulture);
/// <summary>yyyyMMddHHmm (12자리) — SdgUpdDtm/ShtUpdDtm</summary>
public string Minute12 => Value.ToString("yyyyMMddHHmm", CultureInfo.InvariantCulture);
/// <summary>yyyyMMddHHmmssfff (17자리) — 상용구 ShtUpdDtf/ShtFstDtf</summary>
public string Milli17 => Value.ToString("yyyyMMddHHmmssfff", CultureInfo.InvariantCulture);
}
#endregion
#region Methods
/// <summary>
/// 서버 시각 조회 — 왕복 1회로 3포맷을 확보한다.
/// 포맷 마스크를 SQL 에 두지 않고 값만 받아 invariant 로 포맷해 NLS 설정 의존을 없앤다.
/// 실패 시 폴백하지 않는다 — 커넥션이 죽은 것이고 뒤따르는 DML 도 어차피 실패한다.
/// </summary>
public static Stamp Read(OracleConnection connection, OracleTransaction? transaction)
{
using var command = connection.CreateCommand();
if (transaction is not null)
{
command.Transaction = transaction;
}
command.CommandText = "SELECT SYSTIMESTAMP FROM DUAL";
using var reader = command.ExecuteReader();
reader.Read();
return new Stamp(reader.GetDateTime(0));
}
#endregion
}
@@ -0,0 +1,85 @@
using Oracle.ManagedDataAccess.Client;
namespace SheetMe.Data.Stores;
/// <summary>
/// HIS 로그인 사용자 — 감사 컬럼(SdgUidCod/ShtUpdUid 등)에 기록되는 UidCod 의 유일한 출처.
/// 부서·병원은 표시 전용이며 없어도 인증은 성립한다(레거시 bzLogIn 과 동일 의미론).
/// </summary>
public sealed record HisUser(
string UidCod, string UidNam,
string DepCod, string DepNam,
string HspCod, string HspNam)
{
/// <summary>미인증 세션(인자 없이 단독 실행) — DB 쓰기가 차단된다</summary>
public static readonly HisUser Unknown = new(string.Empty, string.Empty, string.Empty, string.Empty, string.Empty, string.Empty);
/// <summary>HIS 사용자로 확인되었는지</summary>
public bool IsAuthenticated => UidCod.Length > 0;
/// <summary>표시용 — "홍길동(MSYS)" 형태, 미인증이면 안내 문구</summary>
public string Display => IsAuthenticated
? (UidNam.Length > 0 ? $"{UidNam}({UidCod})" : UidCod)
: "사용자 미확인";
}
/// <summary>
/// 사용자 컨텍스트 조회 — 레거시 bzLogIn.LogIn(uid, autoLogin) 의 신규 대응물.
/// 레거시도 자동 로그인에서는 비밀번호를 확인하지 않고 M_UidMst 행 존재 + 유효기간만 판정한다
/// (daLogIn.vb: SELECT * FROM M_UidMst WHERE UidCod=:u AND :adp BETWEEN UidStrDte AND UidEndDte).
/// </summary>
public sealed class UserContextStore
{
#region Member Fields
private readonly string connectionString;
#endregion
#region Constructors
public UserContextStore(string connectionString)
{
this.connectionString = connectionString;
}
#endregion
#region Methods
/// <summary>
/// UidCod 검증 + 표시 정보 조회 — 미존재이거나 유효기간 밖이면 null.
/// 적용일자는 서버 시각을 쓴다(단말 시계가 밀리면 유효 계정이 거부될 수 있다).
/// 부서·병원은 LEFT JOIN 이라 마스터에 행이 없어도 인증 자체는 성립한다.
/// </summary>
public HisUser? Resolve(string uidCod)
{
if (string.IsNullOrWhiteSpace(uidCod))
{
return null;
}
using var connection = new OracleConnection(connectionString);
connection.Open();
using var command = connection.CreateCommand();
command.BindByName = true;
command.CommandText =
"SELECT u.UidCod, NVL(u.UidNam,' '), " +
" NVL(u.UidDepCod,' '), NVL(d.DepKorNam,' '), " +
" NVL(u.UidHspCod,' '), NVL(h.HspInsNam,' ') " +
"FROM M_UidMst u " +
"LEFT JOIN M_DepMst d ON d.DepCod = u.UidDepCod " +
" AND TO_CHAR(SYSDATE,'YYYYMMDD') BETWEEN d.DepStrDte AND d.DepEndDte " +
"LEFT JOIN M_HspMst h ON h.HspCod = u.UidHspCod " +
" AND TO_CHAR(SYSDATE,'YYYYMMDD') BETWEEN h.HspStrDte AND NVL(h.HspEndDte,'99991231') " +
"WHERE u.UidCod = :u " +
"AND TO_CHAR(SYSDATE,'YYYYMMDD') BETWEEN u.UidStrDte AND u.UidEndDte";
command.Parameters.Add(new OracleParameter("u", uidCod.Trim()));
using var reader = command.ExecuteReader();
if (!reader.Read())
{
return null;
}
return new HisUser(
reader.GetString(0).Trim(), reader.GetString(1).Trim(),
reader.GetString(2).Trim(), reader.GetString(3).Trim(),
reader.GetString(4).Trim(), reader.GetString(5).Trim());
}
#endregion
}
+94 -68
View File
@@ -13,87 +13,113 @@ namespace SheetMe.Designer;
/// </summary> /// </summary>
public partial class App : Application 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) protected override void OnStartup(StartupEventArgs e)
{ {
base.OnStartup(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]); Services.UserSession.InitializeDiagnostic();
Shutdown(exitCode); Shutdown(RunDiagnostic(e.Args));
return; 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])); MessageBox.Show(error, "서식생성기", MessageBoxButton.OK, MessageBoxImage.Error);
return; Shutdown(2);
}
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]));
return; return;
} }
Services.ThemeManager.LoadSaved(); 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> /// <summary>첫 페이지 오프스크린 렌더 → PNG (렌더 파이프라인 자동 검증)</summary>
@@ -26,6 +26,15 @@ internal sealed class FormDesignDataBusiness
/// <summary>DB 저장 허용 여부 — SaveMode='Db' 명시 설정</summary> /// <summary>DB 저장 허용 여부 — SaveMode='Db' 명시 설정</summary>
public bool CanSaveToDb => CanUseDb && Config.IsDbSaveMode(); 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 #endregion
#region Methods #region Methods
@@ -85,15 +94,18 @@ internal sealed class FormDesignDataBusiness
throw new InvalidOperationException( throw new InvalidOperationException(
"DB 저장이 비활성화되어 있습니다. appsettings.json 의 FormStore:SaveMode 를 'Db' 로 설정하세요."); "DB 저장이 비활성화되어 있습니다. appsettings.json 의 FormStore:SaveMode 를 'Db' 로 설정하세요.");
} }
return DbStore().SaveDesign(document, Environment.UserName); return DbStore().SaveDesign(document, AuditUid);
} }
/// <summary>서식 코드 등록 여부(E_ShtMst)</summary> /// <summary>서식 코드 등록 여부(E_ShtMst)</summary>
public bool SheetExists(string shtCod) => DbStore().SheetExists(shtCod); public bool SheetExists(string shtCod) => DbStore().SheetExists(shtCod);
/// <summary>서식생성기 사용 허용 여부(E_ShtMst.ShtUsrDesYon) — 레거시 열기 게이트</summary>
public SheetDesignPermission GetDesignPermission(string shtCod) => DbStore().GetDesignPermission(shtCod);
/// <summary>신규 서식 최소 등록 — 디자이너 서식 관행 기본값</summary> /// <summary>신규 서식 최소 등록 — 디자이너 서식 관행 기본값</summary>
public void RegisterSheet(string shtCod, string korName, string? clsCod) 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> /// <summary>상용구 저장소(E_SHTWRDMST) — DB 접속 필요</summary>
public RecordWordStore RecordWords() public RecordWordStore RecordWords()
+59 -11
View File
@@ -1,4 +1,4 @@
using System.IO; using System.IO;
using SheetMe.Core.Models; using SheetMe.Core.Models;
using SheetMe.Core.Serialization; using SheetMe.Core.Serialization;
using SheetMe.Data.Stores; 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> /// <summary>테이블 컬럼 스키마 조회(--db-columns) — NOT NULL/타입 확인(읽기 전용)</summary>
public static int RunColumns(string tableName, string reportPath) 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("스모크 문구"))) 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; var before = store.List(shtCod).Count;
lines.Add($"[사전] {shtCod} 상용구 {before}건"); lines.Add($"[사전] {shtCod} 상용구 {before}건");
var key1 = store.Add(shtCod, "스모크 문구 1 (특이사항 없음)", Environment.UserName); var key1 = store.Add(shtCod, "스모크 문구 1 (특이사항 없음)", UserSession.AuditUid);
var key2 = store.Add(shtCod, "스모크 문구 2 (경과 양호)", Environment.UserName); var key2 = store.Add(shtCod, "스모크 문구 2 (경과 양호)", UserSession.AuditUid);
var afterAdd = store.List(shtCod); var afterAdd = store.List(shtCod);
var okAdd = afterAdd.Count == before + 2 var okAdd = afterAdd.Count == before + 2
&& afterAdd.Any(w => w.Key == key1) && afterAdd.Any(w => w.Key == key2); && afterAdd.Any(w => w.Key == key1) && afterAdd.Any(w => w.Key == key2);
lines.Add($"[추가] 2건 → {afterAdd.Count}건 (키 {key1}, {key2}) : {(okAdd ? "" : "")}"); 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 updatedValue = store.List(shtCod).FirstOrDefault(w => w.Key == key1)?.Value;
var okUpdate = updatedValue == "스모크 문구 1 (수정됨)"; var okUpdate = updatedValue == "스모크 문구 1 (수정됨)";
lines.Add($"[수정] 값=\"{updatedValue}\" : {(okUpdate ? "" : "")}"); lines.Add($"[수정] 값=\"{updatedValue}\" : {(okUpdate ? "" : "")}");
var reordered = afterAdd.Select(w => w.Key).ToList(); var reordered = afterAdd.Select(w => w.Key).ToList();
reordered.Reverse(); reordered.Reverse();
store.Reorder(reordered, Environment.UserName); store.Reorder(reordered, UserSession.AuditUid);
var firstKey = store.List(shtCod).FirstOrDefault()?.Key; var firstKey = store.List(shtCod).FirstOrDefault()?.Key;
var okReorder = firstKey == reordered[0]; var okReorder = firstKey == reordered[0];
lines.Add($"[순서] 첫 키={firstKey} 기대={reordered[0]} : {(okReorder ? "" : "")}"); lines.Add($"[순서] 첫 키={firstKey} 기대={reordered[0]} : {(okReorder ? "" : "")}");
store.Remove(key1); store.Remove(key1, UserSession.AuditUid);
store.Remove(key2); store.Remove(key2, UserSession.AuditUid);
var afterRemove = store.List(shtCod).Count; var afterRemove = store.List(shtCod).Count;
var okRemove = afterRemove == before; var okRemove = afterRemove == before;
lines.Add($"[삭제] 원상복구 → {afterRemove}건 : {(okRemove ? "" : "")}"); lines.Add($"[삭제] 원상복구 → {afterRemove}건 : {(okRemove ? "" : "")}");
@@ -370,7 +418,7 @@ public static class DbSmoke
try try
{ {
target.Props.SetText("Text", probeText); target.Props.SetText("Text", probeText);
store.SaveDesign(document, Environment.UserName); store.SaveDesign(document, UserSession.AuditUid);
var (keyAfter, txtAfter) = ReadSctRow(connectionString, sdgKey, target.Id); var (keyAfter, txtAfter) = ReadSctRow(connectionString, sdgKey, target.Id);
var textApplied = string.Equals(txtAfter, probeText, StringComparison.Ordinal); var textApplied = string.Equals(txtAfter, probeText, StringComparison.Ordinal);
var keyStable = keyAfter == keyBefore; var keyStable = keyAfter == keyBefore;
@@ -387,7 +435,7 @@ public static class DbSmoke
if (restoreTarget is not null) if (restoreTarget is not null)
{ {
restoreTarget.Props.SetText("Text", originalText); restoreTarget.Props.SetText("Text", originalText);
store.SaveDesign(restore, Environment.UserName); store.SaveDesign(restore, UserSession.AuditUid);
var (_, txtRestored) = ReadSctRow(connectionString, sdgKey, target.Id); var (_, txtRestored) = ReadSctRow(connectionString, sdgKey, target.Id);
lines.Add($"[SctObjTxt] 원복 → '{Trim30(txtRestored)}' " + lines.Add($"[SctObjTxt] 원복 → '{Trim30(txtRestored)}' " +
$"{(string.Equals(txtRestored, originalText, StringComparison.Ordinal) ? "" : "!")}"); $"{(string.Equals(txtRestored, originalText, StringComparison.Ordinal) ? "" : "!")}");
@@ -458,7 +506,7 @@ public static class DbSmoke
// 무변경 재저장(문서 그대로) — 레거시 저장 시와 동일하게 새 버전 생성(또는 제자리 갱신) // 무변경 재저장(문서 그대로) — 레거시 저장 시와 동일하게 새 버전 생성(또는 제자리 갱신)
var document = serializer.Read(before.Xml); var document = serializer.Read(before.Xml);
document.FormId = shtCod; document.FormId = shtCod;
var savedKey = store.SaveDesign(document, Environment.UserName); var savedKey = store.SaveDesign(document, UserSession.AuditUid);
var after = store.LoadActiveDesignRaw(shtCod) var after = store.LoadActiveDesignRaw(shtCod)
?? throw new InvalidOperationException("저장 후 활성 디자인 조회 실패"); ?? throw new InvalidOperationException("저장 후 활성 디자인 조회 실패");
+19 -1
View File
@@ -425,7 +425,25 @@ public static class EditSmoke
Check("상용구 모드: 제목에 '쿼리' 없음", !wordEditor.Title.Contains("쿼리"), wordEditor.Title); Check("상용구 모드: 제목에 '쿼리' 없음", !wordEditor.Title.Contains("쿼리"), wordEditor.Title);
wordEditor.Close(); 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()); var dirtyDoc = new DesignerViewModel(business.CreateNew());
Check("더티: 초기 상태는 clean", !dirtyDoc.Undo.IsDirty); Check("더티: 초기 상태는 clean", !dirtyDoc.Undo.IsDirty);
dirtyDoc.AddControlAt("Label", new Point(50, 50)); dirtyDoc.AddControlAt("Label", new Point(50, 50));
@@ -31,6 +31,7 @@ public static class ConfigLoader
config.Provider = root["His:Provider"] ?? "Oracle"; config.Provider = root["His:Provider"] ?? "Oracle";
config.SaveMode = root["FormStore:SaveMode"] ?? "File"; config.SaveMode = root["FormStore:SaveMode"] ?? "File";
config.XmlFolder = root["FormStore:XmlFolder"] ?? "forms"; config.XmlFolder = root["FormStore:XmlFolder"] ?? "forms";
config.DevUidCod = root["His:DevUidCod"] ?? string.Empty;
return config; 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> /// <summary>이미 열린 DB 서식이면 해당 탭 활성화 — 없으면 null</summary>
private DesignerViewModel? FindOpenDbDocument(string shtCod) private DesignerViewModel? FindOpenDbDocument(string shtCod)
=> OpenDesigners.FirstOrDefault(d => d.IsFromDb && d.Document.FormId == 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 #endregion
#region Methods - / #region Methods - /
/// <summary>기동 인자로 받은 서식 코드 — 창 로드 후 자동으로 연다(App.OnStartup 이 세팅)</summary>
public string? PendingSheetCode { get; set; }
private async Task OnLoadedAsync() private async Task OnLoadedAsync()
{ {
await LoadSheetListAsync();
// 기동 인자로 서식을 지정했으면 빈 새 문서를 만들지 않는다 — 레거시도 해당 서식만 연다.
// 열기에 실패하면(디자인 없음/권한 거부/코드 오타) 빈 문서로 폴백해 앱이 빈 껍데기가 되지 않게 한다.
if (PendingSheetCode is { Length: > 0 } shtCod)
{
PendingSheetCode = null;
OpenDbSheet(shtCod);
if (OpenDesigners.Count > 0)
{
return;
}
}
OnNewFile(); OnNewFile();
SelectedLeftTabIndex = 0; // 시작 화면은 서식 목록 탭(초기 빈 문서로 레이어 탭 전환되는 것 되돌림) SelectedLeftTabIndex = 0; // 시작 화면은 서식 목록 탭(초기 빈 문서로 레이어 탭 전환되는 것 되돌림)
await LoadSheetListAsync();
} }
private void OnNewFile() private void OnNewFile()
@@ -404,7 +450,11 @@ internal sealed class MainViewModel : ViewModelBase
} }
} }
/// <summary>DB 서식 열기 공통 — 이미 열려 있으면 탭 활성화</summary> /// <summary>
/// DB 서식 열기 공통 — 이미 열려 있으면 탭 활성화.
/// 모든 열기 경로(대화상자·목록 더블클릭·기동 인자)가 여기로 수렴하므로 권한 게이트를 여기 둔다.
/// 레거시는 게이트가 진입점마다 흩어져 있어 이력 노드 클릭·MessageQueue 두 경로로 우회가 가능했다.
/// </summary>
private void OpenDbSheet(string shtCod) private void OpenDbSheet(string shtCod)
{ {
var existing = FindOpenDbDocument(shtCod); var existing = FindOpenDbDocument(shtCod);
@@ -415,6 +465,11 @@ internal sealed class MainViewModel : ViewModelBase
return; return;
} }
if (!EnsureDesignAllowed(shtCod))
{
return;
}
var document = dataBusiness.OpenFromDb(shtCod); var document = dataBusiness.OpenFromDb(shtCod);
if (document is null) if (document is null)
{ {
@@ -444,10 +499,25 @@ internal sealed class MainViewModel : ViewModelBase
"DB 저장", MessageBoxButton.OK, MessageBoxImage.Warning); "DB 저장", MessageBoxButton.OK, MessageBoxImage.Warning);
return false; return false;
} }
if (!dataBusiness.CanWriteDb)
{
MessageBox.Show("HIS 사용자가 확인되지 않아 DB에 저장할 수 없습니다.\n" +
"기록지정보 화면에서 서식생성기를 실행하거나, 사용자 코드를 인자로 전달해 주세요.\n" +
"(저장 이력에 남길 수정자를 특정할 수 없습니다)",
"DB 저장", MessageBoxButton.OK, MessageBoxImage.Warning);
return false;
}
CommitPendingEdits(); CommitPendingEdits();
var document = CurrentDesigner.Document; var document = CurrentDesigner.Document;
// 열기 이후 마스터에서 플래그가 꺼졌을 수 있고, 파일→DB 저장은 열기 게이트를 아예 거치지 않는다.
// 레거시는 저장 경로에 게이트가 없어 이 구멍이 열려 있었다.
if (dataBusiness.SheetExists(document.FormId) && !EnsureDesignAllowed(document.FormId))
{
return false;
}
// 미등록 서식이면 신규 등록 다이얼로그 // 미등록 서식이면 신규 등록 다이얼로그
if (!dataBusiness.SheetExists(document.FormId)) if (!dataBusiness.SheetExists(document.FormId))
{ {
@@ -661,6 +731,13 @@ internal sealed class MainViewModel : ViewModelBase
return; return;
} }
// 파일에서 연 문서의 FormId 가 우연히 거부 서식 코드와 같으면 이 경로로 이력 열람·복원이
// 가능해진다(레거시의 우회 경로 중 하나였다).
if (!EnsureDesignAllowed(document.FormId))
{
return;
}
var versions = dataBusiness.ListVersions(document.FormId); var versions = dataBusiness.ListVersions(document.FormId);
if (versions.Count == 0) if (versions.Count == 0)
{ {
@@ -729,7 +806,8 @@ internal sealed class MainViewModel : ViewModelBase
return; 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, Owner = Application.Current.MainWindow,
}.ShowDialog(); }.ShowDialog();
+20
View File
@@ -278,6 +278,22 @@
<StackPanel Orientation="Horizontal" Margin="1"> <StackPanel Orientation="Horizontal" Margin="1">
<TextBlock Text="{Binding ShtCod}" FontSize="11" Foreground="{DynamicResource B.Muted}" <TextBlock Text="{Binding ShtCod}" FontSize="11" Foreground="{DynamicResource B.Muted}"
Width="52" VerticalAlignment="Center"/> 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 Text="{Binding Name}" FontSize="12" TextTrimming="CharacterEllipsis">
<TextBlock.Style> <TextBlock.Style>
<Style TargetType="TextBlock" BasedOn="{StaticResource {x:Type TextBlock}}"> <Style TargetType="TextBlock" BasedOn="{StaticResource {x:Type TextBlock}}">
@@ -286,6 +302,10 @@
<Setter Property="Foreground" Value="{DynamicResource B.Muted}"/> <Setter Property="Foreground" Value="{DynamicResource B.Muted}"/>
<Setter Property="Opacity" Value="0.6"/> <Setter Property="Opacity" Value="0.6"/>
</DataTrigger> </DataTrigger>
<DataTrigger Binding="{Binding DesignAllowed}" Value="False">
<Setter Property="Foreground" Value="{DynamicResource B.Muted}"/>
<Setter Property="Opacity" Value="0.6"/>
</DataTrigger>
</Style.Triggers> </Style.Triggers>
</Style> </Style>
</TextBlock.Style> </TextBlock.Style>
@@ -13,23 +13,45 @@ public partial class RecordWordDialogView : Window
#region Member Fields #region Member Fields
private readonly RecordWordStore store; private readonly RecordWordStore store;
private readonly string shtCod; private readonly string shtCod;
private readonly string auditUid;
private readonly bool canWrite;
private readonly ObservableCollection<RecordWordInfo> words = new(); private readonly ObservableCollection<RecordWordInfo> words = new();
#endregion #endregion
#region Constructors #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.store = store;
this.shtCod = shtCod; this.shtCod = shtCod;
this.auditUid = auditUid;
this.canWrite = canWrite;
InitializeComponent(); InitializeComponent();
HeaderText.Text = $"[{shtCod}] {sheetTitle} — 작성 화면의 상용구 팝업에 표시되는 문구입니다. " + HeaderText.Text = $"[{shtCod}] {sheetTitle} — 작성 화면의 상용구 팝업에 표시되는 문구입니다. " +
"(텍스트박스의 '상용구 사용'이 켜져 있어야 합니다)"; "(텍스트박스의 '상용구 사용'이 켜져 있어야 합니다)" +
(canWrite ? string.Empty : "\n※ HIS 사용자가 확인되지 않아 읽기 전용입니다.");
WordList.ItemsSource = words; WordList.ItemsSource = words;
Loaded += (_, _) => Reload(); Loaded += (_, _) => Reload();
NewWordBox.Loaded += (_, _) => NewWordBox.Focus(); NewWordBox.Loaded += (_, _) => NewWordBox.Focus();
} }
#endregion #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 #region Methods
private void Reload() private void Reload()
{ {
@@ -51,13 +73,13 @@ public partial class RecordWordDialogView : Window
private void OnAdd(object sender, RoutedEventArgs e) private void OnAdd(object sender, RoutedEventArgs e)
{ {
var value = NewWordBox.Text.Trim(); var value = NewWordBox.Text.Trim();
if (value.Length == 0) if (value.Length == 0 || !EnsureWritable())
{ {
return; return;
} }
try try
{ {
store.Add(shtCod, value, Environment.UserName); store.Add(shtCod, value, auditUid);
NewWordBox.Clear(); NewWordBox.Clear();
NewWordBox.Focus(); NewWordBox.Focus();
Reload(); Reload();
@@ -72,7 +94,7 @@ public partial class RecordWordDialogView : Window
private void OnEdit(object sender, RoutedEventArgs e) private void OnEdit(object sender, RoutedEventArgs e)
{ {
if (WordList.SelectedItem is not RecordWordInfo selected) if (WordList.SelectedItem is not RecordWordInfo selected || !EnsureWritable())
{ {
return; return;
} }
@@ -89,7 +111,7 @@ public partial class RecordWordDialogView : Window
} }
try try
{ {
store.Update(selected.Key, newValue, Environment.UserName); store.Update(selected.Key, newValue, auditUid);
Reload(); Reload();
} }
catch (Exception ex) catch (Exception ex)
@@ -101,7 +123,7 @@ public partial class RecordWordDialogView : Window
private void OnRemove(object sender, RoutedEventArgs e) private void OnRemove(object sender, RoutedEventArgs e)
{ {
if (WordList.SelectedItem is not RecordWordInfo selected) if (WordList.SelectedItem is not RecordWordInfo selected || !EnsureWritable())
{ {
return; return;
} }
@@ -112,7 +134,7 @@ public partial class RecordWordDialogView : Window
} }
try try
{ {
store.Remove(selected.Key); store.Remove(selected.Key, auditUid);
Reload(); Reload();
} }
catch (Exception ex) catch (Exception ex)
@@ -130,14 +152,14 @@ public partial class RecordWordDialogView : Window
{ {
var index = WordList.SelectedIndex; var index = WordList.SelectedIndex;
var target = index + delta; var target = index + delta;
if (index < 0 || target < 0 || target >= words.Count) if (index < 0 || target < 0 || target >= words.Count || !EnsureWritable())
{ {
return; return;
} }
try try
{ {
words.Move(index, target); 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; WordList.SelectedIndex = target;
} }
catch (Exception ex) catch (Exception ex)