fix(security): 배포본 소스맵 노출 차단 + 관리자 설정 탭 지연로딩 + 동반 결함 수정

## 소스맵 노출 차단 (공개 이슈 )

배포본에 .map 이 포함돼 원본 TS/TSX 전문(sourcesContent, 한글 주석 포함)이
그대로 노출됐다. 생성·서빙·저장소 유입 3계층을 모두 막는다.

- 빌드 config 15곳(코어 3 + _bundled 12): sourcemap 하드코딩 제거.
 G7_BUILD_SOURCEMAP 판정식으로 교체 — 미설정(로컬 npm run build)이면 생성해
 개발 디버깅 경험을 보존하고, 0 이면 미생성.
- Build{Core,Module,Plugin,Template}Command: 죽어 있던 --production 플래그 복구.
 $productionMode 를 콘솔 문자열에만 쓰고 npm 프로세스에 env 를 넘기지 않아
 산출물이 개발 빌드와 동일했다. runNpmCommand 에 $env 를 추가해 주입
 (--watch 에는 미주입). Symfony Process 는 부모 환경에 병합하므로 PATH 유실 없음.
- Allowed{Module,Plugin}FileType / {Module,Plugin}Service::getMimeType:
 허용 확장자 map 제거 — 익명 200 서빙 차단(템플릿은 원래 미허용, 3종 정책 일치).
- ExtensionBundleService: CSS 번들의 /*# sourceMappingURL=... */ strip 추가.
 CSS 는 블록 주석이라 JS 의 //# 패턴으로는 검출되지 않던 구멍.
- 추적 .map 13개 삭제 + .gitignore 전역 *.map 규칙(경로 한정 시 새 확장에서 누락).

실측: 산출 JS 의 sourceMappingURL 13 → 0, 추적 .map 13 → 0,
확장 디렉토리 npm run build 는 여전히 map 생성(개발 경험 회귀 없음).

## 관리자 환경설정 탭 지연 로딩

코어/이커머스/게시판 환경설정 화면이 진입 즉시 전 탭의 데이터소스를 한꺼번에
호출했다. 각 데이터소스에 활성 탭 조건(if)을 걸어 해당 탭에서만 fetch 하도록
변경하고, 결제 플러그인 3종의 테스트모드 상태 조회도 주문설정 탭으로 한정.

## 동반 결함 수정 (무관 결함 — 재발 방지 목적으로 같은 세션 처리)

- 나이스페이먼츠: 체크아웃 캐시 프리페치가 라우트를 가리지 않아 플러그인 번들이
 로드되는 모든 페이지(홈/게시판/로그인)에서 주문서를 조회 → 비회원 매번 422.
 형제 플러그인(kginicis)의 기존 가드 패턴을 적용해 체크아웃 경로로 한정.
- admin smoke 8건: 대부분 구현이 아니라 테스트 결함이었다 — URL 오타(단수 board),
 존재하지 않는 권한키, 앱 로케일이 en 인데 한국어 라벨 로케이터, DataGrid 버튼을
 먼저 잡던 휴리스틱 로케이터. 레이아웃에는 sticky className / 안정 id 를 부여.
- PHPDoc ↔ Pint 충돌 3건: 설명 없는 @param 은 no_superfluous_phpdoc_tags 가
 삭제해 audit phpdoc-public-method 와 무한 충돌. 한국어 설명을 붙여 양쪽 충족
 (리스너 3종의 설명 누락 메서드도 전수 보강).
- Log::spy 500 3건: spy 가 Log::channel('activity') 를 null 로 만들고
 logActivity 가 null->info 로 \Error → 같은 줄 catch(\Exception) 은 못 잡아
 통과. 운영에선 채널이 항상 로거이므로 제품 무결 → 테스트에 activity 채널
 보존 헬퍼 추가.
- NhnKcpApiServiceTest 5건: 스텁이 Unix 바이너리만 만들어 Windows 분기
 (pp_cli_exe.exe)에서만 실패. #!/bin/sh 는 Windows 실행 불가 → 파일 내 기존
 컨벤션대로 skip 가드.

## 테스트

- 소스맵 범위 130 passed (신규: BuildCommandSourcemapEnv, NoSourcemapArtifacts,
 ExtensionBundleService CSS strip, Allowed*FileType 반전, 자산 서빙 거부)
- 결제 3플러그인 505 passed / 0 failed (skipped 9 = Windows 전용 CLI, 사유 명시)
- 나이스페이먼츠 프론트 38 passed (가드 제거 시 red 실증 후 복원)
- E2E: production-sourcemap-exposure.spec.ts 6건 + 시나리오 매니페스트

버전: sirsoft-admin_basic 1.0.2 → 1.0.3, sirsoft-board 1.0.1 → 1.0.2
(각 manifest/package/lock/composer 동기화). 코어 7.0.4 는 미출시 누적.
This commit is contained in:
HeuJung committed 2026-07-14 12:50:23 +09:00
1 parent 84ca40f0d8
commit 90f154ecc0
132 files changed
+10069 -312

No files matched your search

