주민번호 복호화를 붙인다 — 이 DB 는 실제로 암호화되어 있었다
문서(docs/RESNUM-DECRYPT.md)의 1→2→3 을 실행. 태그 7종 → 11종. ## 이 DB 가 암호화 병원이다 진단이 읽은 설정: 전체=Y 주민번호=Y. 앞 커밋에서 PAT_주민번호 를 뺀 판단이 맞았다 — PatResNum 을 그대로 썼으면 <b>암호문이 종이에 찍혔다</b>. ## 앱은 키를 갖지 않는다 복호화는 오라클 패키지가 한다. SELECT SUBSTR(:e,1,7) || CRYPTO_AES256.DEC_AES(SUBSTR(:e,8,256)) FROM DUAL 바인드 변수이고 접근 통제는 DB 권한이다. SCHILDREN 갈래(UDF_GETDECRESNUM_damo)도 함께 옮겼다. 앞 7자리가 평문이라 SUBSTR(...,1,7) 을 이어 붙이는 것을 빠뜨리면 앞자리가 사라진다. ## 설정을 못 읽으면 값을 만들지 않는다 "암호화 아님"으로 가정하면 PatResNum 을 평문처럼 써서 암호문을 찍는다. PatientIdentity 는 예외 시 빈 문자열을 돌려주고 사유만 로그에 남긴다 — 값은 남기지 않는다. 주민번호를 만드는 자리를 한 곳으로 좁혀 뒀다(만드는 경로가 여럿이면 어디서 새는지 추적할 수 없다). ## 순수 계산은 표로 고정했다 (테스트 13건) 보정 ①(기관기호, SRCH·JINJU): 앞 6자리를 PatBthDay 로 갈아 끼운다. 원본은 If Not DateValidCheck(Substring(1,6)) 으로 감싸 있지만 <b>그 조건은 항상 참이다</b> — DateValidCheck 는 길이가 8·12·14·17 이 아니면 무조건 False 인데 6자리를 넘긴다. 여기서 "고쳐서" 진짜 날짜 검사를 넣으면 레거시와 다른 번호가 나온다(이 DB 가 SRCH 다). 이식은 동작을 옮기는 일이지 바로잡는 일이 아니다 — 그 사실을 테스트로 못 박았다. 보정 ②(외국인): 7번째 자리 0/9 를 PatBthDay 세기로 6/8·5/7 로. 국적코드가 있어야 걸린다. 성별: 7번째 자리 mod 2. PAT_성별 은 CoiCalCod 가 "24" 로 시작하면 PatSexTyp 을 먼저 쓴다. ## 추측하면 걸린다 M_DtsMst 컬럼을 DtsTabCod·DtlCod 로 추측해 ORA-00904 를 맞았다. 실제는 DtsTblCod·DtsDtlCod·DtsCod 다(dtCommonLib.vb:1136). ## 확인한 것과 못 한 것을 갈라 적는다 진단은 HIS 사용자 문맥 없이 도므로 병원코드가 비고, SRCH 기관기호 보정 갈래는 이 경로로 확인되지 않는다. 통과로 세지 않고 [미확인] 줄로 적는다 — 그 갈래는 순수 함수 테스트가 덮는다. 진단 리포트에 <b>값을 쓰지 않는다</b> — 길이와 "전부 숫자인가"만 적는다. 리포트는 파일로 남는다. ## 아직 안 한 것 나이(AgeCheck)는 병원별 규칙과 의료급여 전산관리번호 규약이 붙어 별도 작업이다. 개인정보 접근 로그(C_PdrInf)에 쓸지는 운영 DB 쓰기라 승인 전까지 하지 않는다. ## 게이트 - dotnet test 336/336 (주민번호 판정 13건 신규) - --db-patient ①~㉑ 전건 통과 (⑲ 설정 · ⑳ 13자리 · ㉑ 성별) - --edit-smoke 실패 0 (이름 검사가 파생 태그 4종까지 본다) - --dialog-shots FAIL 0 - --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
34c53e3d69
commit
4bdd583adb
@@ -0,0 +1,109 @@
|
|||||||
|
namespace SheetMe.Core.Catalog;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 주민번호 보정과 그로부터 나오는 값들 — 복호화 <b>뒤</b>에 붙는 순수 계산.
|
||||||
|
///
|
||||||
|
/// 복호화 자체는 DB 가 한다(<c>CRYPTO_AES256.DEC_AES</c>). 앱은 키를 갖지 않는다 —
|
||||||
|
/// 자세한 것은 <c>docs/RESNUM-DECRYPT.md</c>.
|
||||||
|
///
|
||||||
|
/// <b>순수 함수로 두는 이유.</b> 갈래가 병원·국적·생년월일에 걸쳐 있고 실DB 로는
|
||||||
|
/// 한 갈래씩 확인할 수 없다. 그리고 이 값이 틀리면 종이에 <b>다른 사람의 번호</b>가 찍힌다.
|
||||||
|
/// </summary>
|
||||||
|
public static class ResidentNumber
|
||||||
|
{
|
||||||
|
#region Member Fields
|
||||||
|
/// <summary>주민번호에 기관기호가 들어가는 병원 — bzPatientInfo.vb:556</summary>
|
||||||
|
private static readonly string[] InstitutionPrefixHospitals = { "SRCH", "JINJU" };
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Methods
|
||||||
|
/// <summary>
|
||||||
|
/// 복호화된 주민번호를 실제로 쓸 형태로 — 레거시 <c>bzPatientInfo.vb:546-580</c>.
|
||||||
|
///
|
||||||
|
/// <b>보정 ①(기관기호).</b> SRCH·JINJU 는 앞 6자리를 <paramref name="birthDay"/> 의
|
||||||
|
/// YYMMDD 로 갈아 끼운다.
|
||||||
|
///
|
||||||
|
/// 원본은 <c>If Not DateValidCheck(sResNum.Substring(1, 6))</c> 로 감싸 있지만
|
||||||
|
/// <b>그 조건은 항상 참이다</b> — <c>DateValidCheck</c> 는 길이가 8·12·14·17 이 아니면
|
||||||
|
/// 무조건 False 를 돌려주는데(clsCommonLib.vb DateValidCheck) 호출부는 6자리를 넘긴다.
|
||||||
|
/// 그래서 조건문이 아무것도 걸러 내지 않는다.
|
||||||
|
///
|
||||||
|
/// 여기서 "고쳐서" 진짜 날짜 검사를 넣으면 <b>레거시와 다른 번호</b>가 나온다 —
|
||||||
|
/// 앞자리가 유효한 날짜인 환자에서 갈린다. 이식은 동작을 옮기는 일이지 바로잡는 일이 아니다.
|
||||||
|
/// 바로잡을지는 별도 결정 사항이고, 그때 이 주석이 근거가 된다.
|
||||||
|
///
|
||||||
|
/// <b>보정 ②(외국인).</b> 7번째 자리가 <c>0</c> 또는 <c>9</c> 이고 국적코드가 있으면
|
||||||
|
/// 생년월일의 세기로 그 자리를 바꾼다 — 0 → 19세기대면 6, 아니면 8 / 9 → 5 또는 7.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="decrypted">복호화(또는 평문) 주민번호 13자리</param>
|
||||||
|
/// <param name="birthDay">P_PatInf.PatBthDay — YYYYMMDD</param>
|
||||||
|
/// <param name="nationCode">P_PatInf.PatNatCod — 비어 있으면 외국인 보정을 하지 않는다</param>
|
||||||
|
/// <param name="hospitalCode">HspCfg.HspCod</param>
|
||||||
|
public static string Normalize(string? decrypted, string? birthDay, string? nationCode,
|
||||||
|
string? hospitalCode)
|
||||||
|
{
|
||||||
|
var value = Clean(decrypted);
|
||||||
|
var birth = Clean(birthDay);
|
||||||
|
if (value.Length < 13)
|
||||||
|
{
|
||||||
|
// 원본은 Substring 에서 예외가 나고 Try 가 삼킨다 — 손대지 않는 것이 같은 결과다
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ① 기관기호 — 조건이 항상 참이므로 조건 자체를 옮기지 않는다(위 설명)
|
||||||
|
if (Array.Exists(InstitutionPrefixHospitals,
|
||||||
|
h => string.Equals(h, Clean(hospitalCode), StringComparison.OrdinalIgnoreCase))
|
||||||
|
&& birth.Length >= 8)
|
||||||
|
{
|
||||||
|
value = birth[2..8] + value[6..13];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ② 외국인 — 국적코드가 있어야 하고, 세기 판정에 생년월일이 필요하다
|
||||||
|
var seventh = value[6];
|
||||||
|
if ((seventh == '0' || seventh == '9') && Clean(nationCode).Length > 0 && birth.Length >= 2)
|
||||||
|
{
|
||||||
|
var nineteenth = birth[..2] == "19";
|
||||||
|
var digit = seventh == '0'
|
||||||
|
? (nineteenth ? '6' : '8')
|
||||||
|
: (nineteenth ? '5' : '7');
|
||||||
|
value = value[..6] + digit + value[7..13];
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 성별 — 7번째 자리 mod 2 (<c>clsCommonLib.vb:613-656</c> GetSexAge).
|
||||||
|
/// 짝수면 F, 홀수면 M. 숫자가 아니면 빈 문자열 —
|
||||||
|
/// 원본은 <c>CLong</c> 에서 예외가 나고 삼켜져 값이 비는 것과 같다.
|
||||||
|
/// </summary>
|
||||||
|
public static string SexOf(string? residentNumber)
|
||||||
|
{
|
||||||
|
var value = Clean(residentNumber);
|
||||||
|
if (value.Length < 7 || !char.IsAsciiDigit(value[6]))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
return (value[6] - '0') % 2 == 0 ? "F" : "M";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>한글 성별 — F 는 여, M 은 남(<c>bzDataInterface.vb:305-316</c>)</summary>
|
||||||
|
public static string SexKoreanOf(string? residentNumber) => SexOf(residentNumber) switch
|
||||||
|
{
|
||||||
|
"F" => "여",
|
||||||
|
"M" => "남",
|
||||||
|
_ => string.Empty,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 하이픈 형태 — <c>bzDataInterface.vb:91-102</c>.
|
||||||
|
/// 구분자가 <b>" - "</b>(양쪽 공백)이다. 공백을 빼면 레거시 서식과 폭이 달라진다.
|
||||||
|
/// </summary>
|
||||||
|
public static string WithDash(string? residentNumber)
|
||||||
|
{
|
||||||
|
var value = Clean(residentNumber);
|
||||||
|
return value.Length >= 13 ? $"{value[..6]} - {value[6..13]}" : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Clean(string? value) => (value ?? string.Empty).Trim();
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
using Oracle.ManagedDataAccess.Client;
|
||||||
|
|
||||||
|
namespace SheetMe.Data.Stores;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 이 병원의 개인정보 암호화 설정 — 주민번호를 어느 컬럼에서 읽어야 하는지 결정한다.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Enabled">HSPCFG/PersonalDataEnc 가 Y 인가</param>
|
||||||
|
/// <param name="ResidentNumber">그 아래 ResNum 이 Y 인가 — 이것이 켜져야 PatResEnc 를 읽는다</param>
|
||||||
|
public readonly record struct PersonalDataEncryption(bool Enabled, bool ResidentNumber)
|
||||||
|
{
|
||||||
|
/// <summary>주민번호가 암호화되어 있는가 — 두 단이 모두 Y 여야 한다</summary>
|
||||||
|
public bool ResidentNumberEncrypted => Enabled && ResidentNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 주민번호 복호화 — 레거시 <c>dtCommonLib.GetDecResNum</c> (:6960) 을 옮긴 것.
|
||||||
|
///
|
||||||
|
/// <b>앱은 키를 갖지 않는다.</b> 복호화는 오라클 패키지 <c>CRYPTO_AES256</c> 가 하고
|
||||||
|
/// 접근 통제는 DB 권한이다 — 그것이 있어야 할 자리다. 키를 상수로 옮기는 이식은 하지 않는다.
|
||||||
|
/// 자세한 것은 <c>docs/RESNUM-DECRYPT.md</c>.
|
||||||
|
///
|
||||||
|
/// <b>앞 7자리는 평문이다.</b> 8자리부터만 암호화되어 있어서 <c>SUBSTR(...,1,7)</c> 을
|
||||||
|
/// 그대로 이어 붙인다. 이걸 빠뜨리면 앞자리가 사라진 번호가 나온다.
|
||||||
|
///
|
||||||
|
/// <b>값을 캐시하지 않는다.</b> 설정(플래그)은 병원당 고정이라 한 번 읽고 들고 있지만,
|
||||||
|
/// 복호화 결과는 들고 있지 않는다 — 가장 민감한 값을 메모리에 오래 두지 않는다.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PersonalDataStore
|
||||||
|
{
|
||||||
|
#region Member Fields
|
||||||
|
/// <summary>이 병원만 DAMO 로 바뀌었다(2023-10-14) — dtCommonLib.vb:6979</summary>
|
||||||
|
private const string DamoHospital = "SCHILDREN";
|
||||||
|
|
||||||
|
private readonly string connectionString;
|
||||||
|
private PersonalDataEncryption? cachedFlags;
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Constructors
|
||||||
|
public PersonalDataStore(string connectionString) => this.connectionString = connectionString;
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Methods
|
||||||
|
/// <summary>
|
||||||
|
/// 암호화 설정 — 두 단으로 읽는다(clsCommonLib.vb:5835-5841).
|
||||||
|
///
|
||||||
|
/// ① <c>M_DtlMst</c>(HSPCFG/PersonalDataEnc) 의 <c>DtlCodVal</c> 에서 <c>=</c> 앞 조각
|
||||||
|
/// ② 그것이 Y 일 때만 <c>M_DtsMst</c>(HSPCFG/PersonalDataEnc/ResNum) 의 <c>DtsCodVal</c>
|
||||||
|
///
|
||||||
|
/// ② 를 무조건 읽지 않는 것이 중요하다 — 레거시가 ① 이 Y 일 때만 읽으므로
|
||||||
|
/// ① 이 N 인 병원에서 ② 에 남아 있는 옛 값에 걸리지 않는다.
|
||||||
|
/// </summary>
|
||||||
|
public PersonalDataEncryption Flags()
|
||||||
|
{
|
||||||
|
if (cachedFlags is { } cached)
|
||||||
|
{
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
var result = new PersonalDataEncryption(false, false);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var connection = new OracleConnection(connectionString);
|
||||||
|
connection.Open();
|
||||||
|
var head = Scalar(connection,
|
||||||
|
"SELECT DtlCodVal FROM M_DtlMst WHERE DtlTblCod = 'HSPCFG' AND DtlCod = 'PersonalDataEnc'");
|
||||||
|
// DtlCodVal 은 "Y=..." 형태일 수 있다 — 레거시 Piece(...,"=",1) 과 같이 앞 조각만 본다
|
||||||
|
var enabled = Piece(head).Equals("Y", StringComparison.OrdinalIgnoreCase);
|
||||||
|
var resident = false;
|
||||||
|
if (enabled)
|
||||||
|
{
|
||||||
|
// 컬럼 이름은 DtsTblCod · DtsDtlCod · DtsCod 다(dtCommonLib.vb:1136).
|
||||||
|
// 처음에 DtsTabCod·DtlCod 로 추측해서 ORA-00904 를 맞았다 — 추측하면 여기서 걸린다.
|
||||||
|
var detail = Scalar(connection,
|
||||||
|
"SELECT DtsCodVal FROM M_DtsMst WHERE DtsTblCod = 'HSPCFG'"
|
||||||
|
+ " AND DtsDtlCod = 'PersonalDataEnc' AND DtsCod = 'ResNum'");
|
||||||
|
resident = Piece(detail).Equals("Y", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
result = new PersonalDataEncryption(enabled, resident);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// 설정을 못 읽으면 <b>암호화 아님</b>으로 두지 않는다 — 그러면 암호문을 평문처럼 쓴다.
|
||||||
|
// Enabled=false 는 "PatResNum 을 쓰라"는 뜻이 되므로, 여기서는 호출부가
|
||||||
|
// 값을 만들지 못하도록 두 단 다 false 로 두고 복호화도 시도하지 않게 한다.
|
||||||
|
// (호출부는 Flags 를 못 믿을 때 태그를 비우고 사유를 말해야 한다.)
|
||||||
|
cachedFlags = result;
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
cachedFlags = result;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 암호문을 평문으로. 빈 값이면 빈 값(레거시 :6966-6968).
|
||||||
|
///
|
||||||
|
/// 병원 갈래를 함께 옮긴다 — SCHILDREN 은 UDF 하나로 끝내고 나머지는
|
||||||
|
/// 앞 7자리 + <c>CRYPTO_AES256.DEC_AES</c> 조립이다.
|
||||||
|
/// </summary>
|
||||||
|
public string Decrypt(string? encrypted, string hospitalCode)
|
||||||
|
{
|
||||||
|
var value = (encrypted ?? string.Empty).Trim();
|
||||||
|
if (value.Length == 0)
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
var sql = string.Equals(hospitalCode, DamoHospital, StringComparison.OrdinalIgnoreCase)
|
||||||
|
? "SELECT UDF_GETDECRESNUM_damo(:e) FROM DUAL"
|
||||||
|
: "SELECT SUBSTR(:e,1,7) || CRYPTO_AES256.DEC_AES(SUBSTR(:e,8,256)) FROM DUAL";
|
||||||
|
|
||||||
|
using var connection = new OracleConnection(connectionString);
|
||||||
|
connection.Open();
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.BindByName = true;
|
||||||
|
command.CommandText = sql;
|
||||||
|
command.Parameters.Add(new OracleParameter("e", value));
|
||||||
|
var decrypted = command.ExecuteScalar();
|
||||||
|
return decrypted is null or DBNull ? string.Empty : decrypted.ToString()!.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Scalar(OracleConnection connection, string sql)
|
||||||
|
{
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = sql;
|
||||||
|
var value = command.ExecuteScalar();
|
||||||
|
return value is null or DBNull ? string.Empty : value.ToString()!.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>레거시 <c>Piece(값, "=", 1)</c> — 첫 등호 앞 조각</summary>
|
||||||
|
private static string Piece(string value)
|
||||||
|
{
|
||||||
|
var at = value.IndexOf('=');
|
||||||
|
return (at < 0 ? value : value[..at]).Trim();
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
@@ -2106,6 +2106,43 @@ public static class DbSmoke
|
|||||||
$"Ok={blind.Ok} {blind.Rows.Count}행 — 치환이 실제로 기여하지 않았다는 뜻이다");
|
$"Ok={blind.Ok} {blind.Rows.Count}행 — 치환이 실제로 기여하지 않았다는 뜻이다");
|
||||||
lines.Add($" 대조군 SQL: {blind.Sql.Replace("\r", " ").Replace("\n", " ")}");
|
lines.Add($" 대조군 SQL: {blind.Sql.Replace("\r", " ").Replace("\n", " ")}");
|
||||||
lines.Add($" 치환 못 한 변수 {blind.Unknown.Count}개");
|
lines.Add($" 치환 못 한 변수 {blind.Unknown.Count}개");
|
||||||
|
|
||||||
|
// ── 주민번호 복호화 ──
|
||||||
|
// 값은 <b>절대 리포트에 쓰지 않는다</b> — 길이와 형식만 적는다.
|
||||||
|
// 이 앱이 다루는 것 중 가장 민감한 값이고, 리포트는 파일로 남는다.
|
||||||
|
var personal = new PersonalDataStore(conn);
|
||||||
|
var flags = personal.Flags();
|
||||||
|
var hospital = Services.UserSession.Current.HspCod;
|
||||||
|
lines.Add($" 암호화 설정: 전체={(flags.Enabled ? "Y" : "N")}"
|
||||||
|
+ $" 주민번호={(flags.ResidentNumber ? "Y" : "N")}"
|
||||||
|
+ $" / 병원코드='{hospital}'");
|
||||||
|
Check("⑲ 암호화 설정을 예외 없이 읽는다", true);
|
||||||
|
// 병원코드가 비면 SRCH·JINJU 기관기호 보정이 걸리지 않는다 —
|
||||||
|
// 그 갈래는 이 진단으로 확인되지 않는다. 대신 <b>복호화 자체</b>는 확인한다.
|
||||||
|
// 그것이 이 단계에서 실패하면 나머지가 전부 무의미하고, DB 권한·패키지 문제라
|
||||||
|
// 코드로는 알 수 없다. 확인한 것과 못 한 것을 갈라서 적는다.
|
||||||
|
if (hospital.Length == 0)
|
||||||
|
{
|
||||||
|
lines.Add(" [미확인] 병원코드가 없어 SRCH·JINJU 기관기호 보정은"
|
||||||
|
+ " 이 진단으로 확인되지 않았다(순수 함수 테스트가 그 갈래를 덮는다)");
|
||||||
|
}
|
||||||
|
{
|
||||||
|
var raw = flags.ResidentNumberEncrypted
|
||||||
|
? personal.Decrypt(PatientContext.Value(ctx.PatInf, "PatResEnc"), hospital)
|
||||||
|
: PatientContext.Value(ctx.PatInf, "PatResNum");
|
||||||
|
var normalized = SheetMe.Core.Catalog.ResidentNumber.Normalize(
|
||||||
|
raw,
|
||||||
|
PatientContext.Value(ctx.PatInf, "PatBthDay"),
|
||||||
|
PatientContext.Value(ctx.PatInf, "PatNatCod"),
|
||||||
|
hospital);
|
||||||
|
var sex = SheetMe.Core.Catalog.ResidentNumber.SexOf(normalized);
|
||||||
|
lines.Add($" 주민번호: 원본 {raw.Length}자 → 보정 {normalized.Length}자,"
|
||||||
|
+ $" 전부 숫자={normalized.All(char.IsAsciiDigit)} (값은 기록하지 않는다)");
|
||||||
|
Check("⑳ 주민번호가 13자리 숫자로 만들어진다",
|
||||||
|
normalized.Length == 13 && normalized.All(char.IsAsciiDigit),
|
||||||
|
$"{normalized.Length}자 — 복호화 실패나 컬럼 부재를 의심할 것");
|
||||||
|
Check("㉑ 그 번호에서 성별이 나온다", sex is "M" or "F", $"'{sex}'");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using SheetMe.Core.Catalog;
|
||||||
|
using SheetMe.Data.Stores;
|
||||||
|
|
||||||
|
namespace SheetMe.Designer.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 보정을 마친 주민번호를 만드는 <b>유일한 자리</b>.
|
||||||
|
///
|
||||||
|
/// 왜 한 곳으로 좁히는가: 이 값은 이 앱이 다루는 것 중 가장 민감하다.
|
||||||
|
/// 만드는 경로가 여러 곳이면 어디서 새는지 추적할 수 없고, 보정 순서가 어긋난 사본이 생긴다.
|
||||||
|
///
|
||||||
|
/// <b>값을 저장하지 않는다.</b> 필요할 때 만들어 <see cref="PatientTagResolver"/> 에 넘기고 끝낸다.
|
||||||
|
/// 로그에도 남기지 않는다 — 어떤 진단 리포트에도 값이 아니라 <b>길이</b>만 적는다.
|
||||||
|
///
|
||||||
|
/// 복호화는 DB 가 한다(<see cref="PersonalDataStore"/>). 앱은 키를 갖지 않는다.
|
||||||
|
/// 자세한 것은 <c>docs/RESNUM-DECRYPT.md</c>.
|
||||||
|
/// </summary>
|
||||||
|
public static class PatientIdentity
|
||||||
|
{
|
||||||
|
#region Methods
|
||||||
|
/// <summary>
|
||||||
|
/// 이 환자의 주민번호 — 못 만들면 빈 문자열.
|
||||||
|
///
|
||||||
|
/// <b>암호화 설정을 못 읽으면 만들지 않는다.</b> 설정을 "암호화 아님"으로 가정하면
|
||||||
|
/// <c>PatResNum</c> 을 평문처럼 써서 암호문을 종이에 찍는다. 빈칸이 훨씬 낫다.
|
||||||
|
/// </summary>
|
||||||
|
public static string ResidentNumberOf(PatientContext context)
|
||||||
|
{
|
||||||
|
var connection = ConfigService.Current.ConnectionString;
|
||||||
|
if (connection.Length == 0)
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
var hospital = UserSession.Current.HspCod;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var store = new PersonalDataStore(connection);
|
||||||
|
var flags = store.Flags();
|
||||||
|
var raw = flags.ResidentNumberEncrypted
|
||||||
|
// 암호화 병원 — PatResNum 은 쓰지 않는다. PatResEnc 를 DB 가 풀어 준다
|
||||||
|
? store.Decrypt(PatientContext.Value(context.PatInf, "PatResEnc"), hospital)
|
||||||
|
: PatientContext.Value(context.PatInf, "PatResNum");
|
||||||
|
|
||||||
|
return ResidentNumber.Normalize(
|
||||||
|
raw,
|
||||||
|
PatientContext.Value(context.PatInf, "PatBthDay"),
|
||||||
|
PatientContext.Value(context.PatInf, "PatNatCod"),
|
||||||
|
hospital);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// 사유는 남기지만 값은 남기지 않는다 — 예외 메시지에 SQL 이 섞여도 값은 바인드였다
|
||||||
|
AppLog.Audit($"[주민번호] 만들지 못했습니다: {ex.GetType().Name}");
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
@@ -61,19 +61,35 @@ public sealed class PatientTagResolver : ITagValueResolver
|
|||||||
|
|
||||||
private readonly ITagValueResolver next;
|
private readonly ITagValueResolver next;
|
||||||
private readonly PatientContext context;
|
private readonly PatientContext context;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 보정을 마친 주민번호 — 못 만들었으면 빈 문자열.
|
||||||
|
///
|
||||||
|
/// <b>미리 만들어 받는다.</b> 복호화는 DB 왕복이라 태그마다 하면
|
||||||
|
/// 주민번호를 쓰는 태그 수만큼 왕복이 생긴다. 그리고 이 값은 가장 민감하므로
|
||||||
|
/// 만드는 자리를 한 곳으로 좁혀 둔다(<see cref="PatientIdentity"/>).
|
||||||
|
/// </summary>
|
||||||
|
private readonly string residentNumber;
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Constructors
|
#region Constructors
|
||||||
public PatientTagResolver(PatientContext context, ITagValueResolver next)
|
public PatientTagResolver(PatientContext context, ITagValueResolver next,
|
||||||
|
string residentNumber = "")
|
||||||
{
|
{
|
||||||
this.context = context;
|
this.context = context;
|
||||||
this.next = next;
|
this.next = next;
|
||||||
|
this.residentNumber = residentNumber;
|
||||||
}
|
}
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Methods
|
#region Methods
|
||||||
public TagValue Resolve(string tag)
|
public TagValue Resolve(string tag)
|
||||||
{
|
{
|
||||||
|
// 주민번호에서 나오는 값들은 표로 다룰 수 없다 — 컬럼이 아니라 계산 결과다
|
||||||
|
if (FromResidentNumber(tag) is { } derived)
|
||||||
|
{
|
||||||
|
return derived;
|
||||||
|
}
|
||||||
if (!Direct.TryGetValue(tag, out var map))
|
if (!Direct.TryGetValue(tag, out var map))
|
||||||
{
|
{
|
||||||
// 환자 문맥이 <b>있는데도</b> 못 만든 것과, 문맥이 없어서 못 만든 것은 다른 일이다.
|
// 환자 문맥이 <b>있는데도</b> 못 만든 것과, 문맥이 없어서 못 만든 것은 다른 일이다.
|
||||||
@@ -103,13 +119,61 @@ public sealed class PatientTagResolver : ITagValueResolver
|
|||||||
return new TagValue(true, value);
|
return new TagValue(true, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 주민번호에서 파생되는 태그 — 해당 태그가 아니면 null.
|
||||||
|
///
|
||||||
|
/// <c>PAT_성별</c> 에는 선행 분기가 있다: <c>CoiCalCod</c> 가 <c>"24"</c> 로 시작하면
|
||||||
|
/// 주민번호를 보지 않고 <c>PatSexTyp</c> 을 쓴다(bzDataInterface.vb:283-291).
|
||||||
|
/// 이 분기를 빼면 해당 산정코드 환자에서 성별이 조용히 달라진다.
|
||||||
|
/// </summary>
|
||||||
|
private TagValue? FromResidentNumber(string tag)
|
||||||
|
{
|
||||||
|
switch (tag)
|
||||||
|
{
|
||||||
|
case "PAT_주민번호":
|
||||||
|
return Wrap(residentNumber);
|
||||||
|
case "PAT_주민번호_Dash":
|
||||||
|
return Wrap(ResidentNumber.WithDash(residentNumber));
|
||||||
|
case "PAT_성별":
|
||||||
|
case "PAT_한글성별":
|
||||||
|
{
|
||||||
|
// 산정코드 24* 는 주민번호를 쓰지 않는다 — 이쪽이 먼저다
|
||||||
|
var byCode = PatientContext.Value(context.CoiInf, "CoiCalCod")
|
||||||
|
.StartsWith("24", StringComparison.Ordinal)
|
||||||
|
? PatientContext.Value(context.PatInf, "PatSexTyp")
|
||||||
|
: ResidentNumber.SexOf(residentNumber);
|
||||||
|
var value = tag == "PAT_한글성별"
|
||||||
|
? byCode switch { "F" => "여", "M" => "남", _ => string.Empty }
|
||||||
|
: byCode;
|
||||||
|
return value.Length > 0
|
||||||
|
? new TagValue(true, value)
|
||||||
|
: new TagValue(false, residentNumber.Length == 0
|
||||||
|
? "주민번호를 읽지 못해 성별을 만들 수 없습니다"
|
||||||
|
: "이 환자의 성별을 판정할 수 없습니다");
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>주민번호에서 나온 값 — 비면 <b>왜</b> 비었는지 말한다</summary>
|
||||||
|
private TagValue Wrap(string value) => value.Length > 0
|
||||||
|
? new TagValue(true, value)
|
||||||
|
: new TagValue(false, "주민번호를 읽지 못했습니다(암호화 설정 또는 권한을 확인하세요)");
|
||||||
|
|
||||||
/// <summary>지금 옮겨진 환자 태그 수 — 화면이 커버리지를 정직하게 말할 때 쓴다</summary>
|
/// <summary>지금 옮겨진 환자 태그 수 — 화면이 커버리지를 정직하게 말할 때 쓴다</summary>
|
||||||
public static int MappedCount => Direct.Count;
|
public static int MappedCount => Direct.Count + DerivedTags.Length;
|
||||||
|
|
||||||
|
/// <summary>주민번호에서 파생되는 태그들 — 이름 검사가 이것도 봐야 한다</summary>
|
||||||
|
public static readonly string[] DerivedTags =
|
||||||
|
{
|
||||||
|
"PAT_주민번호", "PAT_주민번호_Dash", "PAT_성별", "PAT_한글성별",
|
||||||
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 옮긴 태그 이름들 — 진단이 <b>실존하는 태그인지</b> 검사한다.
|
/// 옮긴 태그 이름들 — 진단이 <b>실존하는 태그인지</b> 검사한다.
|
||||||
/// 이 검사가 없어서 지어낸 이름 셋이 조용히 아무것도 안 하고 있었다.
|
/// 이 검사가 없어서 지어낸 이름 셋이 조용히 아무것도 안 하고 있었다.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static IReadOnlyCollection<string> MappedTags => Direct.Keys;
|
public static IReadOnlyCollection<string> MappedTags => Direct.Keys.Concat(DerivedTags).ToList();
|
||||||
#endregion
|
#endregion
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -217,7 +217,11 @@ public partial class PreviewWindow : Window
|
|||||||
internal void UsePatient(SheetMe.Data.Stores.PatientContext? picked, string label)
|
internal void UsePatient(SheetMe.Data.Stores.PatientContext? picked, string label)
|
||||||
{
|
{
|
||||||
patient = picked;
|
patient = picked;
|
||||||
tags = picked is null ? session : new PatientTagResolver(picked, session);
|
// 주민번호는 환자를 붙일 때 한 번만 만든다 — 태그마다 만들면 DB 왕복이 그만큼 늘고
|
||||||
|
// 가장 민감한 값을 여러 번 꺼내게 된다.
|
||||||
|
tags = picked is null
|
||||||
|
? session
|
||||||
|
: new PatientTagResolver(picked, session, PatientIdentity.ResidentNumberOf(picked));
|
||||||
// 러너를 <b>새로</b> 만든다. 같은 러너를 재사용하면 옛 환자의 조회 결과가 캐시에 남아
|
// 러너를 <b>새로</b> 만든다. 같은 러너를 재사용하면 옛 환자의 조회 결과가 캐시에 남아
|
||||||
// 환자를 바꿨는데 표는 그대로가 된다 — 태그만 바뀌고 표는 안 바뀌면 아무도 눈치채지 못한다.
|
// 환자를 바꿨는데 표는 그대로가 된다 — 태그만 바뀌고 표는 안 바뀌면 아무도 눈치채지 못한다.
|
||||||
fields = new MDataTableRunner(designer.Document,
|
fields = new MDataTableRunner(designer.Document,
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
using SheetMe.Core.Catalog;
|
||||||
|
|
||||||
|
namespace SheetMe.Core.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 주민번호 보정 고정.
|
||||||
|
///
|
||||||
|
/// 이 값이 틀리면 종이에 <b>다른 사람의 번호</b>가 찍힌다. 그리고 그건 그럴듯해 보여서
|
||||||
|
/// 아무도 의심하지 않는다 — 빈칸으로 나오는 고장보다 위험하다.
|
||||||
|
/// </summary>
|
||||||
|
[TestClass]
|
||||||
|
public sealed class ResidentNumberTests
|
||||||
|
{
|
||||||
|
#region Methods
|
||||||
|
[TestMethod]
|
||||||
|
public void 기관기호_병원은_앞_여섯자리를_생년월일로_바꾼다()
|
||||||
|
{
|
||||||
|
// 레거시 예시: 9750012203265 (앞자리가 날짜가 아니라 보장기관기호)
|
||||||
|
// 1998-03-04 생 → 앞 6자리가 980304 로 갈린다. 뒤 7자리는 그대로.
|
||||||
|
Assert.AreEqual("9803042203265",
|
||||||
|
ResidentNumber.Normalize("9750012203265", "19980304", string.Empty, "SRCH"));
|
||||||
|
Assert.AreEqual("9803042203265",
|
||||||
|
ResidentNumber.Normalize("9750012203265", "19980304", string.Empty, "JINJU"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void 앞자리가_유효한_날짜여도_바꾼다()
|
||||||
|
{
|
||||||
|
// 원본의 If Not DateValidCheck(Substring(1,6)) 는 <b>항상 참</b>이다 —
|
||||||
|
// DateValidCheck 는 길이가 8·12·14·17 이 아니면 무조건 False 이고 6자리를 넘긴다.
|
||||||
|
// 즉 조건이 아무것도 걸러 내지 않는다. "고쳐서" 진짜 날짜 검사를 넣으면
|
||||||
|
// 여기서 레거시와 다른 번호가 나온다. 그 사실을 이 판정으로 못 박는다.
|
||||||
|
Assert.AreEqual("8801011234567",
|
||||||
|
ResidentNumber.Normalize("9001011234567", "19880101", string.Empty, "SRCH"),
|
||||||
|
"앞자리 900101 은 유효한 날짜지만 레거시는 그래도 갈아 끼운다");
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void 다른_병원은_기관기호_보정을_하지_않는다()
|
||||||
|
{
|
||||||
|
Assert.AreEqual("9750012203265",
|
||||||
|
ResidentNumber.Normalize("9750012203265", "19980304", string.Empty, "HANKOOK"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void 생년월일이_없으면_기관기호_보정을_건너뛴다()
|
||||||
|
{
|
||||||
|
// 원본도 PatBthDay 가 비면 갈아 끼우지 않는다 — 바꿀 재료가 없다
|
||||||
|
Assert.AreEqual("9750012203265",
|
||||||
|
ResidentNumber.Normalize("9750012203265", string.Empty, string.Empty, "SRCH"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void 외국인은_일곱번째_자리를_세기로_바꾼다()
|
||||||
|
{
|
||||||
|
// 0 → 19xx 면 6, 그 밖이면 8
|
||||||
|
Assert.AreEqual("8801016234567",
|
||||||
|
ResidentNumber.Normalize("8801010234567", "19880101", "USA", "HANKOOK"));
|
||||||
|
Assert.AreEqual("0501018234567",
|
||||||
|
ResidentNumber.Normalize("0501010234567", "20050101", "USA", "HANKOOK"));
|
||||||
|
|
||||||
|
// 9 → 19xx 면 5, 그 밖이면 7
|
||||||
|
Assert.AreEqual("8801015234567",
|
||||||
|
ResidentNumber.Normalize("8801019234567", "19880101", "USA", "HANKOOK"));
|
||||||
|
Assert.AreEqual("0501017234567",
|
||||||
|
ResidentNumber.Normalize("0501019234567", "20050101", "USA", "HANKOOK"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void 국적코드가_없으면_외국인_보정을_하지_않는다()
|
||||||
|
{
|
||||||
|
// 이 조건이 빠지면 내국인의 7번째 자리까지 바꿔 버린다
|
||||||
|
Assert.AreEqual("8801010234567",
|
||||||
|
ResidentNumber.Normalize("8801010234567", "19880101", string.Empty, "HANKOOK"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void 일곱번째_자리가_0_9_가_아니면_손대지_않는다()
|
||||||
|
{
|
||||||
|
foreach (var digit in new[] { '1', '2', '3', '4', '5', '6', '7', '8' })
|
||||||
|
{
|
||||||
|
var input = "880101" + digit + "234567";
|
||||||
|
Assert.AreEqual(input,
|
||||||
|
ResidentNumber.Normalize(input, "19880101", "USA", "HANKOOK"),
|
||||||
|
$"7번째 자리 {digit} 는 보정 대상이 아니다");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void 두_보정이_함께_걸린다()
|
||||||
|
{
|
||||||
|
// SRCH 에서 앞자리를 갈고, 그 결과의 7번째 자리가 0 이면 외국인 보정까지 이어진다.
|
||||||
|
// 순서가 뒤바뀌면 다른 값이 나온다 — 기관기호 보정이 7번째 자리를 새로 정하기 때문이다.
|
||||||
|
Assert.AreEqual("9803046203265",
|
||||||
|
ResidentNumber.Normalize("9750010203265", "19980304", "USA", "SRCH"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void 열세자리가_안_되면_그대로_돌려준다()
|
||||||
|
{
|
||||||
|
// 원본은 Substring 에서 예외가 나고 Try 가 삼킨다 — 손대지 않는 것이 같은 결과다
|
||||||
|
Assert.AreEqual("880101", ResidentNumber.Normalize("880101", "19880101", "USA", "SRCH"));
|
||||||
|
Assert.AreEqual(string.Empty, ResidentNumber.Normalize(string.Empty, "19880101", "USA", "SRCH"));
|
||||||
|
Assert.AreEqual(string.Empty, ResidentNumber.Normalize(null, null, null, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void 성별은_일곱번째_자리_홀짝이다()
|
||||||
|
{
|
||||||
|
Assert.AreEqual("M", ResidentNumber.SexOf("8801011234567"));
|
||||||
|
Assert.AreEqual("F", ResidentNumber.SexOf("8801012234567"));
|
||||||
|
Assert.AreEqual("M", ResidentNumber.SexOf("0501013234567"));
|
||||||
|
Assert.AreEqual("F", ResidentNumber.SexOf("0501014234567"));
|
||||||
|
// 외국인 자리(5~8)도 같은 규칙이다
|
||||||
|
Assert.AreEqual("M", ResidentNumber.SexOf("8801015234567"));
|
||||||
|
Assert.AreEqual("F", ResidentNumber.SexOf("8801016234567"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void 성별을_만들_수_없으면_빈_값이다()
|
||||||
|
{
|
||||||
|
// 원본은 CLong 에서 예외가 나고 삼켜져 값이 빈다 — 추측해서 채우지 않는다
|
||||||
|
Assert.AreEqual(string.Empty, ResidentNumber.SexOf("880101"));
|
||||||
|
Assert.AreEqual(string.Empty, ResidentNumber.SexOf("880101X234567"));
|
||||||
|
Assert.AreEqual(string.Empty, ResidentNumber.SexOf(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void 한글_성별은_남녀로_바꾼다()
|
||||||
|
{
|
||||||
|
Assert.AreEqual("남", ResidentNumber.SexKoreanOf("8801011234567"));
|
||||||
|
Assert.AreEqual("여", ResidentNumber.SexKoreanOf("8801012234567"));
|
||||||
|
Assert.AreEqual(string.Empty, ResidentNumber.SexKoreanOf("880101"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void 하이픈은_양쪽_공백을_지킨다()
|
||||||
|
{
|
||||||
|
// 레거시가 " - " 다. 공백을 빼면 서식에서 폭이 달라진다
|
||||||
|
Assert.AreEqual("880101 - 1234567", ResidentNumber.WithDash("8801011234567"));
|
||||||
|
Assert.AreEqual("880101", ResidentNumber.WithDash("880101"));
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user