싸인 이미지 SMB 직접 읽기 — 공유 매핑 구현(서버측 SMB 개방 대기)

사용자 결정(②SMB 직접 접근)에 따라 구현. 실측 확인:
- 파일서버 = DB 서버와 같은 호스트, 전송 데몬 포트 2002 는 열림
- <b>SMB 445·139 는 닫힘</b>, UNC·관리공유 접근 모두 실패 → 서버측 조치 필요
- 서명 경로는 서버측 절대경로 D:\MsystechHIS\{사이트}\... (설정 루트 E:\msys\ 와 다름)

그래서 공유가 열리는 즉시 동작하도록 매핑을 넣었다:
Images:SignatureShareMap 에 "서버접두=UNC접두" 를 두면 해석 순서가
①레거시 캐시 ②공유(UNC) ③직접 경로가 된다. 접두는 대소문자 무시로
<b>긴 것부터</b> 맞추고(짧은 접두가 정확한 대응을 묻지 않게), '=' 없는
설정 오타는 무시한다(반쪽 경로를 만들면 조용히 틀린다). 자격증명은
설정에 담지 않는다 — 공유 접근은 단말 로그인 계정으로 붙는다.

진단 --db-filecfg 확장: 접속 <b>대상</b>(Type·IP·Port·FileDirectory)과
경로 접두 분포를 찍고(자격증명은 길이만), 공유 매핑별 접근 가능 여부와
파일서버 SMB(445) 개방 여부를 판정해 무엇을 열어야 하는지 알려 준다.