@@ -1,7 +1,7 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"identifier": "sirsoft-ckeditor5",
"version": "1.0.0-beta.4",
"version": "1.0.0",
"components": {
"basic": [],
"composite": [],
+2 -3
View File
@@ -1,4 +1,4 @@
var SirsoftCkeditor5=(function(G){"use strict";const E=new Map,Y="https://cdn.ckeditor.com/ckeditor5/43.3.1/ckeditor5.css",Z="https://cdn.ckeditor.com/ckeditor5/43.3.1/translations",N="ckeditor5-cdn-css",H="ckeditor5-dark-override",D="ckeditor5-tab-style",O={minimal:["bold","italic","underline","|","link","uploadImage","|","undo","redo"],standard:["heading","|","bold","italic","underline","strikethrough","|","alignment","|","link","blockQuote","|","bulletedList","numberedList","indent","outdent","|","insertTable","uploadImage","|","undo","redo"],full:["heading","|","fontSize","fontColor","fontBackgroundColor","|","bold","italic","underline","strikethrough","|","alignment","|","link","blockQuote","insertTable","uploadImage","mediaEmbed","horizontalLine","|","bulletedList","numberedList","indent","outdent","|","codeBlock","sourceEditing","|","undo","redo"]};function R(t){if(t==="en")return Promise.resolve();const o=`ckeditor5-translations-${t}`;return document.getElementById(o)?Promise.resolve():new Promise(e=>{const r=document.createElement("script");r.id=o,r.src=`${Z}/${t}.umd.js`,r.onload=()=>e(),r.onerror=()=>e(),document.head.appendChild(r)})}function K(){if(document.getElementById(N))return;const t=document.createElement("link");t.id=N,t.rel="stylesheet",t.href=Y,document.head.appendChild(t)}function tt(){if(document.getElementById(D))return;const t=document.createElement("style");t.id=D,t.textContent=`
var SirsoftCkeditor5=function(G){"use strict";const E=new Map,Y="https://cdn.ckeditor.com/ckeditor5/43.3.1/ckeditor5.css",Z="https://cdn.ckeditor.com/ckeditor5/43.3.1/translations",N="ckeditor5-cdn-css",H="ckeditor5-dark-override",D="ckeditor5-tab-style",O={minimal:["bold","italic","underline","|","link","uploadImage","|","undo","redo"],standard:["heading","|","bold","italic","underline","strikethrough","|","alignment","|","link","blockQuote","|","bulletedList","numberedList","indent","outdent","|","insertTable","uploadImage","|","undo","redo"],full:["heading","|","fontSize","fontColor","fontBackgroundColor","|","bold","italic","underline","strikethrough","|","alignment","|","link","blockQuote","insertTable","uploadImage","mediaEmbed","horizontalLine","|","bulletedList","numberedList","indent","outdent","|","codeBlock","sourceEditing","|","undo","redo"]};function R(t){if(t==="en")return Promise.resolve();const o=`ckeditor5-translations-${t}`;return document.getElementById(o)?Promise.resolve():new Promise(e=>{const r=document.createElement("script");r.id=o,r.src=`${Z}/${t}.umd.js`,r.onload=()=>e(),r.onerror=()=>e(),document.head.appendChild(r)})}function K(){if(document.getElementById(N))return;const t=document.createElement("link");t.id=N,t.rel="stylesheet",t.href=Y,document.head.appendChild(t)}function tt(){if(document.getElementById(D))return;const t=document.createElement("style");t.id=D,t.textContent=`
/* 다국어 탭 컨테이너 */
.ckeditor5-locale-tabs {
display: flex;
@@ -369,5 +369,4 @@ var SirsoftCkeditor5=(function(G){"use strict";const E=new Map,Y="https://cdn.ck
.ck-content ul ul ul { list-style-type: square; padding-left: 2em; }
.ck-content ol ol { list-style-type: lower-alpha; padding-left: 2em; }
.ck-content ol ol ol { list-style-type: lower-roman; padding-left: 2em; }
`,document.head.appendChild(t)}}const v={initEditor:ut,destroyEditor:ft,injectContentCss:mt},z=window.G7Core?.createLogger?.(`Plugin:${m}`)??{log:(...t)=>console.log(`[Plugin:${m}]`,...t),warn:(...t)=>console.warn(`[Plugin:${m}]`,...t),error:(...t)=>console.error(`[Plugin:${m}]`,...t)};function Q(t=!1){const o=window.G7Core?.getActionDispatcher?.();if(o)Object.entries(v).forEach(([e,r])=>{const c=`${m}.${e}`;o.registerHandler(c,r,{category:"plugin",source:m})}),z.log(`${Object.keys(v).length} handler(s) registered:`,Object.keys(v).map(e=>`${m}.${e}`));else if(t){let e=0;const r=50,c=()=>{const l=window.G7Core?.getActionDispatcher?.();l?(Object.entries(v).forEach(([i,s])=>{const b=`${m}.${i}`;l.registerHandler(b,s,{category:"plugin",source:m})}),z.log(`${Object.keys(v).length} handler(s) registered:`,Object.keys(v).map(i=>`${m}.${i}`))):(e++,e<=r?(z.warn(`ActionDispatcher not found, retrying... (${e}/${r})`),setTimeout(c,100)):z.error("Failed to register handlers: ActionDispatcher not available after maximum retries"))};c()}else z.warn("ActionDispatcher not found, handlers not registered")}function j(){if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",()=>Q(!0));else{const t=!!window.G7Core?.getActionDispatcher?.();Q(!t)}}return j(),typeof window<"u"&&(window.__SirsoftCkeditor5={identifier:m,handlers:Object.keys(v),initPlugin:j}),G.initPlugin=j,Object.defineProperty(G,Symbol.toStringTag,{value:"Module"}),G})({});
//# sourceMappingURL=plugin.iife.js.map
`,document.head.appendChild(t)}}const v={initEditor:ut,destroyEditor:ft,injectContentCss:mt},z=window.G7Core?.createLogger?.(`Plugin:${m}`)??{log:(...t)=>console.log(`[Plugin:${m}]`,...t),warn:(...t)=>console.warn(`[Plugin:${m}]`,...t),error:(...t)=>console.error(`[Plugin:${m}]`,...t)};function Q(t=!1){const o=window.G7Core?.getActionDispatcher?.();if(o)Object.entries(v).forEach(([e,r])=>{const c=`${m}.${e}`;o.registerHandler(c,r,{category:"plugin",source:m})}),z.log(`${Object.keys(v).length} handler(s) registered:`,Object.keys(v).map(e=>`${m}.${e}`));else if(t){let e=0;const r=50,c=()=>{const l=window.G7Core?.getActionDispatcher?.();l?(Object.entries(v).forEach(([i,s])=>{const b=`${m}.${i}`;l.registerHandler(b,s,{category:"plugin",source:m})}),z.log(`${Object.keys(v).length} handler(s) registered:`,Object.keys(v).map(i=>`${m}.${i}`))):(e++,e<=r?(z.warn(`ActionDispatcher not found, retrying... (${e}/${r})`),setTimeout(c,100)):z.error("Failed to register handlers: ActionDispatcher not available after maximum retries"))};c()}else z.warn("ActionDispatcher not found, handlers not registered")}function j(){if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",()=>Q(!0));else{const t=!!window.G7Core?.getActionDispatcher?.();Q(!t)}}return j(),typeof window<"u"&&(window.__SirsoftCkeditor5={identifier:m,handlers:Object.keys(v),initPlugin:j}),G.initPlugin=j,Object.defineProperty(G,Symbol.toStringTag,{value:"Module"}),G}({});
@@ -1 +0,0 @@
{"version":3,"file":"plugin.iife.js","sources":["../../resources/js/handlers/editorInstances.ts","../../resources/js/handlers/initEditor.ts","../../resources/js/handlers/destroyEditor.ts","../../resources/js/index.ts"],"sourcesContent":["/**\n * CKEditor5 인스턴스 공유 저장소\n *\n * containerId → locale별 에디터 인스턴스 맵을 관리합니다.\n * initEditor / destroyEditor 핸들러가 공유합니다.\n */\n\n// containerId → Map<locale, editorInstance>\nexport const editorInstances = new Map<string, Map<string, unknown>>();\n\n// setData() 중 change:data → syncToForm 재진입 방지 플래그\nlet _syncSuppressed = false;\nexport function isSyncSuppressed(): boolean {\n return _syncSuppressed;\n}\nexport function setSyncSuppressed(value: boolean): void {\n _syncSuppressed = value;\n}\n","/**\n * CKEditor5 초기화 핸들러\n *\n * 컴포넌트 마운트 시 CKEditor5 인스턴스를 생성합니다.\n * 다국어(multilingual) 모드: locale별 탭 + 인스턴스 생성\n * 단일 모드: 단일 인스턴스 생성\n */\n\nimport { editorInstances, isSyncSuppressed } from './editorInstances';\n\n/** CKEditor5 CDN CSS URL */\nconst CKEDITOR5_CSS_URL = 'https://cdn.ckeditor.com/ckeditor5/43.3.1/ckeditor5.css';\n/** CKEditor5 CDN 번역 파일 base URL */\nconst CKEDITOR5_TRANSLATIONS_BASE = 'https://cdn.ckeditor.com/ckeditor5/43.3.1/translations';\n/** CKEditor5 CSS 로드 상태 추적용 엘리먼트 ID */\nconst CKEDITOR5_CSS_ID = 'ckeditor5-cdn-css';\n/** CKEditor5 다크 모드 CSS 엘리먼트 ID */\nconst CKEDITOR5_DARK_CSS_ID = 'ckeditor5-dark-override';\n/** CKEditor5 다국어 탭 기본 CSS 엘리먼트 ID */\nconst CKEDITOR5_TAB_CSS_ID = 'ckeditor5-tab-style';\n\n/** CKEditor 전역 객체 타입 (CDN UMD 빌드 — window.CKEDITOR) */\ninterface CKEditorGlobal {\n ClassicEditor: any;\n Essentials: any;\n Bold: any;\n Italic: any;\n Underline: any;\n Strikethrough: any;\n Link: any;\n Paragraph: any;\n Heading: any;\n BlockQuote: any;\n List: any;\n Alignment: any;\n Table: any;\n TableToolbar: any;\n TableProperties: any;\n TableCellProperties: any;\n Image: any;\n ImageUpload: any;\n ImageResize: any;\n ImageStyle: any;\n ImageCaption: any;\n ImageToolbar: any;\n SimpleUploadAdapter: any;\n MediaEmbed: any;\n HorizontalLine: any;\n Indent: any;\n IndentBlock: any;\n CodeBlock: any;\n SourceEditing: any;\n FontSize: any;\n FontColor: any;\n FontBackgroundColor: any;\n PasteFromOffice: any;\n GeneralHtmlSupport: any;\n}\n\n/** initEditor 파라미터 타입 */\ninterface InitEditorParams {\n name?: string;\n content?: string;\n multilingual?: boolean | string;\n placeholder?: string;\n readOnly?: boolean | string;\n imageUpload?: boolean | string;\n uploadPermission?: string;\n height?: number | string;\n toolbar?: string;\n}\n\n/** 툴바 프리셋 */\nconst TOOLBAR_PRESETS: Record<string, string[]> = {\n minimal: [\n 'bold', 'italic', 'underline', '|',\n 'link', 'uploadImage', '|',\n 'undo', 'redo',\n ],\n standard: [\n 'heading', '|',\n 'bold', 'italic', 'underline', 'strikethrough', '|',\n 'alignment', '|',\n 'link', 'blockQuote', '|',\n 'bulletedList', 'numberedList', 'indent', 'outdent', '|',\n 'insertTable', 'uploadImage', '|',\n 'undo', 'redo',\n ],\n full: [\n 'heading', '|',\n 'fontSize', 'fontColor', 'fontBackgroundColor', '|',\n 'bold', 'italic', 'underline', 'strikethrough', '|',\n 'alignment', '|',\n 'link', 'blockQuote', 'insertTable', 'uploadImage', 'mediaEmbed', 'horizontalLine', '|',\n 'bulletedList', 'numberedList', 'indent', 'outdent', '|',\n 'codeBlock', 'sourceEditing', '|',\n 'undo', 'redo',\n ],\n};\n\n/**\n * CKEditor5 번역 스크립트를 동적으로 로드합니다.\n * 영어(en)는 기본 내장이므로 로드하지 않습니다.\n */\nfunction loadCkeditorTranslations(locale: string): Promise<void> {\n if (locale === 'en') return Promise.resolve();\n const id = `ckeditor5-translations-${locale}`;\n if (document.getElementById(id)) return Promise.resolve();\n return new Promise((resolve) => {\n const script = document.createElement('script');\n script.id = id;\n script.src = `${CKEDITOR5_TRANSLATIONS_BASE}/${locale}.umd.js`;\n script.onload = () => resolve();\n script.onerror = () => resolve(); // 번역 실패해도 에디터는 영어로 동작\n document.head.appendChild(script);\n });\n}\n\n/**\n * CKEditor5 CSS를 동적으로 로드합니다.\n * 이미 로드된 경우 중복 삽입하지 않습니다.\n */\nfunction loadCkeditorCss(): void {\n if (document.getElementById(CKEDITOR5_CSS_ID)) {\n return;\n }\n const link = document.createElement('link');\n link.id = CKEDITOR5_CSSLine truncated
@@ -16,7 +16,9 @@ export default defineConfig({
outDir: 'dist',
emptyOutDir: true,
sourcemap: true,
// 배포용 빌드(--production)는 G7_BUILD_SOURCEMAP=0 을 주입해 소스맵을 생성하지 않는다.
// 미설정(로컬 npm run build)이면 생성 — 개발 디버깅 경험을 유지한다.
sourcemap: !['0', 'false'].includes(process.env.G7_BUILD_SOURCEMAP ?? ''),
rollupOptions: {
output: {
@@ -0,0 +1,10 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"identifier": "sirsoft-daum_postcode",
"version": "1.0.0",
"components": {
"basic": [],
"composite": [],
"layout": []
}
}
@@ -34,4 +34,3 @@ var SirsoftDaumPostcode=function(m){"use strict";const y=window.G7Core?.createLo
justify-content: center;
z-index: 10;
`;const r=document.createElement("div");r.style.cssText="position: relative;",r.appendChild(n),r.appendChild(t),o.appendChild(r),document.body.appendChild(o);const s=()=>{o.parentNode&&o.parentNode.removeChild(o)};n.addEventListener("click",s),o.addEventListener("click",l=>{l.target===o&&s()});const i=l=>{l.key==="Escape"&&(s(),document.removeEventListener("keydown",i))};return document.addEventListener("keydown",i),a.log("[openPostcode] Layer created with size:",{width:e,height:c}),{overlay:o,layer:t,closeLayer:s}}function L(e,c,o){const t=window.G7Core;if(!t?.dispatch){a.error("[openPostcode] G7Core.dispatch not available");return}const n=o?.state||t?.state?.getLocal?.()||{},r={state:n,setState:o?.setState,data:{...o?.data||{},_local:n,$event:c},isolatedContext:o?.isolatedContext};a.log("[openPostcode] Dispatching with componentContext:",{hasLocalState:!!n,sourceType:o?.state?"handlerContext":"getLocal",localCheckout:n?.checkout,event:c});try{const s=Array.isArray(e)?e:[e];for(const i of s)i&&typeof i=="object"&&(a.log("[openPostcode] Dispatching action:",i),t.dispatch(i,{componentContext:r}))}catch(s){a.error("[openPostcode] callbackAction failed:",s)}}function S(e,c){const o=window.G7Core;if(!o?.state?.setLocal){a.error("[openPostcode] G7Core.state.setLocal not available");return}const t={};e.zipcode&&(t[e.zipcode]=c.zipcode),e.address&&(t[e.address]=c.address),e.region&&(t[e.region]=c.region),e.city&&(t[e.city]=c.city),a.log("[openPostcode] Setting local state:",t),o.state.setLocal(t)}const p={setFieldReadOnly:C,openPostcode:x},d="sirsoft-daum_postcode",u=window.G7Core?.createLogger?.(`Plugin:${d}`)??{log:(...e)=>console.log(`[Plugin:${d}]`,...e),warn:(...e)=>console.warn(`[Plugin:${d}]`,...e),error:(...e)=>console.error(`[Plugin:${d}]`,...e)};function P(e=!1){const c=window.G7Core?.getActionDispatcher?.();if(c)Object.entries(p).forEach(([o,t])=>{const n=`${d}.${o}`;c.registerHandler(n,t,{category:"plugin",source:d})}),u.log(`${Object.keys(p).length} handler(s) registered:`,Object.keys(p).map(o=>`${d}.${o}`));else if(e){let o=0;const t=50,n=()=>{const r=window.G7Core?.getActionDispatcher?.();r?(Object.entries(p).forEach(([s,i])=>{const l=`${d}.${s}`;r.registerHandler(l,i,{category:"plugin",source:d})}),u.log(`${Object.keys(p).length} handler(s) registered:`,Object.keys(p).map(s=>`${d}.${s}`))):(o++,o<=t?(u.warn(`ActionDispatcher not found, retrying... (${o}/${t})`),setTimeout(n,100)):u.error("Failed to register handlers: ActionDispatcher not available after maximum retries"))};n()}else u.warn("ActionDispatcher not found, handlers not registered")}function w(){if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",()=>P(!0));else{const e=!!window.G7Core?.getActionDispatcher?.();P(!e)}}return w(),typeof window<"u"&&(window.__SirsoftDaumPostcode={identifier:d,handlers:Object.keys(p),initPlugin:w}),m.initPlugin=w,Object.defineProperty(m,Symbol.toStringTag,{value:"Module"}),m}({});
//# sourceMappingURL=plugin.iife.js.map
@@ -1 +0,0 @@
{"version":3,"file":"plugin.iife.js","sources":["../../resources/js/handlers/setFieldReadOnly.ts","../../resources/js/handlers/openPostcode.ts","../../resources/js/handlers/index.ts","../../resources/js/index.ts"],"sourcesContent":["/**\n * setFieldReadOnly 핸들러\n *\n * 지정된 필드명의 input 요소를 찾아 readOnly 속성을 설정합니다.\n * 주소 검색 플러그인이 설치된 경우, 우편번호/주소 필드를 readOnly로 설정하여\n * 사용자가 직접 입력하지 못하도록 합니다.\n */\n\nimport type { ActionContext } from '../types';\n\n// Logger 설정 (G7Core 초기화 전에도 동작하도록 폴백 포함)\nconst logger = ((window as any).G7Core?.createLogger?.('DaumPostcode:SetFieldReadOnly')) ?? {\n log: (...args: unknown[]) => console.log('[DaumPostcode:SetFieldReadOnly]', ...args),\n warn: (...args: unknown[]) => console.warn('[DaumPostcode:SetFieldReadOnly]', ...args),\n error: (...args: unknown[]) => console.error('[DaumPostcode:SetFieldReadOnly]', ...args),\n};\n\ninterface ActionWithParams {\n handler: string;\n params?: {\n /** readOnly로 설정할 필드명 배열 */\n fields?: string[];\n /** readOnly 값 (기본값: true) */\n readOnly?: boolean;\n };\n [key: string]: any;\n}\n\n/**\n * 지정된 필드명의 input 요소를 찾아 readOnly 속성을 설정합니다.\n *\n * @param action 액션 객체 (params.fields: 필드명 배열, params.readOnly: boolean)\n * @param _context 액션 컨텍스트\n *\n * @example\n * // 레이아웃 JSON에서 사용\n * {\n * \"handler\": \"sirsoft-daum_postcode.setFieldReadOnly\",\n * \"params\": {\n * \"fields\": [\"zonecode\", \"address\"],\n * \"readOnly\": true\n * }\n * }\n */\nexport function setFieldReadOnlyHandler(\n action: ActionWithParams,\n _context: ActionContext\n): void {\n const params = action.params || {};\n const fields = params.fields || [];\n const readOnly = params.readOnly !== false; // 기본값 true\n\n if (!fields.length) {\n logger.warn('[setFieldReadOnly] No fields specified');\n return;\n }\n\n logger.log(`[setFieldReadOnly] Setting readOnly=${readOnly} for fields:`, fields);\n\n // 각 필드에 대해 DOM에서 input 요소를 찾아 readOnly 설정\n fields.forEach((fieldName) => {\n // name 속성으로 input 요소 찾기\n const inputs = document.querySelectorAll<HTMLInputElement | HTMLTextAreaElement>(\n `input[name=\"${fieldName}\"], textarea[name=\"${fieldName}\"]`\n );\n\n if (inputs.length === 0) {\n logger.warn(`[setFieldReadOnly] No input found with name=\"${fieldName}\"`);\n return;\n }\n\n inputs.forEach((input) => {\n input.readOnly = readOnly;\n\n // readOnly 상태에 따른 시각적 피드백 (선택적)\n if (readOnly) {\n input.classList.add('readonly');\n } else {\n input.classList.remove('readonly');\n }\n\n logger.log(`[setFieldReadOnly] Set readOnly=${readOnly} on input[name=\"${fieldName}\"]`);\n });\n });\n}\n","/**\n * openPostcode 핸들러\n *\n * 다음 우편번호 API를 호출하고 G7 표준 형식으로 변환하여 콜백 실행\n * - callbackAction 있으면: 커스텀 동작 실행 (Extension Point에서 정의한 onAddressSelect)\n * - callbackAction 없으면: 플러그인 기본 동작 (설정 기반 setState)\n *\n * G7 표준 주소 이벤트 형식:\n * - zipcode: 우편번호\n * - address: 기본 주소 (도로명/지번)\n * - addressDetail: 상세 주소 (사용자 입력)\n * - region: 시/도\n * - city: 시/군/구\n * - countryCode: 국가 코드\n * - _raw: 원본 다음 API 응답 (필요 시 접근 가능)\n */\n\nimport type { ActionContext } from '../types';\n\n// Logger 설정 (G7Core 초기화 전에도 동작하도록 폴백 포함)\nconst logger = ((window as any).G7Core?.createLogger?.('DaumPostcode:OpenPostcode')) ?? {\n log: (...args: unknown[]) => console.log('[DaumPostcode:OpenPostcode]', ...args),\n warn: (...args: unknown[]) => console.warn('[DaumPostcode:OpenPostcode]', ...args),\n error: (...args: unknown[]) => console.error('[DaumPostcode:OpenPostcode]', ...args),\n};\n\ninterface ActionWithParams {\n handler: string;\n params?: {\n /** 주소 선택 시 실행할 콜백 액션 (Extension Point props에서 전달) */\n callbackAction?: any;\n /** 표시 모드 (popup | layer) */\n displayMode?: string;\n /** 팝업/레이어 너비 */\n width?: number;\n /** 팝업/레이어 높이 */\n height?: number;\n /** 테마 설정 */\n theme?: {\n searchBgColor?: string;\n queryTextColor?: string;\n };\n /** 대상 필드 매핑 (기본 동작 시 사용) */\n targetFields?: {\n zipcode?: string;\nLine truncated
@@ -20,7 +20,9 @@ export default defineConfig({
emptyOutDir: true,
// 소스맵 생성
sourcemap: true,
// 배포용 빌드(--production)는 G7_BUILD_SOURCEMAP=0 을 주입해 소스맵을 생성하지 않는다.
// 미설정(로컬 npm run build)이면 생성 — 개발 디버깅 경험을 유지한다.
sourcemap: !['0', 'false'].includes(process.env.G7_BUILD_SOURCEMAP ?? ''),
rollupOptions: {
output: {
@@ -0,0 +1,10 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"identifier": "sirsoft-gdpr",
"version": "1.0.0",
"components": {
"basic": [],
"composite": [],
"layout": []
}
}
+1 -1
View File
@@ -1 +1 @@
.gdpr-hidden{display:none!important}.gdpr-blocked-embed{display:none}.gdpr-blocked-embed[data-gdpr-blocked-category]:not([aria-hidden=true]){display:inline-flex;align-items:center;justify-content:center;min-height:1.5rem;padding:.25rem .5rem;font-size:.75rem;color:#6b7280;background-color:#f9fafb;border:1px dashed #d1d5db;border-radius:.25rem}@media (prefers-color-scheme: dark){.gdpr-blocked-embed[data-gdpr-blocked-category]:not([aria-hidden=true]){color:#9ca3af;background-color:#1f293780;border-color:#4b5563}}.dark .gdpr-blocked-embed[data-gdpr-blocked-category]:not([aria-hidden=true]){color:#9ca3af;background-color:#1f293780;border-color:#4b5563}
.gdpr-hidden{display:none!important}.gdpr-blocked-embed{display:none}.gdpr-blocked-embed[data-gdpr-blocked-category]:not([aria-hidden=true]){display:inline-flex;align-items:center;justify-content:center;min-height:1.5rem;padding:.25rem .5rem;font-size:.75rem;color:#6b7280;background-color:#f9fafb;border:1px dashed #d1d5db;border-radius:.25rem}@media(prefers-color-scheme:dark){.gdpr-blocked-embed[data-gdpr-blocked-category]:not([aria-hidden=true]){color:#9ca3af;background-color:#1f293780;border-color:#4b5563}}.dark .gdpr-blocked-embed[data-gdpr-blocked-category]:not([aria-hidden=true]){color:#9ca3af;background-color:#1f293780;border-color:#4b5563}
-1
View File
@@ -1,2 +1 @@
(function(){"use strict";function Q(e,t){const n=e.toLowerCase(),r=t.toLowerCase();if(r.startsWith("*.")){const o=r.slice(1);return n.endsWith(o)&&n!==o.slice(1)}return n===r}function v(e,t){if(typeof window>"u"||typeof URL>"u")return null;let n,r;try{const o=new URL(e,window.location.href);r=o.protocol,n=o.hostname}catch{return null}if(r!=="http:"&&r!=="https:"||n===""||n===window.location.hostname)return null;for(const[o,s]of Object.entries(t))if(o!=="necessary"){for(const i of s)if(Q(n,i))return o}return null}const w="data-gdpr-category",h="gdpr-blocked-embed",x="data-gdpr-original",b=[`script[${w}]`,`iframe[${w}]`,"script[src]","iframe[src]","img[src]","link[href]"].join(", ");let l=null,g={categories:{},needs_renewal:!1},R={};function j(e){if(e&&typeof e=="object"&&"categories"in e){const t=e;return{categories:{...t.categories},needs_renewal:t.needs_renewal===!0}}return{categories:{...e},needs_renewal:!1}}function N(e){g=j(e),l!==null&&p()}function V(e){R={...e},l!==null&&p()}function Z(){typeof document>"u"||l!==null||(p(),l=new MutationObserver(e=>{for(const t of e)t.addedNodes.forEach(n=>{n instanceof HTMLElement&&te(n)})}),l.observe(document.documentElement,{childList:!0,subtree:!0}),window.addEventListener("gdpr:consent-changed",ee))}function ee(e){const t=e.detail;t!==null&&typeof t=="object"&&(g=j(t)),p()}function te(e){re(e)&&k(e),e.querySelectorAll(b).forEach(t=>k(t))}function p(){typeof document>"u"||(document.querySelectorAll(`.${h}`).forEach(e=>{const t=e.dataset.gdprBlockedCategory;t&&_(t)&&oe(e)}),document.querySelectorAll(b).forEach(e=>k(e)))}function k(e){if(e.classList.contains(h)||e.hasAttribute("data-gdpr-blocked-category"))return;const t=e.getAttribute(w);if(t!==null&&t!==""){if(_(t))return;H(e,t);return}const n=ne(e);if(n===null)return;const r=v(n,R);r!==null&&(_(r)||H(e,r))}function ne(e){return e instanceof HTMLScriptElement||e instanceof HTMLIFrameElement||e instanceof HTMLImageElement?e.getAttribute("src"):e instanceof HTMLLinkElement?e.getAttribute("href"):null}function re(e){if(typeof e.matches!="function")return!1;try{return e.matches(b)}catch{return!1}}function H(e,t){const n=document.createElement("div");n.className=h,n.dataset.gdprBlockedCategory=t,n.dataset.gdprOriginalTag=e.tagName.toLowerCase(),n.setAttribute(x,e.outerHTML),n.setAttribute("aria-hidden","true"),n.style.display="none",e.replaceWith(n)}function oe(e){const t=e.getAttribute(x);if(!t){e.remove();return}const n=document.createElement("div");n.innerHTML=t;const r=n.firstElementChild;r!==null?e.replaceWith(r):e.remove()}function _(e){return e==="necessary"?!0:g.needs_renewal?!1:g.categories[e]===!0}const se=500,ie=["click","keydown","change","submit","pointerdown","touchstart"];let B=!1,C=0;function ce(e){e.isTrusted&&(C=Date.now())}function ae(){if(!B){B=!0;for(const e of ie){const t=ce;window.addEventListener(e,t,{capture:!0,passive:!0})}}}function M(){const e=window.event;return e&&e.isTrusted?!0:C===0?!1:Date.now()-C<=se}const u=["XSRF-TOKEN","laravel_session","laravel_maintenance","gdpr_session"];let A=!1,a=null,y={functionalConsented:!1,necessaryAllowlist:u};function le(e){const t=e.indexOf("=");return t<=0?null:e.substring(0,t).trim()}function ue(e){return y.necessaryAllowlist.includes(e)}function fe(e){const t=le(e);return t===null?!1:!!(de(e)||ue(t)||y.functionalConsented||M())}function de(e){const t=e.toLowerCase(),n=t.match(/max-age\s*=\s*(-?\d+)/);return!!(n&&parseInt(n[1],10)<=0||t.includes("expires=thu, 01 jan 1970")||t.includes("expires=thu, 01-jan-1970"))}function ge(e){if(A)return;if(A=!0,y=e,a=Object.getOwnPropertyDescriptor(Document.prototype,"cookie")??null,!a||!a.set||!a.get){A=!1;return}const t=a.set,n=a.get;Object.defineProperty(Document.prototype,"cookie",{configurable:!0,get(){return n.call(this)},set(r){fe(r)&&t.call(this,r)}})}function P(e){y=e}const f=[{key:"g7_locale",storage:"localStorage",matchType:"exact"},{key:"auth_token",storage:"localStorage",matchType:"exact"},{key:"g7_cache_version",storage:"localStorage",matchType:"exact"},{key:"g7_cart_key",storage:"localStorage",matchType:"exact"},{key:"g7-devtools-panel",storage:"localStorage",matchType:"exact"},{key:"g7_devtools_",matchType:"prefix"},{key:"g7_filters_",matchType:"prefix"},{key:"g7_columns_",matchType:"prefix"},{key:"g7_order_",matchType:"prefix"}];let $=!1,E=null,m={functionalConsented:!1,necessaryAllowlist:f};function pe(e,t){for(const n of m.necessaryAllowlist){if(n.storage&&n.storage!==t)continue;const r=n.matchType??"exact";if(r==="exact"&&n.key===e||r==="prefix"&&e.startsWith(n.key))return!0}return!1}function ye(e,t){return!!(pe(e,t)||m.functionalConsented||M())}function me(e){if($)return;$=!0,m=e;const t=Storage.prototype;E=t.setItem,t.setItem=function(n,r){const o=this===window.sessionStorage?"sessionStorage":"localStorage";ye(n,o)&&E&&E.call(this,n,r)}}function G(e){m=e}function we(e,t,n){for(const r of n){if(r.storage&&r.storage!==t)continue;const o=r.matchType??"exact";if(o==="exact"&&r.key===e||o==="prefixLine truncated
//# sourceMappingURL=plugin.iife.js.map
@@ -1 +0,0 @@
{"version":3,"file":"plugin.iife.js","sources":["../../resources/js/blocker-domains.ts","../../resources/js/blocker.ts","../../resources/js/userInitiatedTracker.ts","../../resources/js/cookieInterceptor.ts","../../resources/js/storageInterceptor.ts","../../resources/js/functionalCleaner.ts","../../resources/js/layouts.ts","../../resources/js/preblocker.ts","../../resources/js/index.ts"],"sourcesContent":["/**\n * GDPR 도메인 기반 차단 매칭 + 기본 카탈로그\n *\n * 운영자가 카테고리별 차단 도메인 목록을 관리하면 `data-gdpr-category` 속성\n * 없이도 외부 추적 리소스를 동의 전 차단합니다. 매칭 알고리즘은 endsWith 기반\n * 으로 정규식을 쓰지 않아 일반 페이지 영향이 미미합니다.\n *\n * @module sirsoft-gdpr/blocker-domains\n */\n\n/**\n * 호스트가 차단 도메인 패턴과 매칭되는지 확인합니다.\n *\n * 패턴 형식:\n * - 정확 매칭: `google-analytics.com` → `google-analytics.com` 만 매칭\n * - 와일드카드: `*.hotjar.com` → `static.hotjar.com` 등 매칭. `hotjar.com`\n * 자체는 매칭 안 됨 (정확 매칭으로 별도 추가 필요)\n *\n * @param host 검사 대상 호스트\n * @param pattern 차단 패턴\n * @return 매칭 여부\n */\nexport function matchesDomain(host: string, pattern: string): boolean {\n const lowerHost = host.toLowerCase();\n const lowerPattern = pattern.toLowerCase();\n\n if (lowerPattern.startsWith('*.')) {\n const suffix = lowerPattern.slice(1); // \".hotjar.com\"\n return lowerHost.endsWith(suffix) && lowerHost !== suffix.slice(1);\n }\n\n return lowerHost === lowerPattern;\n}\n\n/**\n * URL 의 호스트가 카테고리별 차단 목록에 매칭되는지 확인합니다.\n *\n * 동일 origin 은 차단 대상 외 (운영자 자체 자산 보호). `data:`/`blob:` 등 비\n * HTTP 스킴은 외부 호스트가 없으므로 차단 대상 외. `necessary` 카테고리는\n * 매칭 자체를 skip 합니다 (필수 쿠키 보호).\n *\n * @param url 검사 대상 URL (절대 또는 상대)\n * @param patterns 카테고리 → 도메인 패턴 배열\n * @return 매칭된 카테고리, 매칭 없으면 null\n */\nexport function categorizeUrl(\n url: string,\n patterns: Record<string, readonly string[]>,\n): string | null {\n if (typeof window === 'undefined' || typeof URL === 'undefined') return null;\n\n let host: string;\n let protocol: string;\n try {\n const parsed = new URL(url, window.location.href);\n protocol = parsed.protocol;\n host = parsed.hostname;\n } catch {\n return null;\n }\n\n if (protocol !== 'http:' && protocol !== 'https:') return null;\n if (host === '' || host === window.location.hostname) return null;\n\n for (const [category, domains] of Object.entries(patterns)) {\n if (category === 'necessary') continue;\n for (const domain of domains) {\n if (matchesDomain(host, domain)) return category;\n }\n }\n\n return null;\n}\n\n/**\n * 기본 차단 도메인 카탈로그 — 운영자가 `blocked_domains_default_catalog = true`\n * 로 켜야 적용. 운영자 커스텀 목록과 합집합으로 병합됩니다.\n *\n * 카탈로그 갱신은 코드 PR 로만 가능. 사용자별 변경 불가.\n */\nexport const DEFAULT_BLOCKED_DOMAINS: Record<string, readonly string[]> = {\n // functional 카테고리는 자체 호스팅 functional 만 처리 — 외부 도구 도메인은 Phase 2 에서 보강.\n // 현재 빈 배열로 두어 운영자가 admin UI 의 functional 차단 도메인 섹션을 사용할 수 있도록 함.\n functional: [],\n analytics: [\n // Google Analytics 계열\n 'google-analytics.com',\n '*.google-analytics.com',\n 'googletagmanager.com',\n '*.googletagmanager.com',\n 'ssl.google-analytics.com',\n // 행동 분석\n '*.hotjar.com',\n 'static.hotjar.com',\n '*.mixpanel.com',\n 'cdn.mxpnl.com',\n '*.amplitude.com',\n 'cdn.amplitude.com',\n '*.segment.io',\n '*.segment.com',\n // 한국 분석 도구\n 'wcs.naver.net',\n 'wcs.naver.com',\n '*.beusable.net',\n ],\n marketing: [\n // Meta (Facebook)\n 'facebook.net',\n 'connect.facebook.net',\n 'facebook.com',\n '*.facebook.com',\n // Google Ads\n 'doubleclick.net',\n '*.doubleclick.net',\n 'googleadservices.com',\n 'googlesyndication.com',\n 'ads.google.com',\n // 광고 네트워크\n '*.criteo.com',\n 'static.criteo.net',\n '*.adnxs.com',\n '*.taboola.com',\n 'cdn.taboola.com',\n '*.outbrain.com',\n // 한국 광고 네트워크\n '*.kakao.com',\n 'analytics.ad.daum.net',\n // SNS 임베드\n 'platform.twitter.com',\n '*.twitter.comLine truncated
+3 -1
View File
@@ -19,7 +19,9 @@ export default defineConfig({
},
},
},
sourcemap: true,
// 배포용 빌드(--production)는 G7_BUILD_SOURCEMAP=0 을 주입해 소스맵을 생성하지 않는다.
// 미설정(로컬 npm run build)이면 생성 — 개발 디버깅 경험을 유지한다.
sourcemap: !['0', 'false'].includes(process.env.G7_BUILD_SOURCEMAP ?? ''),
minify: 'esbuild',
target: 'es2020',
chunkSizeWarningLimit: 500,
@@ -0,0 +1,10 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"identifier": "sirsoft-pay_kginicis",
"version": "1.0.0",
"components": {
"basic": [],
"composite": [],
"layout": []
}
}
@@ -88,4 +88,3 @@
</main>
</body>
</html>`),t.document.close()}function S(e){return e.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#039;")}const F="sirsoft-pay_kginicis",Le="__kginicisOrderShowInjectorInstalled",W="kginicis-mp-receipt-row",Ke=/^(?:\/mypage\/orders\/([^/]+)|\/shop\/guest\/orders\/([^/]+))$/,q=new Map;function on(e){try{const n=window.G7Core?.getState,o=n?.()?.currentDataContext?.order?.data;return o&&(o.order_number===e||o.id!==void 0&&String(o.id)===e)?o:null}catch{return null}}function an(){const e=document.getElementById("order_payment_info_panel");if(e)return Array.from(e.children).find(r=>r.className?.includes("space-y"))??e;const t=Array.from(document.querySelectorAll("h3")).find(r=>{const i=r.textContent?.trim()??"";return i.includes("결제 정보")||/^Payment Information$/i.test(i)});if(!t)return null;const n=t.parentElement?.parentElement;return n?Array.from(n.children).find(r=>r.className?.includes("space-y"))??n:null}function sn(e,t){if(!t)return!1;const n=Array.from(e.querySelectorAll("div"));for(const r of n){const i=Array.from(r.children).filter(s=>s instanceof HTMLElement&&s.tagName==="SPAN");if(i.length<2)continue;const o=i[0].textContent?.trim();if(o!=="결제 방법"&&o!=="결제수단"&&o!=="Payment Method")continue;const a=i[i.length-1];return a.textContent?.trim()===t?(r.dataset.kginicisPaymentMethodRow="true",!0):(a.textContent=t,a.dataset.kginicisPaymentMethodPatched="true",r.dataset.kginicisPaymentMethodRow="true",!0)}return!1}function cn(e,t){const n=document.createElement("div");n.id=W,n.className="flex items-center justify-between",n.dataset.kginicisReceiptRow="mypage-order";const r=document.createElement("span");r.className="text-gray-500 dark:text-gray-400 text-sm",r.textContent=nn(t);const i=document.createElement("button");return i.type="button",i.dataset.kginicisReceiptButton="mypage-order",i.className="inline-flex items-center gap-1 text-sm text-blue-600 dark:text-blue-400 hover:underline disabled:opacity-50",i.textContent=M(t),i.addEventListener("click",async()=>{i.disabled=!0,i.textContent="로딩 중...";const o=await N(e);i.disabled=!1,i.textContent=M(o??t),v(o)&&Me(o)}),n.appendChild(r),n.appendChild(i),n}async function dn(e,t){const n=on(e),r=n?.payment;if(r&&(r.pg_provider!=="kginicis"||!r.transaction_id))return!0;const i=n?.order_number??(t?e:null);if(!i)return!1;if(!t&&n&&!r)return!0;const o=an();if(!o)return!1;const{status:a,info:s}=await Ne(i);if(!t&&a===404)return!0;const c=r?.payment_status==="paid",l=s?.receipt_type==="cbt_confirmation";if(r&&!c&&!l)return!0;if(!r&&!v(s))return!1;const d=sn(o,s?.payment_method_display_label);return!document.getElementById(W)&&v(s)&&(o.appendChild(cn(i,s)),console.info(`[${F}] receipt button injected on mypage order show`)),d&&console.info(`[${F}] payment method display patched on mypage order show`),!!(document.getElementById(W)||d)}function ln(e,t){if(q.has(e))return;let n=0;const r=window.setInterval(()=>{n++,dn(e,t).then(i=>{(i||n>=30)&&(window.clearInterval(r),q.delete(e))})},400);q.set(e,r)}function un(){const e=location.pathname.match(Ke);if(e){const t=e[1],n=e[2],r=t??n;r&&ln(r,n!==void 0)}}function fn(){if(typeof window>"u")return;const e=window;if(e[Le])return;e[Le]=!0,console.info(`[${F}] mypage order show injector installed`);let t=null;const n=(s=1500)=>{t!==null&&window.clearTimeout(t),t=window.setTimeout(()=>{t=null,un()},s)};document.readyState==="loading"?document.addEventListener("DOMContentLoaded",()=>n()):n();const r=history.pushState.bind(history);history.pushState=(...s)=>{r(...s),n(600)};const i=history.replaceState.bind(history);history.replaceState=(...s)=>{i(...s),n(600)},window.addEventListener("popstate",()=>n(500));const o=document.getElementById("app")??document.body;new MutationObserver(()=>{Ke.test(location.pathname)&&n(250)}).observe(o,{childList:!0,subtree:!0})}const z="sirsoft-pay_kginicis",Oe="__kginicisAdminPaymentDisplayInjectorInstalled",mn=/^\/admin\/ecommerce\/orders\/([^/]+)$/;function pn(){return localStorage.getItem("auth_token")}async function yn(e){const t=pn();if(!t)return null;try{const n=await fetch(`/api/plugins/${z}/admin/orders/${e}/transaction-status`,{headers:{Authorization:`Bearer ${t}`,Accept:"application/json"}});if(!n.ok)return null;const r=await n.json();return("data"in r?r.data:r)??null}catch{return null}}function hn(e){return Array.from(e.children).filter(t=>t instanceof HTMLElement)}function gn(e,t){let n=!1;const r=Array.from(e.querySelectorAll("div"));for(const i of r){const o=hn(i),a=o.findIndex(u=>u.textContent?.trim()==="결제수단");if(a<0)continue;const s=o.slice(a+1).find(u=>u.textContent?.trim()&&u.textContent.trim()!=="결제수단");if(!s)continue;const c=o.slice(a+2).find(u=>/^\(.+\)$/.test(u.textContent?.trim()??"")),l=c?.textContent?.trim().replace(/^\(|\)$/g,"")??"",d=l&&t.endsWith(")")?t.replace(/\)$/,`, ${l})`):t;s.textContent?.trim()!==d&&(s.textContent=d,s.dataset.kginicisPaymentMethodPatched="true"),i.dataLine truncated
//# sourceMappingURL=plugin.iife.js.map
@@ -1 +0,0 @@
{"version":3,"file":"plugin.iife.js","sources":["../../resources/js/paymentDomCleanup.ts","../../resources/js/paymentCloseMessageListener.ts","../../resources/js/handlers/requestPayment.ts","../../resources/js/handlers/index.ts","../../resources/js/orderResponseInterceptor.ts","../../resources/js/receiptPopup.ts","../../resources/js/mypageOrderShowInjector.ts","../../resources/js/adminOrderPaymentDisplayInjector.ts","../../resources/js/orderCompleteReceiptInjector.ts","../../resources/js/vbankInfoInjector.ts","../../resources/js/checkoutJpyPaymentMethodRestrictor.ts","../../resources/js/checkoutNaverpayBrandButton.ts","../../resources/js/adminPaymentMethodBrandInjector.ts","../../resources/js/index.ts"],"sourcesContent":["export const KOREAN_PAYMENT_FORM_ID_PREFIX = 'kginicis_pay_form_';\nconst RELOAD_STANDARD_PAY_SDK_KEY = '__sirsoftKginicisReloadStandardPaySdk';\nconst MOBILE_PAYMENT_RETURN_PENDING_KEY = '__sirsoftKginicisMobilePaymentReturnPending';\nconst MOBILE_PAYMENT_RETURN_TTL_MS = 30 * 60 * 1000;\n\nexport function removeKoreanPaymentForms(): number {\n if (typeof document === 'undefined') {\n return 0;\n }\n\n const forms = document.querySelectorAll<HTMLFormElement>(\n `form[id^=\"${KOREAN_PAYMENT_FORM_ID_PREFIX}\"]`,\n );\n\n forms.forEach((form) => form.remove());\n\n return forms.length;\n}\n\nexport function markStandardPaySdkForReload(): void {\n if (typeof window === 'undefined') {\n return;\n }\n\n (window as unknown as Record<string, unknown>)[RELOAD_STANDARD_PAY_SDK_KEY] = true;\n}\n\nexport function consumeStandardPaySdkReloadFlag(): boolean {\n if (typeof window === 'undefined') {\n return false;\n }\n\n const w = window as unknown as Record<string, unknown>;\n const shouldReload = w[RELOAD_STANDARD_PAY_SDK_KEY] === true;\n delete w[RELOAD_STANDARD_PAY_SDK_KEY];\n\n return shouldReload;\n}\n\nexport function resetStandardPaySdk(sdkUrl: string): void {\n if (typeof window === 'undefined' || typeof document === 'undefined') {\n return;\n }\n\n (window as any).INIStdPay = undefined;\n\n document\n .querySelectorAll<HTMLScriptElement>(\n `script[src=\"${sdkUrl}\"], script[src*=\"/INIStdPay_third-party.js\"]`,\n )\n .forEach((script) => script.remove());\n}\n\nexport function markMobilePaymentReturnPending(now = Date.now()): void {\n if (typeof window === 'undefined') {\n return;\n }\n\n try {\n window.sessionStorage.setItem(MOBILE_PAYMENT_RETURN_PENDING_KEY, String(now));\n } catch {\n (window as unknown as Record<string, unknown>)[MOBILE_PAYMENT_RETURN_PENDING_KEY] = now;\n }\n}\n\nexport function clearMobilePaymentReturnPending(): void {\n if (typeof window === 'undefined') {\n return;\n }\n\n try {\n window.sessionStorage.removeItem(MOBILE_PAYMENT_RETURN_PENDING_KEY);\n } catch {\n // sessionStorage 접근이 막힌 환경에서는 window fallback 만 정리한다.\n }\n\n delete (window as unknown as Record<string, unknown>)[MOBILE_PAYMENT_RETURN_PENDING_KEY];\n}\n\nexport function hasMobilePaymentReturnPending(now = Date.now()): boolean {\n if (typeof window === 'undefined') {\n return false;\n }\n\n let markedAt: number | null = null;\n\n try {\n const stored = window.sessionStorage.getItem(MOBILE_PAYMENT_RETURN_PENDING_KEY);\n markedAt = stored ? Number(stored) : null;\n } catch {\n markedAt = null;\n }\n\n if (!Number.isFinite(markedAt)) {\n const fallback = (window as unknown as Record<string, unknown>)[MOBILE_PAYMENT_RETURN_PENDING_KEY];\n markedAt = typeof fallback === 'number' ? fallback : Number(fallback);\n }\n\n if (!Number.isFinite(markedAt)) {\n clearMobilePaymentReturnPending();\n return false;\n }\n\n if (now - markedAt > MOBILE_PAYMENT_RETURN_TTL_MS) {\n clearMobilePaymentReturnPending();\n return false;\n }\n\n return true;\n}\n\nexport function consumeMobilePaymentReturnPending(now = Date.now()): boolean {\n if (!hasMobilePaymentReturnPending(now)) {\n return false;\n }\n\n clearMobilePaymentReturnPending();\n\n return true;\n}\n","import {\n clearMobilePaymentReturnPending,\n hasMobilePaymentReturnPending,\n markStandardPaySdkForReload,\n removeKoreanPaymentForms,\n} from './paymentDomCleanup';\n\nconst PLUGIN_IDENTIFIER = 'sirsoft-pay_kginicis';\nconst CLOSE_MESSAGE_SOURCE = PLUGIN_IDENTIFIER;\nconst CLOSE_MESSAGE_TYPE = 'payment-window-closed';\nconst LISTENER_INSTALLED_KEY = '__sirsoftKginicisPaymentCloseListenerInstalled';\nconst ACTIVE_STANDARD_PAYMENT_CLOSE_CONTEXT_KEY = '__sirsoftKginicisActiveStandardPaymentCloseContext';\nconst RESET_RETRY_LIMIT = 20;\nconst RESET_RETRY_INTERVAL_MS = 100;\n\nconst logger = {\n info: (...args: unknown[]) => console.info(`[${PLUGIN_IDENTIFIER}]`, ...args),\n warn: (...args: unknown[]) => Line truncated
@@ -19,6 +19,9 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
private const TEST_MODE_NOTICE_ROW_ID = 'kginicis_test_mode_order_settings_notice';
/** 주문설정 탭이 활성일 때만 fetch (탭별 지연 로딩) */
private const ORDER_SETTINGS_TAB_CONDITION = "{{(query.tab || _global.activeEcommerceSettingsTab || 'basic_info') === 'order_settings'}}";
private const TEST_MODE_CONDITION = 'kginicis_test_mode_status.data?.is_test_mode === true';
private const CORE_NO_PG_METHODS = "['point','deposit','free','dbank']";
@@ -26,7 +29,9 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
private const KGINICIS_NO_PG_METHODS = "['point','deposit','free','dbank','kginicis_samsung_pay','kginicis_naverpay','kginicis_lpay','kginicis_kakaopay','kginicis_japan_paypay','kginicis_japan_cvs']";
/**
* @return array<string, array<string, mixed>>
* 이 리스너가 구독하는 훅 정의를 반환합니다.
*
* @return array<string, array<string, mixed>> 훅 이름 => 구독 설정
*/
public static function getSubscribedHooks(): array
{
@@ -42,14 +47,16 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
/**
* 기본 핸들러 (미사용).
*
* @param mixed ...$args
* @param mixed ...$args
*/
public function handle(...$args): void {}
/**
* @param array<string, mixed> $layout
* @param int $templateId
* @return array<string, mixed>
* 이커머스 결제수단 설정 레이아웃에 KG 이니시스 간편결제를 PG 선택 불필요 항목으로 반영합니다.
*
* @param array<string, mixed> $layout 대상 레이아웃 정의
* @param int $templateId 레이아웃이 속한 템플릿 ID
* @return array<string, mixed> 보정된 레이아웃 정의
*/
public function markEasyPayMethodsAsPgNotRequired(array $layout, int $templateId): array
{
@@ -63,14 +70,17 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
}
/**
* @param array<string, mixed> $node
* @return array<string, mixed>
* 레이아웃 노드를 재귀 순회하며 코어의 PG 불필요 결제수단 목록을 이니시스 목록으로 치환합니다.
*
* @param array<string, mixed> $node 순회 대상 노드
* @return array<string, mixed> 치환이 반영된 노드
*/
private function replaceNoPgMethodExpressions(array $node): array
{
foreach ($node as $key => $value) {
if (is_array($value)) {
$node[$key] = $this->replaceNoPgMethodExpressions($value);
continue;
}
@@ -87,8 +97,10 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
}
/**
* @param array<string, mixed> $layout
* @return array<string, mixed>
* 테스트 모드 경고에 필요한 데이터 소스와 안내 노드를 레이아웃에 보장합니다.
*
* @param array<string, mixed> $layout 대상 레이아웃 정의
* @return array<string, mixed> 경고가 보장된 레이아웃 정의
*/
private function ensureTestModeWarning(array $layout): array
{
@@ -98,8 +110,10 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
}
/**
* @param array<string, mixed> $layout
* @return array<string, mixed>
* 테스트 모드 상태 조회 데이터 소스가 없으면 레이아웃에 추가합니다.
*
* @param array<string, mixed> $layout 대상 레이아웃 정의
* @return array<string, mixed> 데이터 소스가 보장된 레이아웃 정의
*/
private function ensureTestModeDataSource(array $layout): array
{
@@ -119,6 +133,7 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
'type' => 'api',
'endpoint' => '/api/plugins/sirsoft-pay_kginicis/admin/settings/test-mode-status',
'method' => 'GET',
'if' => self::ORDER_SETTINGS_TAB_CONDITION,
'auto_fetch' => true,
'auth_required' => true,
];
@@ -129,8 +144,10 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
}
/**
* @param array<string, mixed> $node
* @return array<string, mixed>
* 주문설정 탭을 찾아 테스트 모드 안내 노드를 삽입합니다.
*
* @param array<string, mixed> $node 순회 대상 노드
* @return array<string, mixed> 안내 노드가 삽입된 노드
*/
private function insertTestModeNotice(array $node): array
{
@@ -162,7 +179,11 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
}
/**
* @param array<int, mixed> $children
* 자식 노드 목록에서 지정한 id 를 가진 노드의 인덱스를 찾습니다.
*
* @param array<int, mixed> $children 자식 노드 목록
* @param string $id 찾을 노드 id
* @return int|null 찾은 인덱스 (없으면 null)
*/
private function findChildIndexById(array $children, string $id): ?int
{
@@ -176,17 +197,27 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
}
/**
* @param array<int, mixed> $children
* 자식 노드 목록에 지정한 id 를 가진 노드가 있는지 확인합니다.
*
* @param array<int, mixed> $children 자식 노드 목록
* @param string $id 확인할 노드 id
* @return bool 존재 여부
*/
private function hasChildWithId(array $children, string $id): bool
{
return $this->findChildIndexById($children, $id) !== null;
}
/**
* 기존 표시 조건에 테스트 모드 조건을 OR 로 병합합니다.
*
* @param string $condition 기존 표시 조건식
* @return string 테스트 모드 조건이 병합된 조건식
*/
private function mergeTestModeCondition(string $condition): string
{
if (str_contains($condition, self::TEST_MODE_CONDITION)) {
return $condition !== '' ? $condition : '{{' . self::TEST_MODE_CONDITION . '}}';
return $condition !== '' ? $condition : '{{'.self::TEST_MODE_CONDITION.'}}';
}
$inner = trim($condition);
@@ -195,15 +226,17 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
}
if ($inner === '') {
return '{{' . self::TEST_MODE_CONDITION . '}}';
return '{{'.self::TEST_MODE_CONDITION.'}}';
}
return '{{' . $inner . ' || ' . self::TEST_MODE_CONDITION . '}}';
return '{{'.$inner.' || '.self::TEST_MODE_CONDITION.'}}';
}
/**
* @param array<string, mixed> $notice
* @return array<string, mixed>
* 안내 컨테이너에 이니시스 테스트 모드 안내 행을 중복 없이 추가합니다.
*
* @param array<string, mixed> $notice 안내 컨테이너 노드
* @return array<string, mixed> 안내 행이 추가된 컨테이너 노드
*/
private function appendTestModeNoticeRow(array $notice): array
{
@@ -219,7 +252,9 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
}
/**
* @return array<string, mixed>
* 테스트 모드 안내를 감싸는 컨테이너 노드 정의를 반환합니다.
*
* @return array<string, mixed> 컨테이너 노드 정의
*/
private function testModeNoticeContainerNode(): array
{
@@ -227,7 +262,7 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
'id' => self::TEST_MODE_NOTICE_ID,
'type' => 'basic',
'name' => 'Div',
'if' => '{{' . self::TEST_MODE_CONDITION . '}}',
'if' => '{{'.self::TEST_MODE_CONDITION.'}}',
'props' => [
'className' => 'mb-4 space-y-3 rounded-lg border border-orange-200 bg-orange-50 p-4 dark:border-orange-700 dark:bg-orange-900/20',
],
@@ -238,7 +273,9 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
}
/**
* @return array<string, mixed>
* 이니시스 테스트 모드 안내 문구와 설정 이동 버튼으로 구성된 행 노드 정의를 반환합니다.
*
* @return array<string, mixed> 안내 행 노드 정의
*/
private function testModeNoticeRowNode(): array
{
@@ -246,7 +283,7 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
'id' => self::TEST_MODE_NOTICE_ROW_ID,
'type' => 'basic',
'name' => 'Div',
'if' => '{{' . self::TEST_MODE_CONDITION . '}}',
'if' => '{{'.self::TEST_MODE_CONDITION.'}}',
'props' => [
'className' => 'flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between',
],
@@ -293,11 +330,11 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
],
],
'children' => [
[
'type' => 'basic',
'name' => 'Span',
'text' => '$t:sirsoft-pay_kginicis.admin.test_mode_settings_warning_action',
],
[
'type' => 'basic',
'name' => 'Span',
'text' => '$t:sirsoft-pay_kginicis.admin.test_mode_settings_warning_action',
],
],
],
],
@@ -3,8 +3,10 @@
namespace Plugins\Sirsoft\PayKginicis\Tests\Feature\Controllers;
use App\Models\User;
use Illuminate\Support\Facades\Log;
use App\Services\PluginSettingsService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderFactory;
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderPaymentFactory;
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
@@ -20,6 +22,21 @@ class PaymentCallbackControllerTest extends PluginTestCase
private const TEST_SIGN_KEY = 'SU5JTElURV9UUklQTEVERVNfS0VZU1RS';
/**
* 애플리케이션 로그를 spy 로 감시하되 `activity` 채널은 실제 로거로 유지합니다.
*
* `Log::spy()` 단독 사용 시 `Log::channel('activity')` 가 null 을 반환하고,
* 결제 완료 훅이 태우는 활동 로그가 null 에 `info()` 를 호출해 \Error 로 죽는다.
* (`ResolvesActivityLogType::logActivity` 의 catch 는 \Exception 만 잡아 통과시킴)
*/
private function spyApplicationLogKeepingActivityChannel(): void
{
$activityChannel = Log::channel('activity');
Log::spy();
Log::shouldReceive('channel')->with('activity')->andReturn($activityChannel);
}
private function makeAuthorizeResponse(string $tid, string $moid, int $amount, string $resultCode = '0000', array $overrides = []): array
{
return array_merge([
@@ -46,7 +63,7 @@ class PaymentCallbackControllerTest extends PluginTestCase
$order = OrderFactory::new()->create([
'user_id' => $user->id,
'order_number' => 'ORD-TEST-' . random_int(10000, 99999),
'order_number' => 'ORD-TEST-'.random_int(10000, 99999),
'order_status' => OrderStatusEnum::PENDING_ORDER,
'currency' => 'KRW',
'currency_snapshot' => self::krwCurrencySnapshot(),
@@ -97,11 +114,11 @@ class PaymentCallbackControllerTest extends PluginTestCase
'redirect_fail_url' => '/shop/checkout',
];
$settingsMock = $this->createMock(\App\Services\PluginSettingsService::class);
$settingsMock = $this->createMock(PluginSettingsService::class);
$settingsMock->method('get')
->willReturn(array_merge($defaults, $overrides));
$this->app->instance(\App\Services\PluginSettingsService::class, $settingsMock);
$this->app->instance(PluginSettingsService::class, $settingsMock);
}
private function makeCallbackParams(string $moid, int $totPrice, array $overrides = []): array
@@ -109,7 +126,7 @@ class PaymentCallbackControllerTest extends PluginTestCase
return array_merge([
'resultCode' => '0000',
'resultMsg' => '성공',
'authToken' => 'AUTH_TOKEN_' . uniqid(),
'authToken' => 'AUTH_TOKEN_'.uniqid(),
'authUrl' => 'https://fcstdpay.inicis.com/api/payAuth',
// idc_name 은 KG 이니시스가 success 콜백에 항상 포함시키는 IDC 식별자.
// 컨트롤러가 isValidIdcAuthUrl 화이트리스트 검증에 사용 (SSRF 방어).
@@ -127,7 +144,7 @@ class PaymentCallbackControllerTest extends PluginTestCase
$order = $this->createTestOrder(50000);
$this->mockPluginSettings();
$tid = 'TID_' . uniqid();
$tid = 'TID_'.uniqid();
$params = $this->makeCallbackParams($order->order_number, 50000);
Http::fake([
@@ -163,7 +180,7 @@ class PaymentCallbackControllerTest extends PluginTestCase
$order = $this->createTestOrder(50000);
$this->mockPluginSettings();
$tid = 'TID_EASYPAY_' . uniqid();
$tid = 'TID_EASYPAY_'.uniqid();
$params = $this->makeCallbackParams($order->order_number, 50000);
Http::fake([
@@ -306,7 +323,7 @@ class PaymentCallbackControllerTest extends PluginTestCase
$order = $this->createTestOrder(50000);
$this->mockPluginSettings();
$tid = 'TID_POST_COMMIT_' . uniqid();
$tid = 'TID_POST_COMMIT_'.uniqid();
$params = $this->makeCallbackParams($order->order_number, 50000);
$orderService = $this->createMock(OrderProcessingService::class);
@@ -316,7 +333,7 @@ class PaymentCallbackControllerTest extends PluginTestCase
$orderService->expects($this->once())
->method('completePayment')
->willReturnCallback(function (Order $callbackOrder, array $paymentData, ?int $paidAmount) use ($tid): void {
\Illuminate\Support\Facades\DB::transaction(function () use ($callbackOrder, $paymentData, $paidAmount, $tid): void {
DB::transaction(function () use ($callbackOrder, $paymentData, $paidAmount, $tid): void {
$callbackOrder->forceFill([
'order_status' => OrderStatusEnum::PAYMENT_COMPLETE,
'total_paid_amount' => $paidAmount,
@@ -477,7 +494,7 @@ class PaymentCallbackControllerTest extends PluginTestCase
$this->markVbankIssued($order, 'VBANK_TID_LOG', '1234567890');
$this->mockPluginSettings();
Log::spy();
$this->spyApplicationLogKeepingActivityChannel();
$response = $this->post('/plugins/sirsoft-pay_kginicis/payment/vbank-notify', $this->makeVbankNotifyPayload([
'no_tid' => 'VBANK_TID_LOG',
@@ -500,7 +517,7 @@ class PaymentCallbackControllerTest extends PluginTestCase
$order = $this->createTestOrder(50000);
$this->mockPluginSettings();
$tid = 'MOBILE_TID_' . uniqid();
$tid = 'MOBILE_TID_'.uniqid();
Http::fake([
'fcmobile.inicis.com/smart/payReq.ini' => Http::response(http_build_query([
@@ -521,7 +538,7 @@ class PaymentCallbackControllerTest extends PluginTestCase
]), 200),
]);
$response = $this->post('/plugins/sirsoft-pay_kginicis/payment/mobile/callback?' . http_build_query([
$response = $this->post('/plugins/sirsoft-pay_kginicis/payment/mobile/callback?'.http_build_query([
'orderId' => $order->order_number,
'selectedPaymentMethod' => 'kginicis_naverpay',
]), [
@@ -560,7 +577,7 @@ class PaymentCallbackControllerTest extends PluginTestCase
$order = $this->createTestOrder(50000);
$this->mockPluginSettings();
$tid = 'MOBILE_TID_POST_COMMIT_' . uniqid();
$tid = 'MOBILE_TID_POST_COMMIT_'.uniqid();
$orderService = $this->createMock(OrderProcessingService::class);
$orderService->method('findByOrderNumber')
@@ -569,7 +586,7 @@ class PaymentCallbackControllerTest extends PluginTestCase
$orderService->expects($this->once())
->method('completePayment')
->willReturnCallback(function (Order $callbackOrder, array $paymentData, ?int $paidAmount) use ($tid): void {
\Illuminate\Support\Facades\DB::transaction(function () use ($callbackOrder, $paymentData, $paidAmount, $tid): void {
DB::transaction(function () use ($callbackOrder, $paymentData, $paidAmount, $tid): void {
$callbackOrder->forceFill([
'order_status' => OrderStatusEnum::PAYMENT_COMPLETE,
'total_paid_amount' => $paidAmount,
@@ -660,7 +677,7 @@ class PaymentCallbackControllerTest extends PluginTestCase
$this->markVbankIssued($order, 'MOBILE_VBANK_TID_LOG', '1234567890');
$this->mockPluginSettings();
Log::spy();
$this->spyApplicationLogKeepingActivityChannel();
$response = $this->post('/plugins/sirsoft-pay_kginicis/payment/mobile/vbank-notify', [
'P_STATUS' => '02',
@@ -731,16 +748,16 @@ class PaymentCallbackControllerTest extends PluginTestCase
private function makeVbankNotifyPayload(array $overrides = []): array
{
return array_merge([
'no_tid' => 'VBANK_TID_' . uniqid(),
'no_oid' => 'ORD-TEST-VBANK',
'id_merchant' => 'INIpayTest',
'dt_trans' => now()->format('Ymd'),
'tm_trans' => now()->format('His'),
'cd_bank' => '04',
'no_vacct' => '1234567890',
'amt_input' => '30000',
'no_tid' => 'VBANK_TID_'.uniqid(),
'no_oid' => 'ORD-TEST-VBANK',
'id_merchant' => 'INIpayTest',
'dt_trans' => now()->format('Ymd'),
'tm_trans' => now()->format('His'),
'cd_bank' => '04',
'no_vacct' => '1234567890',
'amt_input' => '30000',
'nm_inputbank' => '국민은행',
'nm_input' => '홍길동',
'nm_input' => '홍길동',
], $overrides);
}
@@ -14,7 +14,9 @@ export default defineConfig({
entryFileNames: 'js/plugin.iife.js',
},
},
sourcemap: true,
// 배포용 빌드(--production)는 G7_BUILD_SOURCEMAP=0 을 주입해 소스맵을 생성하지 않는다.
// 미설정(로컬 npm run build)이면 생성 — 개발 디버깅 경험을 유지한다.
sourcemap: !['0', 'false'].includes(process.env.G7_BUILD_SOURCEMAP ?? ''),
minify: 'esbuild',
target: 'es2020',
chunkSizeWarningLimit: 500,
@@ -0,0 +1,10 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"identifier": "sirsoft-pay_nhnkcp",
"version": "1.0.0-beta.4",
"components": {
"basic": [],
"composite": [],
"layout": []
}
}
@@ -15,4 +15,3 @@ try {
}
<\/script>
</body></html>`),C.close(),h=window.setTimeout(()=>{v(new Error("KCP SDK load timeout"))},15e3),y.__kcpReady=()=>{h!==void 0&&(clearTimeout(h),h=void 0)}})}const _t={nhnkcp_payco:"PAYCO",nhnkcp_naverpay:"네이버페이",nhnkcp_naverpay_point:"네이버페이 포인트",nhnkcp_kakaopay:"카카오페이",nhnkcp_applepay:"Apple Pay"},gt="0 0 0 2px #ffffff, 0 0 0 5px rgba(0,0,0,0.55)";function ue(){return document.querySelector(".nhnkcp-easy-pay-btns")??null}function pe(){const e=ue();e&&e.querySelectorAll("button").forEach(t=>{t.style.boxShadow="",t.style.outline=""})}function wt(e){const t=ue();if(!t)return;const n=_t[e];t.querySelectorAll("button").forEach(r=>{r.textContent?.trim()===n?(r.style.boxShadow=gt,r.style.outline="none"):(r.style.boxShadow="",r.style.outline="")})}function Pt(e){const t=e.params?.paymentMethod;if(!t)return;const n=window.G7Core;if(M(t)&&!S()){const a=H();n?.state?.setLocal?.({paymentErrorMessage:a,isSubmittingOrder:!1}),n?.modal?.open?.("nhnkcp_payment_error_modal"),pe();return}const r=typeof t=="string"&&t.startsWith("nhnkcp_");n?.state?.setLocal?.({paymentMethod:t,serverPaymentMethod:r?"card":t}),r?wt(t):pe()}function kt(e){const t=document.createElement("textarea");t.value=e,t.setAttribute("readonly","readonly"),t.style.position="fixed",t.style.left="-9999px",t.style.top="0",document.body.appendChild(t),t.select();try{document.execCommand("copy")}finally{t.remove()}}async function bt(e){const t=String(e.params?.text??"");if(t!==""){if(navigator.clipboard&&typeof navigator.clipboard.writeText=="function"){await navigator.clipboard.writeText(t);return}kt(t)}}const me={requestPayment:le,setPaymentMethod:Pt,copyToClipboard:bt},ye="/api/modules/sirsoft-ecommerce/user/orders",Ct="sirsoft-nhnkcp",j="sirsoft-pay_nhnkcp",fe="nhnkcp_",b={info:(...e)=>console.info(`[${j}]`,...e),warn:(...e)=>console.warn(`[${j}]`,...e),error:(...e)=>console.error(`[${j}]`,...e)};function Nt(e){return typeof e=="string"?e:e instanceof URL?e.toString():typeof Request<"u"&&e instanceof Request?e.url:String(e)}function xt(e,t){return t?.method?t.method.toUpperCase():typeof Request<"u"&&e instanceof Request?e.method.toUpperCase():"GET"}function vt(e,t){if(t!=="POST")return!1;const n=e.split("?")[0].split("#")[0];return n===ye||n.endsWith(ye)}function Et(){return window.location.pathname+window.location.search+window.location.hash}function At(e,t){const n=JSON.stringify(t);return new Response(n,{status:e.status,statusText:e.statusText,headers:e.headers})}function St(e){return new Response(JSON.stringify({success:!1,message:e,error:e,errors:{payment_method:[e]}}),{status:422,statusText:"Unprocessable Content",headers:{"Content-Type":"application/json"}})}function he(e){try{return JSON.parse(e).payment_method}catch{return}}function Kt(e,t){try{const n=JSON.parse(e);return n.payment_method=t,JSON.stringify(n)}catch{return e}}function It(){if(typeof window>"u"||typeof window.fetch!="function")return;const e="__sirsoftNhnkcpInterceptorInstalled",t=window;if(t[e])return;t[e]=!0;const n="__sirsoftPgOriginalFetch";t[n]||(t[n]=window.fetch.bind(window));const r=t[n],a=window.fetch.bind(window);window.fetch=async function(i,s){const c=Nt(i),d=xt(i,s);if(!vt(c,d))return a(i,s);let l,u=s;if(s?.body&&typeof s.body=="string"){const p=he(s.body);typeof p=="string"&&p.startsWith(fe)&&(l=p,u={...s,body:Kt(s.body,"card")},b.info(`easy pay detected: replacing payment_method '${p}' → 'card'`))}if(!l)try{const p=window.G7Core?.state?.getLocal?.()?.paymentMethod;typeof p=="string"&&p.startsWith(fe)&&(l=p,b.info(`easy pay detected from local state: '${p}'`))}catch{}if(M(l)&&!S()){const p=H();return b.warn(p),St(p)}const y=await(l?r:a)(i,u);let x;try{x=y.clone()}catch{return y}let h=null;try{h=await x.json()}catch{return y}const f=h?.data;if(!f)return y;const se=f.requires_pg_payment===!0,v=f.pg_provider===Ct;if(!l&&(!se||!v)||l&&!f.order&&!f.pg_payment_data)return y;let w=f.pg_payment_data;if(!w&&l&&f.order){const p=f.order,g=p.options,k=g?.[0]?.product_name??String(p.order_number??""),I=(g?.length??0)>1?`${k} 외 ${(g?.length??0)-1}건`:k;w={order_number:p.order_number,order_name:I,amount:Math.floor(Number(p.total_due_amount??p.total_amount??0)),currency:"KRW",customer_name:p.orderer_name??null,customer_email:p.orderer_email??null,customer_phone:String(p.orderer_phone??"").replace(/[^0-9]/g,"")},b.info("pg_payment_data constructed from order (기본 PG가 타 PG)",{order_number:w.order_number,amount:w.amount})}if(!w)return b.warn("nhnkcp order detected but pg_payment_data missing"),y;b.info("intercepted order create response — opening PG popup");const C=f.order?{tax_amount:f.order.total_tax_amount??0,vat_amount:f.order.total_vat_amount??0,tax_free_amount:f.order.total_tax_free_amount??0}:{},_=!l&&u?.body&&typeof u.body=="string"?(()=>{const p=he(u.body);return p?{...w,pay_method:p,...C}:{...w,...C}})():{...w,...C};le({params:{pgPaymentData:_,paymentMethod:l}});const E={...h,data:{...f,requires_pg_payment:!1,redirect_url:Et()}};Line truncated
//# sourceMappingURL=plugin.iife.js.map
@@ -1 +0,0 @@
{"version":3,"file":"plugin.iife.js","sources":["../../resources/js/paymentCloseReport.ts","../../resources/js/support/applePayDevice.ts","../../resources/js/handlers/requestPayment.ts","../../resources/js/handlers/setPaymentMethod.ts","../../resources/js/handlers/copyToClipboard.ts","../../resources/js/handlers/index.ts","../../resources/js/orderResponseInterceptor.ts","../../resources/js/orderCompleteReceiptInjector.ts","../../resources/js/mypageOrderShowInjector.ts","../../resources/js/checkoutEasyPayInjector.ts","../../resources/js/adminApplePayNoticeInjector.ts","../../resources/js/adminPaymentMethodBrandInjector.ts","../../resources/js/adminOrderPaymentDisplayInjector.ts","../../resources/js/index.ts"],"sourcesContent":["export interface PaymentCloseReportContext {\n closeReportUrl?: string;\n oid: string;\n price: number;\n buyer_email?: string;\n buyer_phone?: string;\n payment_method?: string;\n}\n\nfunction resolveApiUrl(url: string): string {\n if (/^https?:\\/\\//i.test(url) || url.startsWith('/api/')) {\n return url;\n }\n\n if (url.startsWith('/plugins/')) {\n return `/api${url}`;\n }\n\n if (url.startsWith('plugins/')) {\n return `/api/${url}`;\n }\n\n return url;\n}\n\nfunction trimReason(reason: string): string {\n return reason.trim().slice(0, 160);\n}\n\nfunction resolveRetryUrl(closeReportUrl?: string): string | undefined {\n if (!closeReportUrl) {\n return undefined;\n }\n\n return closeReportUrl.replace(/\\/payment\\/close-report$/, '/payment/retry');\n}\n\nasync function postPaymentContext(url: string, payload: Record<string, unknown>, keepalive = false): Promise<void> {\n const apiClient = ((window as any).G7Core)?.api;\n if (typeof apiClient?.post === 'function') {\n await apiClient.post(url, payload);\n return;\n }\n\n const response = await fetch(resolveApiUrl(url), {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(payload),\n keepalive,\n });\n\n if (!response.ok) {\n let message = `NHN KCP payment request failed (${response.status})`;\n try {\n const data = await response.json();\n const responseMessage = Array.isArray(data?.errors?.message)\n ? data.errors.message[0]\n : data?.error ?? data?.message;\n if (typeof responseMessage === 'string' && responseMessage.trim() !== '') {\n message = responseMessage;\n }\n } catch {\n // JSON 응답이 아니면 HTTP 상태 메시지를 사용한다.\n }\n throw new Error(message);\n }\n}\n\nexport async function preparePaymentRetry(context: PaymentCloseReportContext): Promise<void> {\n const retryUrl = resolveRetryUrl(context.closeReportUrl);\n if (!retryUrl) {\n return;\n }\n\n await postPaymentContext(retryUrl, {\n oid: context.oid,\n price: Number(context.price),\n buyer_email: context.buyer_email ?? '',\n buyer_phone: context.buyer_phone ?? '',\n payment_method: context.payment_method ?? '',\n });\n}\n\nexport async function reportPaymentWindowClosed(\n context: PaymentCloseReportContext,\n reason = 'kcp-window-closed',\n): Promise<void> {\n if (!context.closeReportUrl) {\n return;\n }\n\n const payload = {\n oid: context.oid,\n price: Number(context.price),\n buyer_email: context.buyer_email ?? '',\n buyer_phone: context.buyer_phone ?? '',\n payment_method: context.payment_method ?? '',\n reason: trimReason(reason) || 'kcp-window-closed',\n };\n\n try {\n await postPaymentContext(context.closeReportUrl, payload, true);\n } catch (error) {\n console.warn('[sirsoft-pay_nhnkcp] failed to report payment window close', error);\n }\n}\n","export const NHNKCP_APPLEPAY_METHOD = 'nhnkcp_applepay';\n\nexport function isNhnKcpApplePayMethod(paymentMethod: unknown): boolean {\n return paymentMethod === NHNKCP_APPLEPAY_METHOD;\n}\n\nexport function isIosMobileDevice(): boolean {\n if (typeof navigator === 'undefined') return false;\n\n const nav = navigator as Navigator & {\n userAgentData?: {\n mobile?: boolean;\n platform?: string;\n };\n maxTouchPoints?: number;\n };\n const ua = (nav.userAgent || '').toLowerCase();\n const platform = ((nav.userAgentData?.platform ?? nav.platform) || '').toLowerCase();\n const touchPoints = nav.maxTouchPoints ?? 0;\n\n if (/iphone|ipad|ipod/.test(ua) || /iphone|ipad|ipod|ios/.test(platform)) {\n return true;\n }\n\n return /macintosh|mac os x/.test(ua) && touchPoints > 1;\n}\n\nexport function applePayUnsupportedMessage(): string {\n const lang = typeof document !== 'undefined'\n ? document.documentElement.lang\n : '';\n\n if ((lang || '').toLowerCase(Line truncated
@@ -19,6 +19,9 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
private const TEST_MODE_NOTICE_ROW_ID = 'nhnkcp_test_mode_order_settings_notice';
/** 주문설정 탭이 활성일 때만 fetch (탭별 지연 로딩) */
private const ORDER_SETTINGS_TAB_CONDITION = "{{(query.tab || _global.activeEcommerceSettingsTab || 'basic_info') === 'order_settings'}}";
private const TEST_MODE_CONDITION = 'nhnkcp_test_mode_status.data?.is_test_mode === true';
private const CORE_NO_PG_METHODS = ['point', 'deposit', 'free', 'dbank'];
@@ -32,7 +35,9 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
];
/**
* @return array<string, array<string, mixed>>
* 이 리스너가 구독하는 훅 정의를 반환합니다.
*
* @return array<string, array<string, mixed>> 훅 이름 => 구독 설정
*/
public static function getSubscribedHooks(): array
{
@@ -46,14 +51,18 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
}
/**
* @param mixed ...$args
* 기본 핸들러 (미사용).
*
* @param mixed ...$args
*/
public function handle(...$args): void {}
/**
* @param array<string, mixed> $layout
* @param int $templateId
* @return array<string, mixed>
* 이커머스 결제수단 설정 레이아웃에 NHN KCP 간편결제를 PG 선택 불필요 항목으로 반영합니다.
*
* @param array<string, mixed> $layout 대상 레이아웃 정의
* @param int $templateId 레이아웃이 속한 템플릿 ID
* @return array<string, mixed> 보정된 레이아웃 정의
*/
public function markEasyPayMethodsAsPgNotRequired(array $layout, int $templateId): array
{
@@ -67,14 +76,17 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
}
/**
* @param array<string, mixed> $node
* @return array<string, mixed>
* 레이아웃 노드를 재귀 순회하며 PG 불필요 결제수단 표현식에 NHN KCP 간편결제를 추가합니다.
*
* @param array<string, mixed> $node 순회 대상 노드
* @return array<string, mixed> 치환이 반영된 노드
*/
private function replaceNoPgMethodExpressions(array $node): array
{
foreach ($node as $key => $value) {
if (is_array($value)) {
$node[$key] = $this->replaceNoPgMethodExpressions($value);
continue;
}
@@ -86,6 +98,12 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
return $node;
}
/**
* 코어 PG 불필요 결제수단 배열 표현식을 찾아 NHN KCP 간편결제 수단을 덧붙입니다.
*
* @param string $expression 대상 표현식
* @return string NHN KCP 수단이 추가된 표현식 (대상이 아니면 원본 그대로)
*/
private function addNhnKcpMethodsToNoPgArray(string $expression): string
{
return preg_replace_callback(
@@ -106,15 +124,17 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
}
}
return "['" . implode("','", $ids) . "'].includes(\$method.id)";
return "['".implode("','", $ids)."'].includes(\$method.id)";
},
$expression
) ?? $expression;
}
/**
* @param array<string, mixed> $layout
* @return array<string, mixed>
* 테스트 모드 경고에 필요한 데이터 소스와 안내 노드를 레이아웃에 보장합니다.
*
* @param array<string, mixed> $layout 대상 레이아웃 정의
* @return array<string, mixed> 경고가 보장된 레이아웃 정의
*/
private function ensureTestModeWarning(array $layout): array
{
@@ -124,8 +144,10 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
}
/**
* @param array<string, mixed> $layout
* @return array<string, mixed>
* 테스트 모드 상태 조회 데이터 소스가 없으면 레이아웃에 추가합니다.
*
* @param array<string, mixed> $layout 대상 레이아웃 정의
* @return array<string, mixed> 데이터 소스가 보장된 레이아웃 정의
*/
private function ensureTestModeDataSource(array $layout): array
{
@@ -145,6 +167,7 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
'type' => 'api',
'endpoint' => '/api/plugins/sirsoft-pay_nhnkcp/admin/settings/test-mode-status',
'method' => 'GET',
'if' => self::ORDER_SETTINGS_TAB_CONDITION,
'auto_fetch' => true,
'auth_required' => true,
];
@@ -155,8 +178,10 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
}
/**
* @param array<string, mixed> $node
* @return array<string, mixed>
* 주문설정 탭을 찾아 테스트 모드 안내 노드를 삽입합니다.
*
* @param array<string, mixed> $node 순회 대상 노드
* @return array<string, mixed> 안내 노드가 삽입된 노드
*/
private function insertTestModeNotice(array $node): array
{
@@ -188,7 +213,11 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
}
/**
* @param array<int, mixed> $children
* 자식 노드 목록에서 지정한 id 를 가진 노드의 인덱스를 찾습니다.
*
* @param array<int, mixed> $children 자식 노드 목록
* @param string $id 찾을 노드 id
* @return int|null 찾은 인덱스 (없으면 null)
*/
private function findChildIndexById(array $children, string $id): ?int
{
@@ -202,17 +231,27 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
}
/**
* @param array<int, mixed> $children
* 자식 노드 목록에 지정한 id 를 가진 노드가 있는지 확인합니다.
*
* @param array<int, mixed> $children 자식 노드 목록
* @param string $id 확인할 노드 id
* @return bool 존재 여부
*/
private function hasChildWithId(array $children, string $id): bool
{
return $this->findChildIndexById($children, $id) !== null;
}
/**
* 기존 표시 조건에 테스트 모드 조건을 OR 로 병합합니다.
*
* @param string $condition 기존 표시 조건식
* @return string 테스트 모드 조건이 병합된 조건식
*/
private function mergeTestModeCondition(string $condition): string
{
if (str_contains($condition, self::TEST_MODE_CONDITION)) {
return $condition !== '' ? $condition : '{{' . self::TEST_MODE_CONDITION . '}}';
return $condition !== '' ? $condition : '{{'.self::TEST_MODE_CONDITION.'}}';
}
$inner = trim($condition);
@@ -221,15 +260,17 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
}
if ($inner === '') {
return '{{' . self::TEST_MODE_CONDITION . '}}';
return '{{'.self::TEST_MODE_CONDITION.'}}';
}
return '{{' . $inner . ' || ' . self::TEST_MODE_CONDITION . '}}';
return '{{'.$inner.' || '.self::TEST_MODE_CONDITION.'}}';
}
/**
* @param array<string, mixed> $notice
* @return array<string, mixed>
* 안내 컨테이너에 NHN KCP 테스트 모드 안내 행을 중복 없이 추가합니다.
*
* @param array<string, mixed> $notice 안내 컨테이너 노드
* @return array<string, mixed> 안내 행이 추가된 컨테이너 노드
*/
private function appendTestModeNoticeRow(array $notice): array
{
@@ -245,7 +286,9 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
}
/**
* @return array<string, mixed>
* 테스트 모드 안내를 감싸는 컨테이너 노드 정의를 반환합니다.
*
* @return array<string, mixed> 컨테이너 노드 정의
*/
private function testModeNoticeContainerNode(): array
{
@@ -253,7 +296,7 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
'id' => self::TEST_MODE_NOTICE_ID,
'type' => 'basic',
'name' => 'Div',
'if' => '{{' . self::TEST_MODE_CONDITION . '}}',
'if' => '{{'.self::TEST_MODE_CONDITION.'}}',
'props' => [
'className' => 'mb-4 space-y-3 rounded-lg border border-orange-200 bg-orange-50 p-4 dark:border-orange-700 dark:bg-orange-900/20',
],
@@ -264,7 +307,9 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
}
/**
* @return array<string, mixed>
* NHN KCP 테스트 모드 안내 문구와 설정 이동 버튼으로 구성된 행 노드 정의를 반환합니다.
*
* @return array<string, mixed> 안내 행 노드 정의
*/
private function testModeNoticeRowNode(): array
{
@@ -272,7 +317,7 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
'id' => self::TEST_MODE_NOTICE_ROW_ID,
'type' => 'basic',
'name' => 'Div',
'if' => '{{' . self::TEST_MODE_CONDITION . '}}',
'if' => '{{'.self::TEST_MODE_CONDITION.'}}',
'props' => [
'className' => 'flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between',
],
@@ -319,11 +364,11 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
],
],
'children' => [
[
'type' => 'basic',
'name' => 'Span',
'text' => '$t:sirsoft-pay_nhnkcp.admin.test_mode_settings_warning_action',
],
[
'type' => 'basic',
'name' => 'Span',
'text' => '$t:sirsoft-pay_nhnkcp.admin.test_mode_settings_warning_action',
],
],
],
],
@@ -39,20 +39,27 @@ class NhnKcpApiServiceTest extends PluginTestCase
*/
private function makeServiceWithStubbedCli(string $response, array $settingsOverrides = []): array
{
// CLI 스텁은 `#!/bin/sh` 스크립트라 Windows exec() 가 실행하지 못한다
// (.exe 로 이름만 바꿔도 "not compatible with the version of Windows" 로 거부됨).
// Windows 분기(executeCliWindows)는 별도 검증 대상이므로 여기서는 건너뛴다.
if (DIRECTORY_SEPARATOR === '\\') {
$this->markTestSkipped('Linux CLI 스텁 기반 테스트 — Windows 분기 별도 검증.');
}
$service = $this->makeService($settingsOverrides);
$dir = sys_get_temp_dir() . '/nhnkcp_cli_' . uniqid('', true);
$dir = sys_get_temp_dir().'/nhnkcp_cli_'.uniqid('', true);
$this->assertTrue(mkdir($dir), '임시 CLI 디렉터리 생성 실패');
$logPath = $dir . '/argv.log';
$responsePath = $dir . '/response.txt';
$logPath = $dir.'/argv.log';
$responsePath = $dir.'/response.txt';
file_put_contents($responsePath, $response);
$script = "#!/bin/sh\n"
. "printf '%s\\n' \"\$@\" > " . escapeshellarg($logPath) . "\n"
. 'cat ' . escapeshellarg($responsePath) . "\n";
."printf '%s\\n' \"\$@\" > ".escapeshellarg($logPath)."\n"
.'cat '.escapeshellarg($responsePath)."\n";
foreach (['pp_cli', 'pp_cli_x64'] as $binName) {
$binPath = $dir . '/' . $binName;
$binPath = $dir.'/'.$binName;
file_put_contents($binPath, $script);
chmod($binPath, 0755);
}
@@ -71,7 +78,7 @@ class NhnKcpApiServiceTest extends PluginTestCase
private function removeStubbedCliDirectory(string $dir): void
{
foreach (glob($dir . '/*') ?: [] as $path) {
foreach (glob($dir.'/*') ?: [] as $path) {
@unlink($path);
}
@@ -197,7 +204,7 @@ class NhnKcpApiServiceTest extends PluginTestCase
$service->getTransaction('TNO_001', 'ORD-001');
$expectedAuthValue = 'Basic ' . base64_encode(self::TEST_SITE_CD . ':' . self::TEST_SITE_KEY);
$expectedAuthValue = 'Basic '.base64_encode(self::TEST_SITE_CD.':'.self::TEST_SITE_KEY);
Http::assertSent(function ($request) use ($expectedAuthValue) {
return $request->header('Authorization')[0] === $expectedAuthValue;
@@ -221,7 +228,7 @@ class NhnKcpApiServiceTest extends PluginTestCase
public function test_cancel_payment_calls_cli_with_full_cancel_params(): void
{
$stub = $this->makeServiceWithStubbedCli(
'res_cd=0000' . chr(31) . 'res_msg=취소완료' . chr(31) . 'tno=KCP_TNO_CANCEL_001'
'res_cd=0000'.chr(31).'res_msg=취소완료'.chr(31).'tno=KCP_TNO_CANCEL_001'
);
/** @var NhnKcpApiService $service */
$service = $stub['service'];
@@ -238,10 +245,10 @@ class NhnKcpApiServiceTest extends PluginTestCase
$args = $this->capturedCliArgs($stub['log']);
$this->assertStringContainsString('tx_cd=00200000', $args);
$this->assertStringContainsString('ordr_idxx=' . $ordrIdxx, $args);
$this->assertStringContainsString('modx_data=mod_data=tno=' . $tno, $args);
$this->assertStringContainsString('ordr_idxx='.$ordrIdxx, $args);
$this->assertStringContainsString('modx_data=mod_data=tno='.$tno, $args);
$this->assertStringContainsString('mod_type=STSC', $args);
$this->assertStringContainsString('mod_desc=' . $cancelMsg, $args);
$this->assertStringContainsString('mod_desc='.$cancelMsg, $args);
$this->assertStringNotContainsString('mod_mny=', $args);
$this->assertStringNotContainsString('rem_mny=', $args);
} finally {
@@ -251,7 +258,7 @@ class NhnKcpApiServiceTest extends PluginTestCase
public function test_cancel_payment_calls_cli_with_partial_cancel_params(): void
{
$stub = $this->makeServiceWithStubbedCli('res_cd=0000' . chr(31) . 'res_msg=부분취소완료');
$stub = $this->makeServiceWithStubbedCli('res_cd=0000'.chr(31).'res_msg=부분취소완료');
/** @var NhnKcpApiService $service */
$service = $stub['service'];
@@ -270,7 +277,7 @@ class NhnKcpApiServiceTest extends PluginTestCase
public function test_register_escrow_delivery_uses_test_site_cd_when_escrow_test_site_cd_is_empty(): void
{
$stub = $this->makeServiceWithStubbedCli(
'res_cd=0000' . chr(31) . 'res_msg=배송등록완료',
'res_cd=0000'.chr(31).'res_msg=배송등록완료',
['escrow_test_site_cd' => '']
);
/** @var NhnKcpApiService $service */
@@ -280,7 +287,7 @@ class NhnKcpApiServiceTest extends PluginTestCase
$service->registerEscrowDelivery('TNO_ESCROW', 'ORD-ESCROW', '1234567890', '04');
$args = $this->capturedCliArgs($stub['log']);
$this->assertStringContainsString('site_cd=' . self::TEST_SITE_CD, $args);
$this->assertStringContainsString('site_cd='.self::TEST_SITE_CD, $args);
} finally {
$this->removeStubbedCliDirectory($stub['dir']);
}
@@ -288,7 +295,7 @@ class NhnKcpApiServiceTest extends PluginTestCase
public function test_cancel_payment_throws_on_non_0000_res_cd(): void
{
$stub = $this->makeServiceWithStubbedCli('res_cd=9999' . chr(31) . 'res_msg=취소 실패');
$stub = $this->makeServiceWithStubbedCli('res_cd=9999'.chr(31).'res_msg=취소 실패');
/** @var NhnKcpApiService $service */
$service = $stub['service'];
@@ -384,7 +391,7 @@ class NhnKcpApiServiceTest extends PluginTestCase
$tmpFile = tempnam(sys_get_temp_dir(), 'kcp_cli_test_');
$this->assertNotFalse($tmpFile);
file_put_contents($tmpFile, '#!/bin/sh' . PHP_EOL . 'exit 0' . PHP_EOL);
file_put_contents($tmpFile, '#!/bin/sh'.PHP_EOL.'exit 0'.PHP_EOL);
chmod($tmpFile, 0664);
$this->assertFalse(is_executable($tmpFile), 'precondition: 0664 는 실행 권한 없음');
@@ -411,7 +418,7 @@ class NhnKcpApiServiceTest extends PluginTestCase
$tmpFile = tempnam(sys_get_temp_dir(), 'kcp_cli_test_');
$this->assertNotFalse($tmpFile);
file_put_contents($tmpFile, '#!/bin/sh' . PHP_EOL . 'exit 0' . PHP_EOL);
file_put_contents($tmpFile, '#!/bin/sh'.PHP_EOL.'exit 0'.PHP_EOL);
chmod($tmpFile, 0755);
try {
@@ -435,10 +442,10 @@ class NhnKcpApiServiceTest extends PluginTestCase
$method = (new \ReflectionClass($service))->getMethod('ensureCliExecutable');
$method->setAccessible(true);
$this->expectException(\Plugins\Sirsoft\PayNhnkcp\Exceptions\NhnKcpApiException::class);
$this->expectException(NhnKcpApiException::class);
$this->expectExceptionMessageMatches('/CLI 바이너리/');
$method->invoke($service, '/tmp/nonexistent_kcp_cli_' . uniqid());
$method->invoke($service, '/tmp/nonexistent_kcp_cli_'.uniqid());
}
/**
@@ -14,7 +14,9 @@ export default defineConfig({
entryFileNames: 'js/plugin.iife.js',
},
},
sourcemap: true,
// 배포용 빌드(--production)는 G7_BUILD_SOURCEMAP=0 을 주입해 소스맵을 생성하지 않는다.
// 미설정(로컬 npm run build)이면 생성 — 개발 디버깅 경험을 유지한다.
sourcemap: !['0', 'false'].includes(process.env.G7_BUILD_SOURCEMAP ?? ''),
minify: 'esbuild',
target: 'es2020',
chunkSizeWarningLimit: 500,
@@ -6,6 +6,10 @@
## [Unreleased]
### Fixed
- 플러그인이 활성화되어 있으면 홈, 게시판, 로그인 등 결제와 무관한 모든 페이지에서 주문서 조회 요청이 한 번씩 불필요하게 발생하던 문제를 수정했습니다. 장바구니가 없는 비회원에게는 이 요청이 매번 실패해 브라우저 오류로 기록됐습니다. 이제 결제 페이지에서만 요청합니다.
### Security
- SignData 발급 요청에 주문번호+IP 단위 rate limit과 구매자 이메일/전화번호 대조를 추가해 공개 서명 엔드포인트의 주문번호/금액 프로빙 가능성을 줄였습니다.
@@ -0,0 +1,10 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"identifier": "sirsoft-pay_nicepayments",
"version": "1.0.0-beta.4",
"components": {
"basic": [],
"composite": [],
"layout": []
}
}
@@ -1,2 +1 @@
(function(){"use strict";function Oe(e){return/^https?:\/\//i.test(e)||e.startsWith("/api/")?e:e.startsWith("/plugins/")?`/api${e}`:e.startsWith("plugins/")?`/api/${e}`:e}function De(e){return e.trim().slice(0,160)}async function Be(e,t="nicepay-window-closed"){if(!e.closeReportUrl)return;const n={oid:e.oid,price:Number(e.price),buyer_email:e.buyer_email??"",buyer_phone:e.buyer_phone??"",payment_method:e.payment_method??"",reason:De(t)||"nicepay-window-closed"};try{const a=window.G7Core?.api;if(typeof a?.post=="function"){await a.post(e.closeReportUrl,n);return}await fetch(Oe(e.closeReportUrl),{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(n),keepalive:!0})}catch(a){console.warn("[sirsoft-pay_nicepayments] failed to report payment window close",a)}}function Ge(e){return new Promise((t,n)=>{if(document.querySelector(`script[src="${e}"]`)){t();return}const a=document.createElement("script");a.src=e,a.onload=()=>t(),a.onerror=()=>n(new Error(`Failed to load script: ${e}`)),document.head.appendChild(a)})}function je(e,t){const n=document.createElement("form");n.id="nicepayForm",n.method="post",n.action=e,n.acceptCharset="utf-8";for(const[a,o]of Object.entries(t)){const r=document.createElement("input");r.type="hidden",r.name=a,r.value=o,n.appendChild(r)}return document.body.appendChild(n),n}const qe="/plugins/sirsoft-pay_nicepayments/payment/callback",Fe="https://web.nicepay.co.kr/v3/v3Payment.jsp";function Ye(){if(typeof navigator>"u")return!1;const e=navigator;if(e.userAgentData?.mobile!==void 0)return e.userAgentData.mobile;const t=(navigator.userAgent||"").toLowerCase();return!!(/android|iphone|ipod|windows phone|iemobile|blackberry|opera mini|mobile safari/.test(t)||(navigator.maxTouchPoints??0)>1&&!t.includes("windows")&&!t.includes("macintosh"))}async function J(e,t){const{pgPaymentData:n,paymentMethod:a}=e.params??{},o=window.__templateApp?.globalState?._local,r=a??o?.paymentMethod??"card";if(!n){console.error("[sirsoft-pay_nicepayments] pgPaymentData is required");return}const i=window.G7Core;try{const s=await i.api.get("/modules/sirsoft-ecommerce/payments/client-config/nicepayments");if(!s.data){console.error("[sirsoft-pay_nicepayments] Failed to fetch client config",s);return}const l=s.data,u=Ye();if(!u&&(await Ge(l.sdk_url),typeof window.goPay!="function")){i?.toast?.error?.("나이스페이먼츠 SDK를 불러올 수 없습니다. 잠시 후 다시 시도해주세요."),i?.state?.setLocal?.({isSubmittingOrder:!1,paymentMethod:r});return}const b=window.location.origin+l.sign_data_url,m=typeof localStorage<"u"?localStorage.getItem("auth_token"):null,p={"Content-Type":"application/json","X-Requested-With":"XMLHttpRequest",Accept:"application/json"};m&&(p.Authorization=`Bearer ${m}`);const P=await fetch(b,{method:"POST",credentials:"include",headers:p,body:JSON.stringify({amt:n.amount,moid:n.order_number,buyer_email:n.customer_email??"",buyer_phone:n.customer_phone??""})});if(!P.ok)throw new Error("SignData 생성에 실패했습니다.");const g=await P.json(),I=window.location.origin+qe,f=typeof r=="string"&&r.startsWith("nicepay_"),C=f?"CARD":{card:"CARD",vbank:"VBANK",bank:"BANK",phone:"CELLPHONE"}[r??"card"]??"CARD",d={PayMethod:C,GoodsName:n.order_name,Amt:String(n.amount),MID:g.mid,Moid:n.order_number,BuyerName:n.customer_name??"",BuyerEmail:n.customer_email??"",BuyerTel:n.customer_phone??"",ReturnURL:I,EdiDate:g.ediDate,SignData:g.signData,CharSet:"utf-8",GoodsCl:"1",DirectShowOpt:"",DirectEasyPay:"",NicepayReserved:"",EasyPayMethod:"",MallReserved:"",MallReserved1:""};C==="CELLPHONE"&&(d.GoodsCl=n.goods_cl??"1");const c=l.useEscrow&&!f;if(d.TransType=c?"1":"0",f)switch(d.DirectShowOpt="CARD",d.MallReserved=`nicepay_easy_pay_method=${encodeURIComponent(r)}`,d.MallReserved1=r,r){case"nicepay_naverpay":d.DirectEasyPay="E020",d.EasyPayMethod="E020=CARD";break;case"nicepay_kakaopay":d.NicepayReserved="DirectKakao=Y";break;case"nicepay_samsungpay":d.DirectEasyPay="E021";break;case"nicepay_applepay":d.DirectEasyPay="E025";break;case"nicepay_payco":d.NicepayReserved="DirectPayco=Y";break;case"nicepay_skpay":d.NicepayReserved="DirectPay11=Y";break;case"nicepay_ssgpay":d.DirectEasyPay="E007";break;case"nicepay_lpay":d.DirectEasyPay="E018";break}try{const w=(await i.api.get(`/modules/sirsoft-ecommerce/user/orders/${n.order_number}`))?.data;if(w){const T=Number(w.total_tax_amount??0),E=Number(w.total_vat_amount??0),$e=Number(w.total_tax_free_amount??0);(T>0||E>0||$e>0)&&(d.TaxAmt=String(T),d.VatAmt=String(E),d.TaxFreeAmt=String($e))}}catch{}const y=je(I,d);if(u){y.action=Fe,y.acceptCharset="euc-kr";const S=y.querySelector('input[name="CharSet"]');S&&(S.value="euc-kr"),y.submit();return}window.nicepaySubmit=()=>{y.submit()};let k=!1;const z=new Set(document.body.children),Re=(S,w)=>{if(k)return;k=!0;const T=[S,w].filter(Boolean).join(": ")||"nicepay-window-closed";Be({closeReportUrl:l.close_report_url,oid:n.order_number,price:Number(n.amount),buyer_email:n.customer_email??"Line truncated
//# sourceMappingURL=plugin.iife.js.map
(function(){"use strict";function $e(e){return/^https?:\/\//i.test(e)||e.startsWith("/api/")?e:e.startsWith("/plugins/")?`/api${e}`:e.startsWith("plugins/")?`/api/${e}`:e}function De(e){return e.trim().slice(0,160)}async function Be(e,t="nicepay-window-closed"){if(!e.closeReportUrl)return;const n={oid:e.oid,price:Number(e.price),buyer_email:e.buyer_email??"",buyer_phone:e.buyer_phone??"",payment_method:e.payment_method??"",reason:De(t)||"nicepay-window-closed"};try{const a=window.G7Core?.api;if(typeof a?.post=="function"){await a.post(e.closeReportUrl,n);return}await fetch($e(e.closeReportUrl),{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(n),keepalive:!0})}catch(a){console.warn("[sirsoft-pay_nicepayments] failed to report payment window close",a)}}function Ge(e){return new Promise((t,n)=>{if(document.querySelector(`script[src="${e}"]`)){t();return}const a=document.createElement("script");a.src=e,a.onload=()=>t(),a.onerror=()=>n(new Error(`Failed to load script: ${e}`)),document.head.appendChild(a)})}function je(e,t){const n=document.createElement("form");n.id="nicepayForm",n.method="post",n.action=e,n.acceptCharset="utf-8";for(const[a,o]of Object.entries(t)){const r=document.createElement("input");r.type="hidden",r.name=a,r.value=o,n.appendChild(r)}return document.body.appendChild(n),n}const qe="/plugins/sirsoft-pay_nicepayments/payment/callback",Fe="https://web.nicepay.co.kr/v3/v3Payment.jsp";function He(){if(typeof navigator>"u")return!1;const e=navigator;if(e.userAgentData?.mobile!==void 0)return e.userAgentData.mobile;const t=(navigator.userAgent||"").toLowerCase();return!!(/android|iphone|ipod|windows phone|iemobile|blackberry|opera mini|mobile safari/.test(t)||(navigator.maxTouchPoints??0)>1&&!t.includes("windows")&&!t.includes("macintosh"))}async function J(e,t){const{pgPaymentData:n,paymentMethod:a}=e.params??{},o=window.__templateApp?.globalState?._local,r=a??o?.paymentMethod??"card";if(!n){console.error("[sirsoft-pay_nicepayments] pgPaymentData is required");return}const i=window.G7Core;try{const s=await i.api.get("/modules/sirsoft-ecommerce/payments/client-config/nicepayments");if(!s.data){console.error("[sirsoft-pay_nicepayments] Failed to fetch client config",s);return}const l=s.data,u=He();if(!u&&(await Ge(l.sdk_url),typeof window.goPay!="function")){i?.toast?.error?.("나이스페이먼츠 SDK를 불러올 수 없습니다. 잠시 후 다시 시도해주세요."),i?.state?.setLocal?.({isSubmittingOrder:!1,paymentMethod:r});return}const b=window.location.origin+l.sign_data_url,m=typeof localStorage<"u"?localStorage.getItem("auth_token"):null,p={"Content-Type":"application/json","X-Requested-With":"XMLHttpRequest",Accept:"application/json"};m&&(p.Authorization=`Bearer ${m}`);const P=await fetch(b,{method:"POST",credentials:"include",headers:p,body:JSON.stringify({amt:n.amount,moid:n.order_number,buyer_email:n.customer_email??"",buyer_phone:n.customer_phone??""})});if(!P.ok)throw new Error("SignData 생성에 실패했습니다.");const g=await P.json(),I=window.location.origin+qe,f=typeof r=="string"&&r.startsWith("nicepay_"),N=f?"CARD":{card:"CARD",vbank:"VBANK",bank:"BANK",phone:"CELLPHONE"}[r??"card"]??"CARD",d={PayMethod:N,GoodsName:n.order_name,Amt:String(n.amount),MID:g.mid,Moid:n.order_number,BuyerName:n.customer_name??"",BuyerEmail:n.customer_email??"",BuyerTel:n.customer_phone??"",ReturnURL:I,EdiDate:g.ediDate,SignData:g.signData,CharSet:"utf-8",GoodsCl:"1",DirectShowOpt:"",DirectEasyPay:"",NicepayReserved:"",EasyPayMethod:"",MallReserved:"",MallReserved1:""};N==="CELLPHONE"&&(d.GoodsCl=n.goods_cl??"1");const c=l.useEscrow&&!f;if(d.TransType=c?"1":"0",f)switch(d.DirectShowOpt="CARD",d.MallReserved=`nicepay_easy_pay_method=${encodeURIComponent(r)}`,d.MallReserved1=r,r){case"nicepay_naverpay":d.DirectEasyPay="E020",d.EasyPayMethod="E020=CARD";break;case"nicepay_kakaopay":d.NicepayReserved="DirectKakao=Y";break;case"nicepay_samsungpay":d.DirectEasyPay="E021";break;case"nicepay_applepay":d.DirectEasyPay="E025";break;case"nicepay_payco":d.NicepayReserved="DirectPayco=Y";break;case"nicepay_skpay":d.NicepayReserved="DirectPay11=Y";break;case"nicepay_ssgpay":d.DirectEasyPay="E007";break;case"nicepay_lpay":d.DirectEasyPay="E018";break}try{const w=(await i.api.get(`/modules/sirsoft-ecommerce/user/orders/${n.order_number}`))?.data;if(w){const T=Number(w.total_tax_amount??0),E=Number(w.total_vat_amount??0),Oe=Number(w.total_tax_free_amount??0);(T>0||E>0||Oe>0)&&(d.TaxAmt=String(T),d.VatAmt=String(E),d.TaxFreeAmt=String(Oe))}}catch{}const y=je(I,d);if(u){y.action=Fe,y.acceptCharset="euc-kr";const S=y.querySelector('input[name="CharSet"]');S&&(S.value="euc-kr"),y.submit();return}window.nicepaySubmit=()=>{y.submit()};let k=!1;const z=new Set(document.body.children),Re=(S,w)=>{if(k)return;k=!0;const T=[S,w].filter(Boolean).join(": ")||"nicepay-window-closed";Be({closeReportUrl:l.close_report_url,oid:n.order_number,price:Number(n.amount),buyer_email:n.customer_email??"Line truncated
@@ -1 +0,0 @@
{"version":3,"file":"plugin.iife.js","sources":["../../resources/js/paymentCloseReport.ts","../../resources/js/handlers/requestPayment.ts","../../resources/js/handlers/setPaymentMethod.ts","../../resources/js/handlers/index.ts","../../resources/js/orderResponseInterceptor.ts","../../resources/js/checkoutEasyPayInjector.ts","../../resources/js/orderCompleteReceiptInjector.ts","../../resources/js/mypageOrderShowInjector.ts","../../resources/js/adminPaymentMethodBrandInjector.ts","../../resources/js/adminOrderPaymentDisplayInjector.ts","../../resources/js/index.ts"],"sourcesContent":["export interface PaymentCloseReportContext {\n closeReportUrl?: string;\n oid: string;\n price: number;\n buyer_email?: string;\n buyer_phone?: string;\n payment_method?: string;\n}\n\nfunction resolveApiUrl(url: string): string {\n if (/^https?:\\/\\//i.test(url) || url.startsWith('/api/')) {\n return url;\n }\n\n if (url.startsWith('/plugins/')) {\n return `/api${url}`;\n }\n\n if (url.startsWith('plugins/')) {\n return `/api/${url}`;\n }\n\n return url;\n}\n\nfunction trimReason(reason: string): string {\n return reason.trim().slice(0, 160);\n}\n\nexport async function reportPaymentWindowClosed(\n context: PaymentCloseReportContext,\n reason = 'nicepay-window-closed',\n): Promise<void> {\n if (!context.closeReportUrl) {\n return;\n }\n\n const payload = {\n oid: context.oid,\n price: Number(context.price),\n buyer_email: context.buyer_email ?? '',\n buyer_phone: context.buyer_phone ?? '',\n payment_method: context.payment_method ?? '',\n reason: trimReason(reason) || 'nicepay-window-closed',\n };\n\n try {\n const apiClient = ((window as any).G7Core)?.api;\n if (typeof apiClient?.post === 'function') {\n await apiClient.post(context.closeReportUrl, payload);\n return;\n }\n\n await fetch(resolveApiUrl(context.closeReportUrl), {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(payload),\n keepalive: true,\n });\n } catch (error) {\n console.warn('[sirsoft-pay_nicepayments] failed to report payment window close', error);\n }\n}\n","import { reportPaymentWindowClosed } from '../paymentCloseReport';\n\ninterface G7ApiResponse {\n data?: unknown;\n}\n\ninterface G7CoreApi {\n get: (url: string) => Promise<G7ApiResponse>;\n post?: (url: string, data?: Record<string, unknown>) => Promise<G7ApiResponse>;\n}\n\ninterface G7CoreToast {\n error: (message: string) => void;\n}\n\ninterface G7CoreStateApi {\n setLocal: (state: Record<string, unknown>) => void;\n}\n\ninterface G7Core {\n api: G7CoreApi;\n toast?: G7CoreToast;\n state?: G7CoreStateApi;\n}\n\ninterface TemplateLocalState {\n paymentMethod?: string;\n}\n\ninterface TemplateApp {\n globalState?: {\n _local?: TemplateLocalState;\n };\n}\n\ninterface PgPaymentData {\n order_number: string;\n order_name: string;\n amount: number;\n currency?: string;\n customer_name?: string;\n customer_email?: string;\n customer_phone?: string;\n goods_cl?: string; // 휴대폰결제 상품 유형: '0'=디지털컨텐츠, '1'=실물\n}\n\ninterface RequestPaymentParams {\n pgPaymentData: PgPaymentData;\n paymentMethod?: string;\n}\n\ninterface ClientConfig {\n mid: string;\n sdk_url: string;\n sign_data_url: string;\n close_report_url?: string;\n useEscrow?: boolean;\n}\n\ninterface SignDataResponse {\n ediDate: string;\n signData: string;\n mid: string;\n}\n\ninterface PaymentAction {\n params?: RequestPaymentParams;\n}\n\ndeclare global {\n interface Window {\n nicepaySubmit: () => void;\n nicepayClose: (resultCode: string, resultMsg: string) => void;\n goPay: (form: HTMLFormElement) => void;\n G7Core: G7Core;\n __templateApp?: TemplateApp;\n }\n}\n\nfunction loadScript(src: string): Promise<void> {\n return new Promise((resolve, reject) => {\n if (document.querySelector(`script[src=\"${src}\"]`)) {\n resolve();\n return;\n }\n\n const script = document.createElement('script');\n script.src = src;\n script.onload = () => resolve();\n script.onerror = () => reject(new Error(`Failed to load script: ${src}`));\n document.head.appendChild(script);\n });\n}\n\nfunction createPaymentForm(action: string, fields: Record<string, string>): HTMLFormElement {\n const form = document.createElement('form');\n form.id = 'nicepayForm';\n form.method = 'post';\n form.action = action;\n // CharSet 필드와 일치 — 모바일은 form.acceptCharset 으로 charset 결정\n form.acceptCharset = 'utf-8';\n\n for (const [name, value] of Object.entries(fields)) {\n const input = document.creatLine truncated
@@ -14,6 +14,34 @@ function resetNicepayWindowFlags(): void {
delete w.__sirsoftPgOriginalFetch;
delete w.__sirsoftNicepayPaymentInProgress;
delete w.__sirsoftNicepayCheckoutCache;
delete w.__sirsoftNicepayAxiosCheckoutInterceptorInstalled;
}
/** 현재 경로를 지정한다 (jsdom 은 history API 로만 pathname 변경 가능) */
function setPathname(pathname: string): void {
window.history.replaceState({}, '', pathname);
}
/**
* G7Core.api 스파이를 설치하고 get 스파이를 반환한다.
*
* 캐시 프리페치는 tryInstallAxiosCheckoutInterceptor 안에 있고, 이 함수는
* `G7Core.api.client`(Axios)가 준비될 때까지 재시도하다가 준비되면 프리페치한다.
* 따라서 client(=interceptors 를 가진 객체)를 함께 제공해야 실제 경로를 탄다.
*/
function installCoreApiSpy(): ReturnType<typeof vi.fn> {
const getSpy = vi.fn().mockResolvedValue({});
const axiosClient = {
interceptors: {
request: { use: vi.fn() },
response: { use: vi.fn() },
},
};
(window as unknown as Record<string, unknown>).G7Core = {
api: { get: getSpy, client: axiosClient },
};
return getSpy;
}
function buildOrderResponse(order: Record<string, unknown>): Response {
@@ -45,9 +73,55 @@ describe('installOrderResponseInterceptor', () => {
afterEach(() => {
window.fetch = originalFetch;
resetNicepayWindowFlags();
delete (window as unknown as Record<string, unknown>).G7Core;
setPathname('/');
vi.restoreAllMocks();
});
describe('체크아웃 캐시 프리페치 라우트 가드', () => {
// 플러그인 번들은 모든 페이지에 로드된다. 캐시 프리페치는 체크아웃 페이지의
// navigate-to-self 재초기화를 위한 것이므로, 다른 라우트에서 발화하면
// 비회원에게 무의미한 422(cart_key_required) 왕복만 발생시킨다.
it.each([
['홈', '/'],
['게시판', '/board'],
['로그인', '/login'],
['상품 상세', '/shop/products/1'],
['장바구니', '/shop/cart'],
])('%s 에서는 체크아웃을 프리페치하지 않는다', async (_label, pathname) => {
setPathname(pathname);
const getSpy = installCoreApiSpy();
installOrderResponseInterceptor();
await vi.waitFor(() => expect(true).toBe(true));
expect(getSpy).not.toHaveBeenCalled();
});
it('체크아웃 페이지에서는 캐시 프리페치가 동작한다', async () => {
setPathname('/shop/checkout');
const getSpy = installCoreApiSpy();
installOrderResponseInterceptor();
await vi.waitFor(() => {
expect(getSpy).toHaveBeenCalledWith('/modules/sirsoft-ecommerce/checkout');
});
});
it('이미 캐시가 있으면 체크아웃 페이지에서도 재요청하지 않는다', async () => {
setPathname('/shop/checkout');
(window as unknown as Record<string, unknown>).__sirsoftNicepayCheckoutCache =
JSON.stringify({ data: {} });
const getSpy = installCoreApiSpy();
installOrderResponseInterceptor();
await vi.waitFor(() => expect(true).toBe(true));
expect(getSpy).not.toHaveBeenCalled();
});
});
it('간편결제 fallback pg_payment_data 금액은 total_due_amount를 우선 사용', async () => {
fetchSpy.mockResolvedValue(buildOrderResponse({
order_number: 'ORD-NICE-001',
@@ -23,6 +23,9 @@ const CHECKOUT_ENDPOINT_PATH = '/api/modules/sirsoft-ecommerce/checkout';
const PAYMENT_IN_PROGRESS_FLAG = '__sirsoftNicepayPaymentInProgress';
const CHECKOUT_CACHE_KEY = '__sirsoftNicepayCheckoutCache';
/** 체크아웃 페이지 경로 (캐시 프리페치를 이 경로에서만 수행) */
const CHECKOUT_ROUTE_RE = /^\/shop\/checkout\/?$/;
const logger = {
info: (...args: unknown[]) => console.info(`[${PLUGIN_IDENTIFIER}]`, ...args),
warn: (...args: unknown[]) => console.warn(`[${PLUGIN_IDENTIFIER}]`, ...args),
@@ -45,6 +48,11 @@ function isCheckoutEndpoint(url: string, method: string): boolean {
return path === CHECKOUT_ENDPOINT_PATH || path.endsWith(CHECKOUT_ENDPOINT_PATH);
}
/** 현재 브라우저 경로가 체크아웃 페이지인지 판정한다. */
function isCheckoutRoute(): boolean {
return CHECKOUT_ROUTE_RE.test(window.location.pathname);
}
function extractPaymentMethodFromBody(body: unknown): string | undefined {
if (!body) return undefined;
let obj: Record<string, unknown> | null = null;
@@ -311,7 +319,12 @@ function tryInstallAxiosCheckoutInterceptor(w: Record<string, unknown>, retries:
logger.info('axios checkout interceptor installed');
// 인터셉터 설치 직후 GET checkout 즉시 캐싱 — navigate-to-self 재초기화 대비
if (!w[CHECKOUT_CACHE_KEY]) {
//
// 체크아웃 페이지에서만 수행한다. 플러그인 번들은 모든 페이지에 로드되므로 경로를
// 가리지 않으면 홈/게시판/로그인 등 어디서나 이 요청이 나가고, 장바구니가 없는
// 비회원은 매 페이지 422(cart_key_required)를 받는다. 캐시는 체크아웃 페이지의
// navigate-to-self 재초기화 대비용이므로 다른 경로에서는 만들 이유가 없다.
if (isCheckoutRoute() && !w[CHECKOUT_CACHE_KEY]) {
void (async () => {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -19,6 +19,9 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
private const TEST_MODE_NOTICE_ROW_ID = 'nicepay_test_mode_order_settings_notice';
/** 주문설정 탭이 활성일 때만 fetch (탭별 지연 로딩) */
private const ORDER_SETTINGS_TAB_CONDITION = "{{(query.tab || _global.activeEcommerceSettingsTab || 'basic_info') === 'order_settings'}}";
private const TEST_MODE_CONDITION = 'nicepay_test_mode_status.data?.is_test_mode === true';
private const CORE_NO_PG_METHODS = ['point', 'deposit', 'free', 'dbank'];
@@ -35,7 +38,9 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
];
/**
* @return array<string, array<string, mixed>>
* 이 리스너가 구독하는 훅 정의를 반환합니다.
*
* @return array<string, array<string, mixed>> 훅 이름 => 구독 설정
*/
public static function getSubscribedHooks(): array
{
@@ -49,14 +54,16 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
}
/**
* @param mixed ...$args
* @param mixed ...$args
*/
public function handle(...$args): void {}
/**
* @param array<string, mixed> $layout
* @param int $templateId
* @return array<string, mixed>
* 이커머스 결제수단 설정 레이아웃에 나이스페이먼츠 간편결제를 PG 선택 불필요 항목으로 반영합니다.
*
* @param array<string, mixed> $layout 대상 레이아웃 정의
* @param int $templateId 레이아웃이 속한 템플릿 ID
* @return array<string, mixed> 보정된 레이아웃 정의
*/
public function markEasyPayMethodsAsPgNotRequired(array $layout, int $templateId): array
{
@@ -70,14 +77,17 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
}
/**
* @param array<string, mixed> $node
* @return array<string, mixed>
* 레이아웃 노드를 재귀 순회하며 PG 불필요 결제수단 표현식에 나이스페이먼츠 간편결제를 추가합니다.
*
* @param array<string, mixed> $node 순회 대상 노드
* @return array<string, mixed> 치환이 반영된 노드
*/
private function replaceNoPgMethodExpressions(array $node): array
{
foreach ($node as $key => $value) {
if (is_array($value)) {
$node[$key] = $this->replaceNoPgMethodExpressions($value);
continue;
}
@@ -89,6 +99,12 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
return $node;
}
/**
* 코어 PG 불필요 결제수단 배열 표현식을 찾아 나이스페이먼츠 간편결제 수단을 덧붙입니다.
*
* @param string $expression 대상 표현식
* @return string 나이스페이먼츠 수단이 추가된 표현식 (대상이 아니면 원본 그대로)
*/
private function addNicepayMethodsToNoPgArray(string $expression): string
{
return preg_replace_callback(
@@ -109,15 +125,17 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
}
}
return "['" . implode("','", $ids) . "'].includes(\$method.id)";
return "['".implode("','", $ids)."'].includes(\$method.id)";
},
$expression
) ?? $expression;
}
/**
* @param array<string, mixed> $layout
* @return array<string, mixed>
* 테스트 모드 경고에 필요한 데이터 소스와 안내 노드를 레이아웃에 보장합니다.
*
* @param array<string, mixed> $layout 대상 레이아웃 정의
* @return array<string, mixed> 경고가 보장된 레이아웃 정의
*/
private function ensureTestModeWarning(array $layout): array
{
@@ -127,8 +145,10 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
}
/**
* @param array<string, mixed> $layout
* @return array<string, mixed>
* 테스트 모드 상태 조회 데이터 소스가 없으면 레이아웃에 추가합니다.
*
* @param array<string, mixed> $layout 대상 레이아웃 정의
* @return array<string, mixed> 데이터 소스가 보장된 레이아웃 정의
*/
private function ensureTestModeDataSource(array $layout): array
{
@@ -148,6 +168,7 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
'type' => 'api',
'endpoint' => '/api/plugins/sirsoft-pay_nicepayments/admin/settings/test-mode-status',
'method' => 'GET',
'if' => self::ORDER_SETTINGS_TAB_CONDITION,
'auto_fetch' => true,
'auth_required' => true,
];
@@ -158,8 +179,10 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
}
/**
* @param array<string, mixed> $node
* @return array<string, mixed>
* 주문설정 탭을 찾아 테스트 모드 안내 노드를 삽입합니다.
*
* @param array<string, mixed> $node 순회 대상 노드
* @return array<string, mixed> 안내 노드가 삽입된 노드
*/
private function insertTestModeNotice(array $node): array
{
@@ -191,7 +214,11 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
}
/**
* @param array<int, mixed> $children
* 자식 노드 목록에서 지정한 id 를 가진 노드의 인덱스를 찾습니다.
*
* @param array<int, mixed> $children 자식 노드 목록
* @param string $id 찾을 노드 id
* @return int|null 찾은 인덱스 (없으면 null)
*/
private function findChildIndexById(array $children, string $id): ?int
{
@@ -205,17 +232,27 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
}
/**
* @param array<int, mixed> $children
* 자식 노드 목록에 지정한 id 를 가진 노드가 있는지 확인합니다.
*
* @param array<int, mixed> $children 자식 노드 목록
* @param string $id 확인할 노드 id
* @return bool 존재 여부
*/
private function hasChildWithId(array $children, string $id): bool
{
return $this->findChildIndexById($children, $id) !== null;
}
/**
* 기존 표시 조건에 테스트 모드 조건을 OR 로 병합합니다.
*
* @param string $condition 기존 표시 조건식
* @return string 테스트 모드 조건이 병합된 조건식
*/
private function mergeTestModeCondition(string $condition): string
{
if (str_contains($condition, self::TEST_MODE_CONDITION)) {
return $condition !== '' ? $condition : '{{' . self::TEST_MODE_CONDITION . '}}';
return $condition !== '' ? $condition : '{{'.self::TEST_MODE_CONDITION.'}}';
}
$inner = trim($condition);
@@ -224,15 +261,17 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
}
if ($inner === '') {
return '{{' . self::TEST_MODE_CONDITION . '}}';
return '{{'.self::TEST_MODE_CONDITION.'}}';
}
return '{{' . $inner . ' || ' . self::TEST_MODE_CONDITION . '}}';
return '{{'.$inner.' || '.self::TEST_MODE_CONDITION.'}}';
}
/**
* @param array<string, mixed> $notice
* @return array<string, mixed>
* 안내 컨테이너에 나이스페이먼츠 테스트 모드 안내 행을 중복 없이 추가합니다.
*
* @param array<string, mixed> $notice 안내 컨테이너 노드
* @return array<string, mixed> 안내 행이 추가된 컨테이너 노드
*/
private function appendTestModeNoticeRow(array $notice): array
{
@@ -248,7 +287,9 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
}
/**
* @return array<string, mixed>
* 테스트 모드 안내를 감싸는 컨테이너 노드 정의를 반환합니다.
*
* @return array<string, mixed> 컨테이너 노드 정의
*/
private function testModeNoticeContainerNode(): array
{
@@ -256,7 +297,7 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
'id' => self::TEST_MODE_NOTICE_ID,
'type' => 'basic',
'name' => 'Div',
'if' => '{{' . self::TEST_MODE_CONDITION . '}}',
'if' => '{{'.self::TEST_MODE_CONDITION.'}}',
'props' => [
'className' => 'mb-4 space-y-3 rounded-lg border border-orange-200 bg-orange-50 p-4 dark:border-orange-700 dark:bg-orange-900/20',
],
@@ -267,7 +308,9 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
}
/**
* @return array<string, mixed>
* 나이스페이먼츠 테스트 모드 안내 문구와 설정 이동 버튼으로 구성된 행 노드 정의를 반환합니다.
*
* @return array<string, mixed> 안내 행 노드 정의
*/
private function testModeNoticeRowNode(): array
{
@@ -275,7 +318,7 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
'id' => self::TEST_MODE_NOTICE_ROW_ID,
'type' => 'basic',
'name' => 'Div',
'if' => '{{' . self::TEST_MODE_CONDITION . '}}',
'if' => '{{'.self::TEST_MODE_CONDITION.'}}',
'props' => [
'className' => 'flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between',
],
@@ -322,11 +365,11 @@ class AdjustEcommercePaymentMethodsLayoutListener implements HookListenerInterfa
],
],
'children' => [
[
'type' => 'basic',
'name' => 'Span',
'text' => '$t:sirsoft-pay_nicepayments.admin.test_mode_settings_warning_action',
],
[
'type' => 'basic',
'name' => 'Span',
'text' => '$t:sirsoft-pay_nicepayments.admin.test_mode_settings_warning_action',
],
],
],
],
@@ -4,9 +4,10 @@ namespace Plugins\Sirsoft\PayNicepayments\Tests\Feature\Controllers;
use App\Extension\HookManager;
use App\Models\User;
use Illuminate\Support\Facades\RateLimiter;
use App\Services\PluginSettingsService;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\RateLimiter;
use Mockery;
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderAddressFactory;
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderFactory;
@@ -22,6 +23,21 @@ class PaymentCallbackControllerTest extends PluginTestCase
{
private const TEST_MID = 'nicepay00m';
/**
* 애플리케이션 로그를 spy 로 감시하되 `activity` 채널은 실제 로거로 유지합니다.
*
* `Log::spy()` 단독 사용 시 `Log::channel('activity')` 가 null 을 반환하고,
* 결제 완료 훅이 태우는 활동 로그가 null 에 `info()` 를 호출해 \Error 로 죽는다.
* (`ResolvesActivityLogType::logActivity` 의 catch 는 \Exception 만 잡아 통과시킴)
*/
private function spyApplicationLogKeepingActivityChannel(): void
{
$activityChannel = Log::channel('activity');
Log::spy();
Log::shouldReceive('channel')->with('activity')->andReturn($activityChannel);
}
private const TEST_MERCHANT_KEY = 'EYzu8jGGMfqaDEp76gSckuvnaHHu+bC4opsSN6lHv3b2lurNYkVXrZ7Z1AoqQnXI3eLuaUFyoRNC6FkrzVjceg==';
private function makeAuthorizeResponse(string $tid, string $moid, int $amount, string $resultCode = '3001'): array
@@ -49,7 +65,7 @@ class PaymentCallbackControllerTest extends PluginTestCase
$order = OrderFactory::new()->create([
'user_id' => $user->id,
'order_number' => 'ORD-TEST-' . random_int(10000, 99999),
'order_number' => 'ORD-TEST-'.random_int(10000, 99999),
'order_status' => OrderStatusEnum::PENDING_ORDER,
'subtotal_amount' => $totalAmount,
'total_discount_amount' => 0,
@@ -96,28 +112,28 @@ class PaymentCallbackControllerTest extends PluginTestCase
'redirect_fail_url' => '/shop/checkout',
];
$settingsMock = $this->createMock(\App\Services\PluginSettingsService::class);
$settingsMock = $this->createMock(PluginSettingsService::class);
$settingsMock->method('get')
->willReturn(array_merge($defaults, $overrides));
$this->app->instance(\App\Services\PluginSettingsService::class, $settingsMock);
$this->app->instance(PluginSettingsService::class, $settingsMock);
}
private function makeSignature(string $authToken, string $mid, int $amt, string $merchantKey): string
{
return bin2hex(hash('sha256', $authToken . $mid . (string) $amt . $merchantKey, true));
return bin2hex(hash('sha256', $authToken.$mid.(string) $amt.$merchantKey, true));
}
private function makeCallbackParams(string $moid, int $amt, array $overrides = []): array
{
$authToken = 'AUTH_TOKEN_' . uniqid();
$authToken = 'AUTH_TOKEN_'.uniqid();
$signature = $this->makeSignature($authToken, self::TEST_MID, $amt, self::TEST_MERCHANT_KEY);
return array_merge([
'AuthResultCode' => '0000',
'AuthResultMsg' => '성공',
'NextAppURL' => 'https://pay.nicepay.co.kr/v1/authorize',
'TxTid' => 'TX_TID_' . uniqid(),
'TxTid' => 'TX_TID_'.uniqid(),
'AuthToken' => $authToken,
'PayMethod' => 'CARD',
'MID' => self::TEST_MID,
@@ -135,7 +151,7 @@ class PaymentCallbackControllerTest extends PluginTestCase
'PayMethod' => 'VBANK',
'MID' => self::TEST_MID,
'MOID' => $moid,
'TID' => 'VBANK_TID_' . uniqid(),
'TID' => 'VBANK_TID_'.uniqid(),
'Amt' => $amt,
'ResultCode' => '4110',
'ResultMsg' => '입금완료',
@@ -432,7 +448,7 @@ class PaymentCallbackControllerTest extends PluginTestCase
$order = $this->createTestOrder(50000);
$this->mockPluginSettings();
$tid = 'TID_' . uniqid();
$tid = 'TID_'.uniqid();
$params = $this->makeCallbackParams($order->order_number, 50000);
Http::fake([
@@ -462,7 +478,7 @@ class PaymentCallbackControllerTest extends PluginTestCase
$order = $this->createTestOrder(50000);
$this->mockPluginSettings();
$tid = 'TID_EASY_PAY_' . uniqid();
$tid = 'TID_EASY_PAY_'.uniqid();
$params = $this->makeCallbackParams($order->order_number, 50000, [
'MallReserved' => 'nicepay_easy_pay_method=nicepay_kakaopay',
'MallReserved1' => 'nicepay_kakaopay',
@@ -1034,7 +1050,7 @@ class PaymentCallbackControllerTest extends PluginTestCase
public function test_vbank_notify_success_log_does_not_include_depositor_or_full_account(): void
{
Log::spy();
$this->spyApplicationLogKeepingActivityChannel();
$tid = 'VBANK_TID_LOG_SAFE';
$order = $this->createTestOrder(30000, PaymentMethodEnum::VBANK);
@@ -14,7 +14,9 @@ export default defineConfig({
entryFileNames: 'js/plugin.iife.js',
},
},
sourcemap: true,
// 배포용 빌드(--production)는 G7_BUILD_SOURCEMAP=0 을 주입해 소스맵을 생성하지 않는다.
// 미설정(로컬 npm run build)이면 생성 — 개발 디버깅 경험을 유지한다.
sourcemap: !['0', 'false'].includes(process.env.G7_BUILD_SOURCEMAP ?? ''),
minify: 'esbuild',
target: 'es2020',
chunkSizeWarningLimit: 500,
@@ -1,7 +1,7 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"identifier": "sirsoft-verification_kginicis",
"version": "1.0.0-beta.1",
"version": "1.0.1",
"components": {
"basic": [],
"composite": [],
@@ -1,2 +1 @@
var SirsoftVerificationKginicis=function(s){"use strict";const o="sirsoft-verification_kginicis",w="inicis",A="width=400,height=640,scrollbars=yes,resizable=yes",D="https://sa.inicis.com/auth",I="identity-challenge-modal",l={info:(...e)=>console.info(`[${o}]`,...e),warn:(...e)=>console.warn(`[${o}]`,...e),error:(...e)=>console.error(`[${o}]`,...e)};function a(){return window.G7Core??null}function v(){return`${window.location.origin}/plugins/${o}/plugin/inicis/callback`}function C(e,i){const t=document.createElement("form");t.setAttribute("name","saForm"),t.setAttribute("method","POST"),t.setAttribute("action",D),t.setAttribute("target",i),t.setAttribute("accept-charset","UTF-8"),t.style.display="none";const u={mid:e.mid,reqSvcCd:e.reqSvcCd,mTxId:e.mtxid,authHash:e.authHash,flgFixedUser:e.flgFixedUser,successUrl:v(),failUrl:v(),reservedMsg:e.reservedMsg};for(const[d,c]of Object.entries(u)){const r=document.createElement("input");r.type="hidden",r.name=d,r.value=c,t.appendChild(r)}document.body.appendChild(t),t.submit(),window.setTimeout(()=>{try{t.remove()}catch{}},0)}function _(e){const t=a()?.state?.set;typeof t=="function"&&t({identityChallenge:{providerInProgress:e}})}const L={NOT_ADULT:"$t:sirsoft-verification_kginicis.errors.not_adult",INVALID_AUTH_URL:"$t:sirsoft-verification_kginicis.errors.invalid_auth_url",DECRYPT_FAILED:"$t:sirsoft-verification_kginicis.errors.decrypt_failed",REMOTE_CALL_FAILED:"$t:sirsoft-verification_kginicis.errors.remote_call_failed",NOT_FOUND:"$t:sirsoft-verification_kginicis.errors.not_found",ALREADY_CONSUMED:"$t:sirsoft-verification_kginicis.errors.already_consumed",IDENTITY_BINDING_MISMATCH:"$t:sirsoft-verification_kginicis.errors.binding_mismatch",INCOMPLETE_IDENTITY:"$t:sirsoft-verification_kginicis.errors.incomplete_identity",STORAGE_FAILED:"$t:sirsoft-verification_kginicis.errors.storage_failed"},P="$t:sirsoft-verification_kginicis.errors.verify_failed",S=new Set(["NOT_ADULT"]);function T(e){return L[e]??P}function U(){const e=a()?.identity?.markDomainNoticeShown;if(typeof e=="function")try{e()}catch{}}async function g(e,i="error"){const t=a()?.dispatch;if(typeof t=="function")try{await t({handler:"toast",params:{type:i,message:e}})}catch{}}function O(e){const i=a()?.dispatch;return typeof i!="function"?Promise.resolve():i({handler:"sequence",params:{actions:[{handler:"resolveIdentityChallenge",params:e},{handler:"closeModal",target:I}]}}).catch(()=>{})}async function h(){const e=a(),i=e?.state?.getGlobal?.()?.identityChallenge??e?.state?.get?.()?.identityChallenge;if(!i){l.error("_global.identityChallenge 미설정 — 코어 모달 진입 흐름 확인 필요");return}const t=i.public_payload;if(!t?.mid||!t?.mtxid||!t?.authHash){l.error("public_payload 의 mid/mtxid/authHash 부재 — Provider.requestChallenge 응답 확인 필요",t),await g("본인인증 페이로드가 준비되지 않았습니다. 잠시 후 다시 시도해 주세요.");return}const u="sa_popup",d=window.open("",u,A);if(!d){l.error("window.open null — 팝업 차단됨"),await g("팝업이 차단되었습니다. 브라우저 팝업 허용 설정 후 다시 시도해 주세요.");return}_(!0),C(t,u);let c=!1,r=null,f=null;const p=()=>{f&&(window.removeEventListener("message",f),f=null),r!==null&&(window.clearInterval(r),r=null)};f=y=>{if(y.origin!==window.location.origin)return;const n=y.data;if(!n||n.type!=="identity_result"||c)return;c=!0,p();try{d.close()}catch{}_(!1);const b=n.verification_token?{result:"verified",token:n.verification_token}:n.identity_error?{result:"failed",failureCode:n.identity_error}:{result:"cancelled"};n.identity_error&&(S.has(n.identity_error)&&U(),g(T(n.identity_error),"error")),O(b)},window.addEventListener("message",f),r=window.setInterval(()=>{if(c){p();return}d.closed&&(c=!0,p(),_(!1))},500)}function E(){const i=a()?.getActionDispatcher;if(typeof i!="function")return!1;const t=i();return!t||typeof t.registerHandler!="function"?!1:(t.registerHandler(`${o}.startAuth`,h,{category:"plugin",source:o}),l.info("startAuth handler registered"),!0)}function m(){if(E())return;let e=0;const i=window.setInterval(()=>{e++,(E()||e>=50)&&(window.clearInterval(i),e>=50&&l.warn("G7Core ActionDispatcher 미준비 — handler 등록 실패"))},100)}return m(),window.__SirsoftVerificationKginicis={identifier:o,init:m,startAuthHandler:h},s.MODAL_ID=I,s.PLUGIN_IDENTIFIER=o,s.PROVIDER_ID=w,s.init=m,s.startAuthHandler=h,Object.defineProperty(s,Symbol.toStringTag,{value:"Module"}),s}({});
//# sourceMappingURL=plugin.iife.js.map
@@ -1 +0,0 @@
{"version":3,"file":"plugin.iife.js","sources":["../../resources/js/index.ts"],"sourcesContent":["/**\n * KG이니시스 본인인증 plugin — 코어 IDV 모달 슬롯 기반 통합.\n *\n * 설계 (Phase E′ 전환):\n * - 코어 IDV 흐름 (428 → 템플릿 launcher → POST /api/identity/challenges → 모달 open) 을 그대로 사용\n * - 코어 모달의 `identity_provider_ui:text_code` Extension Point 슬롯을 본 plugin 의\n * `resources/extensions/identity_provider_inicis.json` (mode: 'replace') 로 교체\n * - 슬롯 안의 \"이니시스 본인인증 시작\" 버튼이 본 파일의 핸들러 `startAuth` 를 호출\n * - 사용자 클릭 직접 호출 → window.open 이 사용자 제스처 컨텍스트 안에서 실행 →\n * Chrome popup blocker 회피 (자동 호출은 차단됨)\n * - 인증 완료 시 bridge 페이지가 부모창에 postMessage → resolveIdentityChallenge 핸들러 →\n * 코어 모달이 닫히고 return_request 재실행\n *\n * 이전 setLauncher 덮어쓰기 방식은 popup blocker 차단으로 폐기 (Phase E′-revert).\n *\n * @since 1.0.0-beta.1\n */\n\nconst PLUGIN_IDENTIFIER = 'sirsoft-verification_kginicis';\nconst PROVIDER_ID = 'inicis';\nconst POPUP_FEATURES = 'width=400,height=640,scrollbars=yes,resizable=yes';\nconst INICIS_AUTH_URL = 'https://sa.inicis.com/auth';\nconst POPUP_CLOSED_POLL_MS = 500;\nconst MODAL_ID = 'identity-challenge-modal';\n\ninterface InicisChallengePayload {\n mid: string;\n mtxid: string;\n reqSvcCd: string;\n flgFixedUser: string;\n reservedMsg: string;\n authHash: string;\n}\n\ninterface BridgeResult {\n type: 'identity_result';\n verification_token?: string;\n challenge_id?: string;\n identity_error?: string;\n}\n\nconst logger = {\n info: (...args: unknown[]) => console.info(`[${PLUGIN_IDENTIFIER}]`, ...args),\n warn: (...args: unknown[]) => console.warn(`[${PLUGIN_IDENTIFIER}]`, ...args),\n error: (...args: unknown[]) => console.error(`[${PLUGIN_IDENTIFIER}]`, ...args),\n};\n\nfunction getG7Core(): Record<string, any> | null {\n return ((window as any).G7Core as Record<string, any> | undefined) ?? null;\n}\n\nfunction buildSuccessUrl(): string {\n return `${window.location.origin}/plugins/${PLUGIN_IDENTIFIER}/plugin/inicis/callback`;\n}\n\n/**\n * 이니시스 본인인증 form 을 동적 생성하여 지정 target 에 POST.\n *\n * KISA 샘플 (sirsoft-verification/inicis/PHP/request.php) 와 동일한 form 구조 + setAttribute 패턴.\n */\nfunction submitInicisForm(payload: InicisChallengePayload, popupName: string): void {\n const form = document.createElement('form');\n form.setAttribute('name', 'saForm');\n form.setAttribute('method', 'POST');\n form.setAttribute('action', INICIS_AUTH_URL);\n form.setAttribute('target', popupName);\n form.setAttribute('accept-charset', 'UTF-8');\n form.style.display = 'none';\n\n const fields: Record<string, string> = {\n mid: payload.mid,\n reqSvcCd: payload.reqSvcCd,\n mTxId: payload.mtxid,\n authHash: payload.authHash,\n flgFixedUser: payload.flgFixedUser,\n successUrl: buildSuccessUrl(),\n failUrl: buildSuccessUrl(),\n reservedMsg: payload.reservedMsg,\n };\n\n for (const [name, value] of Object.entries(fields)) {\n const input = document.createElement('input');\n input.type = 'hidden';\n input.name = name;\n input.value = value;\n form.appendChild(input);\n }\n\n document.body.appendChild(form);\n form.submit();\n // form 은 자동 정리되지 않으므로 다음 tick 에 제거\n window.setTimeout(() => { try { form.remove(); } catch { /* noop */ } }, 0);\n}\n\n/**\n * _global.identityChallenge.providerInProgress 플래그를 set 한다.\n *\n * 모달 슬롯의 if 분기 — true 면 진행 중 안내 (스피너 + \"팝업창에서 진행 중\") 노출,\n * false 면 사전 안내 카드 + 시작 버튼 노출.\n */\nfunction setProviderInProgress(value: boolean): void {\n const G7Core = getG7Core();\n const set = G7Core?.state?.set;\n if (typeof set !== 'function') return;\n set({ identityChallenge: { providerInProgress: value } });\n}\n\n/**\n * verify 실패 코드 → 사용자 안내 메시지 i18n 키 매핑.\n *\n * 브리지 query 에는 failureCode 만 실려오므로(다국어 메시지 미전달), 프론트에서 코드를\n * `$t:` 다국어 키로 매핑한다. 코어 toast 핸들러가 `$t:` prefix 를 자동 번역하므로\n * 하드코딩 없이 로케일별 메시지가 표시된다.\n *\n * 미성년자 차단(NOT_ADULT) 안내 + 기존 미노출 실패 다건 안내를 일괄 처리한다.\n */\nconst FAILURE_MESSAGE_KEYS: Record<string, string> = {\n NOT_ADULT: '$t:sirsoft-verification_kginicis.errors.not_adult',\n INVALID_AUTH_URL: '$t:sirsoft-verification_kginicis.errors.invalid_auth_url',\n DECRYPT_FAILED: '$t:sirsoft-verification_kgiLine truncated
@@ -14,7 +14,9 @@ export default defineConfig({
entryFileNames: 'js/plugin.iife.js',
},
},
sourcemap: true,
// 배포용 빌드(--production)는 G7_BUILD_SOURCEMAP=0 을 주입해 소스맵을 생성하지 않는다.
// 미설정(로컬 npm run build)이면 생성 — 개발 디버깅 경험을 유지한다.
sourcemap: !['0', 'false'].includes(process.env.G7_BUILD_SOURCEMAP ?? ''),
minify: 'esbuild',
target: 'es2020',
chunkSizeWarningLimit: 500,