From 0f77cb717a445d1276f8178c00509c103a4ab042 Mon Sep 17 00:00:00 2001 From: Msystech Date: Tue, 11 Aug 2026 18:37:46 +0900 Subject: [PATCH] =?UTF-8?q?HIS=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=BB=A8?= =?UTF-8?q?=ED=85=8D=EC=8A=A4=ED=8A=B8=20+=20=EC=84=9C=EC=8B=9D=EC=83=9D?= =?UTF-8?q?=EC=84=B1=EA=B8=B0=20=EA=B6=8C=ED=95=9C=20=EA=B2=8C=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20+=20=EC=84=9C=EB=B2=84=20=EC=8B=9C=EA=B0=81=20?= =?UTF-8?q?=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [사용자 컨텍스트] 감사 컬럼에 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 --- src/SheetMe.Data/Config/DataConfig.cs | 7 + .../Stores/OracleLegacyFormStore.cs | 57 +++++- src/SheetMe.Data/Stores/RecordWordStore.cs | 29 ++-- src/SheetMe.Data/Stores/ServerClock.cs | 50 ++++++ src/SheetMe.Data/Stores/UserContextStore.cs | 85 +++++++++ src/SheetMe.Designer/App.xaml.cs | 162 ++++++++++-------- .../DataBusiness/FormDesignDataBusiness.cs | 16 +- src/SheetMe.Designer/Diagnostics/DbSmoke.cs | 70 ++++++-- src/SheetMe.Designer/Diagnostics/EditSmoke.cs | 20 ++- src/SheetMe.Designer/Services/ConfigLoader.cs | 1 + .../Services/SessionBootstrap.cs | 71 ++++++++ .../Services/StartupArguments.cs | 38 ++++ src/SheetMe.Designer/Services/UserSession.cs | 54 ++++++ .../ViewModels/MainViewModel.cs | 84 ++++++++- src/SheetMe.Designer/Views/MainView.xaml | 20 +++ .../Views/RecordWordDialogView.xaml.cs | 42 +++-- 16 files changed, 695 insertions(+), 111 deletions(-) create mode 100644 src/SheetMe.Data/Stores/ServerClock.cs create mode 100644 src/SheetMe.Data/Stores/UserContextStore.cs create mode 100644 src/SheetMe.Designer/Services/SessionBootstrap.cs create mode 100644 src/SheetMe.Designer/Services/StartupArguments.cs create mode 100644 src/SheetMe.Designer/Services/UserSession.cs diff --git a/src/SheetMe.Data/Config/DataConfig.cs b/src/SheetMe.Data/Config/DataConfig.cs index d7b7d40..78c9343 100644 --- a/src/SheetMe.Data/Config/DataConfig.cs +++ b/src/SheetMe.Data/Config/DataConfig.cs @@ -18,6 +18,13 @@ public sealed class DataConfig /// 파일 모드 서식 폴더 public string XmlFolder { get; set; } = "forms"; + + /// + /// 개발용 HIS 사용자 코드 — 기동 인자 없이 실행할 때만 시도하는 폴백. + /// 운영 배포본의 appsettings.json 에는 이 키가 없으므로 자동으로 비활성이다. + /// 여기 담는 것은 설정값(UidCod 문자열)뿐이며, 해석된 사용자 정보는 담지 않는다. + /// + public string DevUidCod { get; set; } = string.Empty; #endregion #region Methods diff --git a/src/SheetMe.Data/Stores/OracleLegacyFormStore.cs b/src/SheetMe.Data/Stores/OracleLegacyFormStore.cs index c95adb7..3024152 100644 --- a/src/SheetMe.Data/Stores/OracleLegacyFormStore.cs +++ b/src/SheetMe.Data/Stores/OracleLegacyFormStore.cs @@ -5,7 +5,21 @@ using SheetMe.Core.Serialization; namespace SheetMe.Data.Stores; /// 서식 목록 1건(열기 대화상자) -public sealed record SheetSummary(string ShtCod, string Name, bool HasDesign); +/// 서식생성기 사용 허용(E_ShtMst.ShtUsrDesYon) — 목록 잠금 표시용 +public sealed record SheetSummary(string ShtCod, string Name, bool HasDesign, bool DesignAllowed); + +/// 서식생성기 사용 권한 판정 결과 +public enum SheetDesignPermission +{ + /// E_ShtMst 에 서식 코드가 없음 + NotRegistered, + + /// 허용 — ShtUsrDesYon 이 'Y' 또는 미설정 + Allowed, + + /// 거부 — ShtUsrDesYon 이 'N' 등 + Denied, +} /// 디자인 버전 1건 public sealed record DesignVersionInfo(decimal SdgKey, string UpdDtm, string UpdUid, bool Deleted); @@ -54,7 +68,8 @@ public sealed class OracleLegacyFormStore command.CommandText = "SELECT * FROM (" + " 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" + $") WHERE ROWNUM <= {max}"; @@ -65,11 +80,36 @@ public sealed class OracleLegacyFormStore result.Add(new SheetSummary( reader.GetString(0), reader.IsDBNull(1) ? string.Empty : reader.GetString(1), - reader.GetDecimal(2) > 0)); + reader.GetDecimal(2) > 0, + IsDesignAllowed(reader.GetString(3)))); } return result; } + /// + /// 서식생성기 사용 허용 여부(E_ShtMst.ShtUsrDesYon) — 레거시 frmSheetDesigner.vb:71-84 규칙. + /// 'Y' 또는 빈 문자열/NULL(미설정 = 레거시 데이터 호환)이면 허용, 그 외('N' 등)만 거부한다. + /// + 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; + } + + /// ShtUsrDesYon 판정 — CHAR 패딩을 흡수하려 TRIM 후 비교(레거시보다 관대한 방향이라 오탐만 줄인다) + private static bool IsDesignAllowed(string flag) + { + var trimmed = flag.Trim(); + return trimmed is "Y" or ""; + } + /// 활성(비삭제 최신) 디자인 로드 — 없으면 null. FormId/Title/SourceSdgKey 채움 public FormDocument? LoadActiveDesign(string shtCod) { @@ -214,12 +254,15 @@ public sealed class OracleLegacyFormStore } var mergedXml = serializer.Write(document); - var now = DateTime.Now.ToString("yyyyMMddHHmm"); // 레거시 SystemDateTime 포맷(12자리) using var connection = Open(); using var transaction = connection.BeginTransaction(); try { + // 레거시 SystemDateTime 과 동일하게 DB 서버 시각을 쓴다 — 단말 시계가 밀리면 + // ResolveActiveSdgKey 의 ORDER BY SdgUpdDtm 이 활성 버전을 잘못 고를 수 있다. + var now = ServerClock.Read(connection, transaction).Minute12; + var shtCneYon = Scalar(connection, transaction, "SELECT NVL(ShtCneYon,' ') FROM E_ShtMst WHERE ShtCod = :s", new OracleParameter("s", document.FormId)) as string; @@ -302,17 +345,19 @@ public sealed class OracleLegacyFormStore using var transaction = connection.BeginTransaction(); try { + var stamp = ServerClock.Read(connection, transaction); + // ShtUsrDesYon='Y' 하드코딩 — 여기서 만든 서식은 서식생성기가 만든 것이므로 열기 게이트를 자동 통과한다 NonQuery(connection, transaction, "INSERT INTO E_ShtMst (ShtCod, ShtAdpDte, ShtKorNam, ShtClsCod, ShtTyp, ShtUseYon, " + "ShtUsrDesYon, ShtUseSdg, ShtUseSct, ShtUseEmr, ShtRedAth, ShtWrtTyp, ShtCneYon, ShtTraSto, " + "ShtEleSig, ShtUpdUid, ShtUpdDtm) " + "VALUES (:cod, :adp, :nam, :cls, 'D', 'Y', 'Y', 'Y', 'Y', 'Y', 'A', '9A', 'N', 'N', 'N', :uid, :dtm)", new OracleParameter("cod", shtCod), - new OracleParameter("adp", DateTime.Now.ToString("yyyyMMdd")), + new OracleParameter("adp", stamp.Date8), new OracleParameter("nam", korName), new OracleParameter("cls", string.IsNullOrWhiteSpace(clsCod) ? (object)DBNull.Value : clsCod), new OracleParameter("uid", uid), - new OracleParameter("dtm", DateTime.Now.ToString("yyyyMMddHHmm"))); + new OracleParameter("dtm", stamp.Minute12)); transaction.Commit(); } catch diff --git a/src/SheetMe.Data/Stores/RecordWordStore.cs b/src/SheetMe.Data/Stores/RecordWordStore.cs index 34e2fe1..69478ea 100644 --- a/src/SheetMe.Data/Stores/RecordWordStore.cs +++ b/src/SheetMe.Data/Stores/RecordWordStore.cs @@ -1,4 +1,4 @@ -using Oracle.ManagedDataAccess.Client; +using Oracle.ManagedDataAccess.Client; namespace SheetMe.Data.Stores; @@ -54,6 +54,8 @@ public sealed class RecordWordStore using var transaction = connection.BeginTransaction(); try { + // 서버 시각 1회 — RETURNING 경로와 폴백 경로가 같은 값을 쓰게 한다 + var stamp = ServerClock.Read(connection, transaction).Milli17; var seq = Convert.ToDecimal(Scalar(connection, transaction, "SELECT NVL(MAX(ShtDspSeq),0)+1 FROM E_SHTWRDMST WHERE ShtCod = :s AND ShtStpLvl = 1", new OracleParameter("s", shtCod))!); @@ -74,7 +76,7 @@ public sealed class RecordWordStore command.Parameters.Add(new OracleParameter("v", value)); command.Parameters.Add(new OracleParameter("q", seq)); 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); command.Parameters.Add(outKey); command.ExecuteNonQuery(); @@ -94,7 +96,7 @@ public sealed class RecordWordStore new OracleParameter("v", value), new OracleParameter("q", seq), new OracleParameter("u", uid), - new OracleParameter("d", Now17())); + new OracleParameter("d", stamp)); } transaction.Commit(); @@ -111,20 +113,28 @@ public sealed class RecordWordStore public void Update(decimal key, string value, string uid) { using var connection = Open(); + var stamp = ServerClock.Read(connection, null).Milli17; using var command = connection.CreateCommand(); command.BindByName = true; command.CommandText = "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("u", uid)); - command.Parameters.Add(new OracleParameter("d", Now17())); + command.Parameters.Add(new OracleParameter("d", stamp)); command.Parameters.Add(new OracleParameter("k", key)); command.ExecuteNonQuery(); } - /// 상용구 삭제(하위 단계 포함) - public void Remove(decimal key) + /// + /// 상용구 삭제(하위 단계 포함) — 물리 DELETE. + /// auditUid 를 DB 에 남길 곳은 없다: 레거시 런타임 조회 쿼리(dtRecWordMst.vb)에 ShtStt 필터가 없어 + /// 논리 삭제로 바꾸면 지운 문구가 EMR 작성 화면에 계속 노출된다. 그럼에도 인자로 받는 이유는 + /// "모든 쓰기 API 는 uid 를 명시한다"는 규약을 컴파일 타임에 강제해, 나중에 uid 없는 경로가 + /// 새로 생기지 않게 하기 위해서다. + /// + public void Remove(decimal key, string auditUid) { + _ = auditUid; using var connection = Open(); using var transaction = connection.BeginTransaction(); try @@ -149,13 +159,15 @@ public sealed class RecordWordStore using var transaction = connection.BeginTransaction(); try { + // 루프 밖에서 1회 — 한 번의 순서 변경이 행마다 다른 시각을 남기지 않게 한다 + var stamp = ServerClock.Read(connection, transaction).Milli17; for (var i = 0; i < orderedKeys.Count; i++) { NonQuery(connection, transaction, "UPDATE E_SHTWRDMST SET ShtDspSeq = :q, ShtUpdUid = :u, ShtUpdDtf = :d WHERE ShtWrdKey = :k", new OracleParameter("q", (decimal)(i + 1)), new OracleParameter("u", uid), - new OracleParameter("d", Now17()), + new OracleParameter("d", stamp), new OracleParameter("k", orderedKeys[i])); } transaction.Commit(); @@ -174,9 +186,6 @@ public sealed class RecordWordStore return connection; } - /// SHTUPDDTF 17자리(yyyyMMddHHmmssfff) - private static string Now17() => DateTime.Now.ToString("yyyyMMddHHmmssfff"); - private static object? Scalar(OracleConnection connection, OracleTransaction transaction, string sql, params OracleParameter[] parameters) { using var command = connection.CreateCommand(); diff --git a/src/SheetMe.Data/Stores/ServerClock.cs b/src/SheetMe.Data/Stores/ServerClock.cs new file mode 100644 index 0000000..0d6e249 --- /dev/null +++ b/src/SheetMe.Data/Stores/ServerClock.cs @@ -0,0 +1,50 @@ +using System.Globalization; +using Oracle.ManagedDataAccess.Client; + +namespace SheetMe.Data.Stores; + +/// +/// 감사 컬럼 시각의 유일한 출처 = DB 서버 시각(레거시 clsCommonLib.SystemDateTime 동형 — +/// 레거시도 SELECT Sysdate FROM DUAL 로 서버 시각을 받아 클라이언트에서 포맷한다). +/// +/// 단순한 감사 정확성 문제가 아니다. ResolveActiveSdgKey 가 +/// ORDER BY SdgUpdDtm DESC, SdgKey DESC 로 활성 버전을 고르므로, +/// PC 시계가 뒤로 밀린 단말에서 저장하면 활성 버전 판정이 뒤집힐 수 있다. +/// +internal static class ServerClock +{ + #region Types + /// 서버 시각 1회 조회 결과 — 레거시가 쓰는 3가지 포맷을 함께 제공 + internal readonly record struct Stamp(DateTime Value) + { + /// yyyyMMdd (8자리) — ShtAdpDte 류 + public string Date8 => Value.ToString("yyyyMMdd", CultureInfo.InvariantCulture); + + /// yyyyMMddHHmm (12자리) — SdgUpdDtm/ShtUpdDtm + public string Minute12 => Value.ToString("yyyyMMddHHmm", CultureInfo.InvariantCulture); + + /// yyyyMMddHHmmssfff (17자리) — 상용구 ShtUpdDtf/ShtFstDtf + public string Milli17 => Value.ToString("yyyyMMddHHmmssfff", CultureInfo.InvariantCulture); + } + #endregion + + #region Methods + /// + /// 서버 시각 조회 — 왕복 1회로 3포맷을 확보한다. + /// 포맷 마스크를 SQL 에 두지 않고 값만 받아 invariant 로 포맷해 NLS 설정 의존을 없앤다. + /// 실패 시 폴백하지 않는다 — 커넥션이 죽은 것이고 뒤따르는 DML 도 어차피 실패한다. + /// + 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 +} diff --git a/src/SheetMe.Data/Stores/UserContextStore.cs b/src/SheetMe.Data/Stores/UserContextStore.cs new file mode 100644 index 0000000..0d3ac1e --- /dev/null +++ b/src/SheetMe.Data/Stores/UserContextStore.cs @@ -0,0 +1,85 @@ +using Oracle.ManagedDataAccess.Client; + +namespace SheetMe.Data.Stores; + +/// +/// HIS 로그인 사용자 — 감사 컬럼(SdgUidCod/ShtUpdUid 등)에 기록되는 UidCod 의 유일한 출처. +/// 부서·병원은 표시 전용이며 없어도 인증은 성립한다(레거시 bzLogIn 과 동일 의미론). +/// +public sealed record HisUser( + string UidCod, string UidNam, + string DepCod, string DepNam, + string HspCod, string HspNam) +{ + /// 미인증 세션(인자 없이 단독 실행) — DB 쓰기가 차단된다 + public static readonly HisUser Unknown = new(string.Empty, string.Empty, string.Empty, string.Empty, string.Empty, string.Empty); + + /// HIS 사용자로 확인되었는지 + public bool IsAuthenticated => UidCod.Length > 0; + + /// 표시용 — "홍길동(MSYS)" 형태, 미인증이면 안내 문구 + public string Display => IsAuthenticated + ? (UidNam.Length > 0 ? $"{UidNam}({UidCod})" : UidCod) + : "사용자 미확인"; +} + +/// +/// 사용자 컨텍스트 조회 — 레거시 bzLogIn.LogIn(uid, autoLogin) 의 신규 대응물. +/// 레거시도 자동 로그인에서는 비밀번호를 확인하지 않고 M_UidMst 행 존재 + 유효기간만 판정한다 +/// (daLogIn.vb: SELECT * FROM M_UidMst WHERE UidCod=:u AND :adp BETWEEN UidStrDte AND UidEndDte). +/// +public sealed class UserContextStore +{ + #region Member Fields + private readonly string connectionString; + #endregion + + #region Constructors + public UserContextStore(string connectionString) + { + this.connectionString = connectionString; + } + #endregion + + #region Methods + /// + /// UidCod 검증 + 표시 정보 조회 — 미존재이거나 유효기간 밖이면 null. + /// 적용일자는 서버 시각을 쓴다(단말 시계가 밀리면 유효 계정이 거부될 수 있다). + /// 부서·병원은 LEFT JOIN 이라 마스터에 행이 없어도 인증 자체는 성립한다. + /// + 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 +} diff --git a/src/SheetMe.Designer/App.xaml.cs b/src/SheetMe.Designer/App.xaml.cs index 441caf4..94e323a 100644 --- a/src/SheetMe.Designer/App.xaml.cs +++ b/src/SheetMe.Designer/App.xaml.cs @@ -13,87 +13,113 @@ namespace SheetMe.Designer; /// public partial class App : Application { - /// 기동 — 스모크 모드 분기 또는 메인 셸 표시 + /// + /// 기동 — 진단 모드 분기, 아니면 HIS 세션을 확정한 뒤 메인 셸 표시. + /// HIS 기동 규약: SheetMe.Designer.exe "UidCod,ComNum,ShtCod" (콤마 구분 단일 인자). + /// 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(); + } + + /// 진단 플래그 분기 — 종료 코드를 반환한다(호출부가 Shutdown 처리) + 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; } /// 첫 페이지 오프스크린 렌더 → PNG (렌더 파이프라인 자동 검증) diff --git a/src/SheetMe.Designer/DataBusiness/FormDesignDataBusiness.cs b/src/SheetMe.Designer/DataBusiness/FormDesignDataBusiness.cs index 0bb3bee..67cfad5 100644 --- a/src/SheetMe.Designer/DataBusiness/FormDesignDataBusiness.cs +++ b/src/SheetMe.Designer/DataBusiness/FormDesignDataBusiness.cs @@ -26,6 +26,15 @@ internal sealed class FormDesignDataBusiness /// DB 저장 허용 여부 — SaveMode='Db' 명시 설정 public bool CanSaveToDb => CanUseDb && Config.IsDbSaveMode(); + + /// 현재 HIS 사용자 — 미인증이면 HisUser.Unknown + public HisUser User => UserSession.Current; + + /// DB 쓰기 가능 — 접속 + SaveMode='Db' + 사용자 확인. 감사 컬럼에 남길 주체가 있어야 쓴다 + public bool CanWriteDb => CanSaveToDb && UserSession.IsAuthenticated; + + /// 감사 컬럼 기록 값 — 쓰기 직전에만 접근한다(미인증이면 예외) + 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); } /// 서식 코드 등록 여부(E_ShtMst) public bool SheetExists(string shtCod) => DbStore().SheetExists(shtCod); + /// 서식생성기 사용 허용 여부(E_ShtMst.ShtUsrDesYon) — 레거시 열기 게이트 + public SheetDesignPermission GetDesignPermission(string shtCod) => DbStore().GetDesignPermission(shtCod); + /// 신규 서식 최소 등록 — 디자이너 서식 관행 기본값 public void RegisterSheet(string shtCod, string korName, string? clsCod) - => DbStore().RegisterSheet(shtCod, korName, clsCod, Environment.UserName); + => DbStore().RegisterSheet(shtCod, korName, clsCod, AuditUid); /// 상용구 저장소(E_SHTWRDMST) — DB 접속 필요 public RecordWordStore RecordWords() diff --git a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs index 47c8c7c..b4c723f 100644 --- a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs +++ b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs @@ -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 } } + /// + /// 권한 게이트 영향 범위(--db-gate) — ShtUsrDesYon 값별 서식 수를 집계한다. + /// 게이트 도입 전에 "갑자기 서식이 안 열린다"는 문의 규모를 미리 알기 위한 읽기 전용 진단. + /// + 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 { "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; + } + } + /// 테이블 컬럼 스키마 조회(--db-columns) — NOT NULL/타입 확인(읽기 전용) 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("저장 후 활성 디자인 조회 실패"); diff --git a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs index 3c98487..2485893 100644 --- a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs +++ b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs @@ -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()) is null); + + // 25) 더티 추적 — 종료 가드의 판단 근거(별도 문서로 격리해 위 단계와 간섭 없게) var dirtyDoc = new DesignerViewModel(business.CreateNew()); Check("더티: 초기 상태는 clean", !dirtyDoc.Undo.IsDirty); dirtyDoc.AddControlAt("Label", new Point(50, 50)); diff --git a/src/SheetMe.Designer/Services/ConfigLoader.cs b/src/SheetMe.Designer/Services/ConfigLoader.cs index fdeb211..a522e91 100644 --- a/src/SheetMe.Designer/Services/ConfigLoader.cs +++ b/src/SheetMe.Designer/Services/ConfigLoader.cs @@ -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; } diff --git a/src/SheetMe.Designer/Services/SessionBootstrap.cs b/src/SheetMe.Designer/Services/SessionBootstrap.cs new file mode 100644 index 0000000..d9aaaf2 --- /dev/null +++ b/src/SheetMe.Designer/Services/SessionBootstrap.cs @@ -0,0 +1,71 @@ +using SheetMe.Data.Stores; + +namespace SheetMe.Designer.Services; + +/// +/// 기동 시 사용자 세션 확정. 레거시 frmSheetDesigner.vb:892 와 동일하게 +/// "인자로 받은 UidCod 가 유효하지 않으면 기동 중단" 이다 — 호출자가 HIS 이므로 +/// 조용히 읽기 전용으로 떨어뜨리면 사용자가 저장이 안 되는 이유를 알 수 없다. +/// +public static class SessionBootstrap +{ + #region Methods + /// + /// 세션 초기화. 진행 가능하면 true, 기동을 중단해야 하면 false 와 함께 사유를 돌려준다. + /// + /// 인자에 UidCod 있음 → 조회 성공 시 정상 세션 / 실패 시 기동 중단. + /// 인자 없음 → 개발 폴백(His:DevUidCod)을 시도하되 실패해도 미인증으로 계속(DB 쓰기만 차단). + /// 접속 문자열이 없으면(파일 전용 모드) 조회 자체를 건너뛴다. + /// + 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; + } + + /// 조회 실패(접속 오류 등)를 null 로 흡수 — 기동 경로에서 예외로 죽지 않게 + private static HisUser? SafeResolve(string connectionString, string uidCod) + { + try + { + return new UserContextStore(connectionString).Resolve(uidCod); + } + catch + { + return null; + } + } + #endregion +} diff --git a/src/SheetMe.Designer/Services/StartupArguments.cs b/src/SheetMe.Designer/Services/StartupArguments.cs new file mode 100644 index 0000000..ae855a5 --- /dev/null +++ b/src/SheetMe.Designer/Services/StartupArguments.cs @@ -0,0 +1,38 @@ +namespace SheetMe.Designer.Services; + +/// HIS 기동 인자 — 레거시 규약 "UidCod,ComNum,ShtCod" (콤마 구분 단일 argv) +/// HIS 사용자 코드 — 감사 컬럼의 출처 +/// 내원번호 — 레거시에서도 선언·대입만 있고 사용처가 없는 죽은 인자. 형상 보존용 +/// 기동 시 자동으로 열 서식 코드 +/// 원문 — 단일 인스턴스 포워딩에 그대로 전달 +public sealed record HisLaunch(string UidCod, int? ComNum, string ShtCod, string Raw); + +/// +/// 기동 인자 파싱. 레거시 frmSheetDesigner.vb:877 의 관용 파싱 +/// ((Command() & "".PadRight(10, ",")).Split(","))을 이식했다 — +/// 필드가 모자라도 인덱스가 보장되므로 인자 형상이 변해도 깨지지 않는다. +/// +public static class StartupArguments +{ + #region Methods + /// 진단 플래그 기동인지 — 첫 인자가 "--" 로 시작 + public static bool IsDiagnostic(string[] args) + => args.Length > 0 && args[0].StartsWith("--", StringComparison.Ordinal); + + /// HIS 기동 인자 — 진단 모드이거나 인자가 없으면 null + public static HisLaunch? Parse(string[] args) + => IsDiagnostic(args) || args.Length == 0 ? null : ParseRaw(string.Join(" ", args)); + + /// + /// 원문 파싱 — 단일 인스턴스 포워딩 수신부와 공용. + /// ComNum 은 레거시가 CInt 로 변환하다 비숫자에서 예외를 냈지만, 죽은 값 때문에 기동이 + /// 실패하면 안 되므로 여기서는 TryParse 로 관용 처리한다. + /// + 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 +} diff --git a/src/SheetMe.Designer/Services/UserSession.cs b/src/SheetMe.Designer/Services/UserSession.cs new file mode 100644 index 0000000..918ac6e --- /dev/null +++ b/src/SheetMe.Designer/Services/UserSession.cs @@ -0,0 +1,54 @@ +using SheetMe.Data.Stores; + +namespace SheetMe.Designer.Services; + +/// +/// 프로세스 수명 사용자 세션 — 레거시 clsCommonLib.GetInstance.UidMst 싱글턴의 대응물. +/// +/// 생성자 주입 대신 정적으로 둔 이유: MainViewModel 이 XAML DataContext 로 무인자 생성되고, +/// 무엇보다 진단 모드(--db-*)는 ViewModel 그래프 밖이라 주입으로는 감사 컬럼 기록 지점에 +/// 도달할 수 없다. 대신 이 정적을 읽는 곳을 App 초기화 / FormDesignDataBusiness / +/// MainViewModel 표시 세 군데로 제한하고, 저장소는 지금처럼 uid 를 파라미터로만 받는다. +/// +public static class UserSession +{ + #region Member Fields + /// 진단 모드 감사 식별자 — DB 에서 즉시 구분되도록(개발자 윈도우 계정명 대신) + private const string DiagnosticUid = "SMOKE"; + #endregion + + #region Properties + /// 현재 세션 사용자 — 초기화 전/미인증이면 + public static HisUser Current { get; private set; } = HisUser.Unknown; + + /// HIS 사용자로 확인되었는지 — DB 쓰기의 전제 + public static bool IsAuthenticated => Current.IsAuthenticated; + + /// 진단 모드 세션인지 + public static bool IsDiagnostic { get; private set; } + + /// + /// 감사 컬럼에 기록할 사용자 코드. 미인증 상태에서 접근하면 예외 — + /// DB 쓰기는 그 전에 로 차단되어야 한다. + /// + public static string AuditUid => IsAuthenticated + ? Current.UidCod + : throw new InvalidOperationException("사용자 컨텍스트가 없어 DB 쓰기를 수행할 수 없습니다."); + #endregion + + #region Methods + /// 세션 확정 — 기동 시 1회 + public static void Initialize(HisUser user) + { + Current = user; + IsDiagnostic = false; + } + + /// 진단 모드 식별자 부여 — --db-* 스모크 전용 + public static void InitializeDiagnostic() + { + Current = new HisUser(DiagnosticUid, "진단", string.Empty, string.Empty, string.Empty, string.Empty); + IsDiagnostic = true; + } + #endregion +} diff --git a/src/SheetMe.Designer/ViewModels/MainViewModel.cs b/src/SheetMe.Designer/ViewModels/MainViewModel.cs index fec4429..a0d946b 100644 --- a/src/SheetMe.Designer/ViewModels/MainViewModel.cs +++ b/src/SheetMe.Designer/ViewModels/MainViewModel.cs @@ -285,14 +285,60 @@ internal sealed class MainViewModel : ViewModelBase /// 이미 열린 DB 서식이면 해당 탭 활성화 — 없으면 null private DesignerViewModel? FindOpenDbDocument(string shtCod) => OpenDesigners.FirstOrDefault(d => d.IsFromDb && d.Document.FormId == shtCod); + + /// + /// 서식생성기 사용 권한 확인(E_ShtMst.ShtUsrDesYon) — 레거시 DisplaySheetDesign 게이트 이식. + /// 거부 메시지는 레거시 문구를 그대로 재현한다(사용자 재교육 비용 0, 조치 안내가 이미 담겨 있다). + /// + 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 - 열기/저장 + /// 기동 인자로 받은 서식 코드 — 창 로드 후 자동으로 연다(App.OnStartup 이 세팅) + 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 } } - /// DB 서식 열기 공통 — 이미 열려 있으면 탭 활성화 + /// + /// DB 서식 열기 공통 — 이미 열려 있으면 탭 활성화. + /// 모든 열기 경로(대화상자·목록 더블클릭·기동 인자)가 여기로 수렴하므로 권한 게이트를 여기 둔다. + /// 레거시는 게이트가 진입점마다 흩어져 있어 이력 노드 클릭·MessageQueue 두 경로로 우회가 가능했다. + /// 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(); diff --git a/src/SheetMe.Designer/Views/MainView.xaml b/src/SheetMe.Designer/Views/MainView.xaml index 70fe856..97f3c82 100644 --- a/src/SheetMe.Designer/Views/MainView.xaml +++ b/src/SheetMe.Designer/Views/MainView.xaml @@ -278,6 +278,22 @@ + + + + + + diff --git a/src/SheetMe.Designer/Views/RecordWordDialogView.xaml.cs b/src/SheetMe.Designer/Views/RecordWordDialogView.xaml.cs index 9001395..cf22c4f 100644 --- a/src/SheetMe.Designer/Views/RecordWordDialogView.xaml.cs +++ b/src/SheetMe.Designer/Views/RecordWordDialogView.xaml.cs @@ -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 words = new(); #endregion #region Constructors - public RecordWordDialogView(RecordWordStore store, string shtCod, string sheetTitle) + /// 감사 컬럼(ShtUpdUid/ShtFstUid)에 기록할 HIS 사용자 코드 + /// DB 쓰기 가능 여부 — 미인증/게이트 차단이면 읽기 전용으로 연다 + 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 - 가드 + /// 쓰기 전 확인 — 미인증이면 안내 후 차단(감사 컬럼에 남길 주체가 없다) + 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)