- dotnet test 358/358 (매핑 4건 추가: 치환·긴 접두 우선·오타 무시·해석 순서)
- --edit-smoke 실패 0 · --db-patient ①~㊲ 전건 통과
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Msystech
2026-08-20 12:40:56 +09:00
co-authored by Claude Fable 5
parent a0333791b6
commit 345b2e1da1
10 changed files with 214 additions and 12 deletions
+76 -5
View File
@@ -1614,10 +1614,13 @@ public static class DbSmoke
{
var key = reader.GetValue(0)?.ToString()?.Trim() ?? string.Empty;
var value = reader.IsDBNull(1) ? string.Empty : reader.GetValue(1).ToString()!.Trim();
// Type 만 값을 찍는다 — 나머지(IP·Port·User·Pwd)는 접속 정보라 길이만
lines.Add(string.Equals(key, "Type", StringComparison.OrdinalIgnoreCase)
? $" 설정 {key} = {value}"
: $" 설정 {key} = ({value.Length}자, 값은 기록하지 않는다)");
// 접속 <b>대상</b>(Type·IP·Port·FileDirectory)은 값을 찍는다 — SMB 직접 접근
// 가능성을 판단하려면 주소와 공유 이름이 필요하다.
// <b>자격증명(User·Pwd)은 길이만</b> — 값은 어디에도 남기지 않는다.
var secret = key is "Pwd" or "User" or "Password" or "Account";
lines.Add(secret
? $" 설정 {key} = ({value.Length}자, 값은 기록하지 않는다)"
: $" 설정 {key} = {value}");
if (string.Equals(key, "Type", StringComparison.OrdinalIgnoreCase))
{
mode = value;
@@ -1652,6 +1655,23 @@ public static class DbSmoke
"SELECT v FROM (SELECT UidImgPth v FROM M_UidMst"
+ " WHERE UidImgPth IS NOT NULL AND TRIM(UidImgPth) <> ' ' ORDER BY UidCod) WHERE ROWNUM = 1");
lines.Add($" M_UidMst.UidImgPth 채워진 행: {signed}");
// 폴더 구조는 개인정보가 아니다 — 파일명(사용자 코드가 섞일 수 있다)만 뗀다.
// 이 접두가 FileDirectory(서버측 루트)와 어떻게 대응하는지가 SMB 매핑의 근거다.
using (var command = connection.CreateCommand())
{
command.CommandText =
"SELECT folder, COUNT(*) c FROM (SELECT SUBSTR(UidImgPth, 1,"
+ " INSTR(UidImgPth, '\\', -1)) folder FROM M_UidMst"
+ " WHERE UidImgPth IS NOT NULL AND TRIM(UidImgPth) <> ' ')"
+ " GROUP BY folder ORDER BY COUNT(*) DESC";
using var reader = command.ExecuteReader();
var shown = 0;
while (reader.Read() && shown < 5)
{
lines.Add($" 경로 접두: {reader.GetValue(0)} ({reader.GetValue(1)}건)");
shown++;
}
}
// 경로 <b>모양</b>만 남긴다 — 사람 이름이 섞일 수 있어 값 자체는 안 찍는다
var shape = sample.Length == 0 ? "없음"
: sample.StartsWith(@"\\", StringComparison.Ordinal) ? "UNC 공유(\\\\서버\\...)"
@@ -1660,7 +1680,58 @@ public static class DbSmoke
: sample.Contains('/') ? "슬래시 상대경로" : "구분자 없음";
lines.Add($" 경로 모양: {shape} (길이 {sample.Length}자, 값은 기록하지 않는다)");
// ④ 병원 로고·직인도 같은 축이다
// ④ 공유(SMB) 직접 접근이 실제로 되는가 — OCX 전송을 대신할 유일한 경로다.
// 설정이 없으면 "무엇을 설정해야 하는지"를 알려 준다.
var map = config.SignatureShareMap;
if (map.Length == 0)
{
lines.Add(" 공유 매핑: 설정 없음(Images:SignatureShareMap)");
lines.Add(" → 예: \"D:\\MsystechHIS=\\\\<파일서버>\\<공유이름>\" (서버 경로 접두 = UNC 접두)");
}
else
{
foreach (var entry in map)
{
var at = entry.IndexOf('=', StringComparison.Ordinal);
var unc = at > 0 ? entry[(at + 1)..].Trim() : string.Empty;
var reachable = unc.Length > 0 && System.IO.Directory.Exists(unc);
lines.Add($" 공유 매핑: {entry} → 접근 {(reachable ? "" : "")}");
}
// 실제 경로 하나로 끝까지 확인한다 — 매핑이 맞아도 파일이 없으면 소용없다
if (sample.Length > 0)
{
var mapped = SheetMe.Core.Catalog.SignatureImagePath.MapToShare(sample, map);
lines.Add(mapped.Length == 0
? " 표본 경로: 매핑되는 접두가 없다(접두를 실제 저장 경로에 맞출 것)"
: $" 표본 경로 매핑 결과: 파일 {(System.IO.File.Exists(mapped) ? "" : "")}"
+ " (경로는 기록하지 않는다)");
}
}
// 파일서버가 SMB 를 열어 두었는지 — 닫혀 있으면 매핑을 아무리 맞춰도 안 된다.
// 주소는 접속 설정의 IP 를 그대로 쓴다(자격증명은 쓰지 않는다 — 단말 계정으로 붙는다).
var serverIp = FirstText(config.ConnectionString,
"SELECT DtlCodNam FROM M_DtlMst WHERE DtlTblCod = 'EMR_FileTransfer' AND DtlCod = 'IP'");
if (serverIp.Length > 0)
{
var open = false;
try
{
using var probe = new System.Net.Sockets.TcpClient();
open = probe.ConnectAsync(serverIp, 445).Wait(TimeSpan.FromSeconds(3));
}
catch
{
open = false;
}
lines.Add($" 파일서버 {serverIp} SMB(445): {(open ? "" : "")}");
if (!open)
{
lines.Add(" → 서버에서 SMB 공유를 열어야 이미지 직접 읽기가 가능하다"
+ "(전송 데몬 포트만 열려 있으면 우리는 못 받는다)");
}
}
// ⑤ 병원 로고·직인도 같은 축이다
var logo = FirstText(config.ConnectionString,
"SELECT COUNT(*) FROM M_HspMst WHERE HspLgoPth IS NOT NULL AND TRIM(HspLgoPth) <> ' '");
lines.Add($" M_HspMst.HspLgoPth 채워진 행: {(logo.Length == 0 ? " " : logo)}");
@@ -50,6 +50,15 @@ public static class ConfigLoader
config.DevUidCod = root["His:DevUidCod"] ?? string.Empty;
config.GridSize = ReadInt(root["Designer:GridSize"], 4);
config.SnapThreshold = ReadInt(root["Designer:SnapThreshold"], 6);
// 싸인 이미지 공유 매핑 — 배열(Images:SignatureShareMap:0…) 또는 세미콜론 한 줄 둘 다 받는다.
// 병원 배포는 ini·json 을 손으로 고치는 일이 많아 한 줄 형태가 실수를 줄인다.
config.SignatureShareMap = root.GetSection("Images:SignatureShareMap").GetChildren()
.Select(c => c.Value ?? string.Empty)
.Concat((root["Images:SignatureShareMap"] ?? string.Empty)
.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.Where(v => v.Contains('=', StringComparison.Ordinal))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
return config;
}
@@ -214,6 +214,9 @@ public static class PatientIdentity
/// <summary>HIS 설치 경로 — 레거시 싸인 캐시 폴더(ServerInfo ini 의 Install\Path)</summary>
public static string? InstallPath() => SheetMe.Data.Config.ServerInfoReader.InstallPath();
/// <summary>파일서버 공유 매핑 — 설정(Images:SignatureShareMap)</summary>
public static IReadOnlyList<string> ShareMap() => ConfigService.Current.SignatureShareMap;
/// <summary>문맥 저장소 공급자 — 입원과·퇴원과의 시점별 진료과 조회용</summary>
public static Func<PatientContextStore?> ContextStoreSource()
{
@@ -77,7 +77,8 @@ public static class PatientSession
PatientIdentity.PathologyDoctorSource(), PatientIdentity.LabDoctorSource(),
PatientIdentity.ExtraStoreSource(), PatientIdentity.ContextStoreSource(),
PatientIdentity.BstSourceFor(picked), PatientIdentity.ChartVitalSourceFor(picked),
PatientIdentity.SignatureStoreSource(), PatientIdentity.InstallPath())
PatientIdentity.SignatureStoreSource(), PatientIdentity.InstallPath(),
PatientIdentity.ShareMap())
: session;
return (tags, new MDataTableRunner(document, Variables()));
}
@@ -234,6 +234,9 @@ public sealed class PatientTagResolver : ITagValueResolver
/// <summary>HIS 설치 경로 — 레거시 싸인 캐시 폴더의 뿌리(ServerInfo ini)</summary>
private readonly string? installPath;
/// <summary>파일서버 공유 매핑(서버경로=UNC) — 설정에서 온다</summary>
private readonly IReadOnlyList<string>? shareMap;
/// <summary>환자 부가정보(자기 SQL 7종) 공급자 — 태그가 물을 때 만든다</summary>
private readonly Func<PatientExtraStore?>? extraStoreSource;
@@ -273,7 +276,8 @@ public sealed class PatientTagResolver : ITagValueResolver
Func<string, string>? bstSource = null,
Func<string, string, string[], string, string>? chartVitalSource = null,
Func<SignatureStore?>? signatureStoreSource = null,
string? installPath = null)
string? installPath = null,
IReadOnlyList<string>? shareMap = null)
{
this.context = context;
this.next = next;
@@ -296,6 +300,7 @@ public sealed class PatientTagResolver : ITagValueResolver
this.chartVitalSource = chartVitalSource;
this.signatureStoreSource = signatureStoreSource;
this.installPath = installPath;
this.shareMap = shareMap;
}
#endregion
@@ -1099,7 +1104,7 @@ public sealed class PatientTagResolver : ITagValueResolver
}
// ② 그 경로의 파일을 이 단말에서 찾는다(레거시 캐시 → 경로 자체)
var found = SignatureImagePath.Resolve(remote, installPath);
var found = SignatureImagePath.Resolve(remote, installPath, null, shareMap);
return found.Origin switch
{
SignatureImageOrigin.None => new TagValue(false,
@@ -234,7 +234,8 @@ public partial class PreviewWindow : Window
PatientIdentity.PathologyDoctorSource(), PatientIdentity.LabDoctorSource(),
PatientIdentity.ExtraStoreSource(), PatientIdentity.ContextStoreSource(),
PatientIdentity.BstSourceFor(picked), PatientIdentity.ChartVitalSourceFor(picked),
PatientIdentity.SignatureStoreSource(), PatientIdentity.InstallPath());
PatientIdentity.SignatureStoreSource(), PatientIdentity.InstallPath(),
PatientIdentity.ShareMap());
// 러너를 <b>새로</b> 만든다. 같은 러너를 재사용하면 옛 환자의 조회 결과가 캐시에 남아
// 환자를 바꿨는데 표는 그대로가 된다 — 태그만 바뀌고 표는 안 바뀌면 아무도 눈치채지 못한다.
fields = new MDataTableRunner(designer.Document,
+5
View File
@@ -11,5 +11,10 @@
"Designer": {
"GridSize": 4,
"SnapThreshold": 6
},
"Images": {
"//": "싸인·직인 이미지를 파일서버 공유(UNC)에서 직접 읽기 위한 경로 대응. DB 는 서버측 절대경로(예: D:\\MsystechHIS\\002\\...)를 들고 있고, 레거시는 그 경로를 ActiveX 전송 데몬으로 받아 온다 — 우리는 같은 파일을 공유로 읽는다. 병원마다 접두가 다르므로 설치 시 채운다. 진단 --db-filecfg 가 현재 상태와 필요한 설정을 알려 준다.",
"//예시": "\"SignatureShareMap\": [ \"D:\\\\MsystechHIS=\\\\\\\\192.168.100.222\\\\MsystechHIS\" ] 또는 한 줄로 \"D:\\\\MsystechHIS=\\\\\\\\서버\\\\공유;E:\\\\msys=\\\\\\\\서버\\\\msys\"",
"SignatureShareMap": []
}
}