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

@@ -4,6 +4,12 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.2] - 2026-07-14
### Fixed
- 게시판 설정 화면의 상단 고정 영역이 좌우 여백과 어긋나 내용이 가로로 밀려 보일 수 있던 문제를 수정했습니다.
## [1.0.1] - 2026-07-08
### Added
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "modules/sirsoft-board",
"description": "Board module for Gnuboard7",
"type": "library",
"version": "1.0.1",
"version": "1.0.2",
"license": "MIT",
"autoload": {
"psr-4": {
+1 -1
View File
@@ -5,7 +5,7 @@
"ko": "게시판",
"en": "Board"
},
"version": "1.0.1",
"version": "1.0.2",
"license": "MIT",
"description": {
"ko": "게시판 관리를 위한 모듈",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@g7/sirsoft-board",
"version": "1.0.1",
"version": "1.0.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@g7/sirsoft-board",
"version": "1.0.1",
"version": "1.0.2",
"devDependencies": {
"jsdom": "^27.4.0",
"typescript": "^5.3.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@g7/sirsoft-board",
"version": "1.0.1",
"version": "1.0.2",
"description": "그누보드7 게시판 모듈 프론트엔드 에셋",
"private": true,
"type": "module",
@@ -6,6 +6,7 @@
"type": "api",
"endpoint": "/api/modules/sirsoft-board/admin/boards",
"method": "GET",
"if": "{{(query.tab || _global.activeEcommerceSettingsTab || 'basic_info') === 'basic_info'}}",
"auto_fetch": true,
"auth_required": true,
"params": {
@@ -0,0 +1,89 @@
/**
* @file admin-board-settings-tab-scoped-datasources.test.tsx
* @description 게시판 설정 데이터소스 탭 스코프 게이팅 회귀 가드
*
* 이커머스 환경설정과 동일 결함: 진입 시 전 탭의 데이터소스를 한꺼번에 fetch 했다.
* 탭 본문은 `if` 로 게이트되어 비활성 탭은 렌더되지 않으므로 그 데이터는 아무도 읽지 않는다.
* 엔진(DataSourceManager.filterByCondition + TemplateApp.updateQueryParams)이
* 데이터소스 `if` 로 탭별 로딩을 지원하므로, 탭 전용 소스는 `if` 게이트를 가져야 한다.
*/
import { describe, it, expect } from 'vitest';
const layout = require('../../../layouts/admin/admin_board_settings.json');
interface AnyJson { [k: string]: any }
const SHARED_SOURCE_IDS = ['settings'];
const TAB_SCOPED: Record<string, string[]> = {
boards_list: ['basic_defaults'],
roles: ['basic_defaults', 'report_policy'],
board_types: ['basic_defaults'],
boardNotificationDefinitions: ['notification_definitions', 'report_policy'],
availableChannels: ['notification_definitions'],
boardIdentityPolicies: ['identity_policies'],
boardIdentityPurposes: ['identity_policies'],
identityProviders: ['identity_policies'],
};
const sources: AnyJson[] = layout.data_sources;
const byId = (id: string) => sources.find(s => s.id === id);
describe('admin_board_settings — 데이터소스 탭 스코프 게이팅', () => {
it('공유 소스(settings)는 if 게이트 없이 항상 fetch 된다', () => {
for (const id of SHARED_SOURCE_IDS) {
const s = byId(id);
expect(s).toBeTruthy();
expect(s!.if).toBeUndefined();
expect(s!.auto_fetch).not.toBe(false);
}
});
it.each(Object.keys(TAB_SCOPED))('탭 전용 소스 %s 는 if 게이트를 가진다', (id) => {
const s = byId(id);
expect(s).toBeTruthy();
expect(typeof s!.if).toBe('string');
});
it.each(Object.entries(TAB_SCOPED))('%s 의 if 가 소유 탭(%s)을 참조한다', (id, tabs) => {
const cond: string = byId(id)!.if;
for (const tab of tabs as string[]) {
expect(cond).toContain(`'${tab}'`);
}
expect(cond).toContain('query.tab');
expect(cond).toContain('activeBoardSettingsTab');
});
it('탭 전용 소스를 auto_fetch:false 로 죽여두지 않는다', () => {
for (const id of Object.keys(TAB_SCOPED)) {
expect(byId(id)!.auto_fetch).not.toBe(false);
}
});
it('탭 전환은 navigate + replace:true 로 수행한다 (if 재평가 필요)', () => {
const seqs: AnyJson[] = [];
const walk = (n: any) => {
if (!n || typeof n !== 'object') return;
if (Array.isArray(n)) { n.forEach(walk); return; }
if (n.handler === 'sequence' && Array.isArray(n.params?.actions)) seqs.push(n);
Object.values(n).forEach(walk);
};
walk(layout);
const tabSwitches = seqs.filter(s =>
(s.params.actions as AnyJson[]).some(
a => a?.handler === 'setState' && 'activeBoardSettingsTab' in (a.params ?? {})
)
);
expect(tabSwitches.length).toBeGreaterThan(0);
for (const seq of tabSwitches) {
const acts = seq.params.actions as AnyJson[];
expect(acts.some(a => a.handler === 'replaceUrl')).toBe(false);
const nav = acts.find(a => a.handler === 'navigate');
expect(nav).toBeTruthy();
expect(nav!.params.replace).toBe(true);
}
});
});
@@ -304,8 +304,11 @@ describe('admin_board_settings.json - 메인 레이아웃', () => {
expect(innerActions[1].handler).toBe('setState');
expect(innerActions[1].params.target).toBe('global');
// replaceUrl - URL 업데이트
expect(innerActions[2].handler).toBe('replaceUrl');
// navigate(replace:true) - URL 업데이트 + 데이터소스 `if` 재평가
// replaceUrl 은 history 만 교체하므로 탭 전용 데이터소스가 로드되지 않는다.
// 가드: admin-board-settings-tab-scoped-datasources.test.tsx
expect(innerActions[2].handler).toBe('navigate');
expect(innerActions[2].params.replace).toBe(true);
expect(innerActions[2].params.query.tab).toBeDefined();
// scrollIntoView - 탭 변경 후 본문 상단으로 스크롤 (#369)
@@ -80,6 +80,7 @@
"type": "api",
"endpoint": "/api/modules/sirsoft-board/admin/boards?per_page=999&fields=id,title",
"method": "GET",
"if": "{{(query.tab || _global.activeBoardSettingsTab || 'general') === 'basic_defaults'}}",
"auto_fetch": true,
"auth_required": true,
"fallback": {
@@ -94,6 +95,7 @@
"type": "api",
"endpoint": "/api/admin/roles/active",
"method": "GET",
"if": "{{['basic_defaults', 'report_policy'].includes(query.tab || _global.activeBoardSettingsTab || 'general')}}",
"auto_fetch": true,
"auth_required": true,
"fallback": {
@@ -106,6 +108,7 @@
"type": "api",
"endpoint": "/api/modules/sirsoft-board/admin/board-types",
"method": "GET",
"if": "{{(query.tab || _global.activeBoardSettingsTab || 'general') === 'basic_defaults'}}",
"auto_fetch": true,
"auth_required": true,
"fallback": {
@@ -118,6 +121,7 @@
"type": "api",
"endpoint": "/api/admin/notification-definitions?extension_type=module&extension_identifier=sirsoft-board",
"method": "GET",
"if": "{{['notification_definitions', 'report_policy'].includes(query.tab || _global.activeBoardSettingsTab || 'general')}}",
"auto_fetch": true,
"auth_required": true,
"params": {
@@ -145,6 +149,7 @@
"type": "api",
"endpoint": "/api/admin/notification-channels",
"method": "GET",
"if": "{{(query.tab || _global.activeBoardSettingsTab || 'general') === 'notification_definitions'}}",
"auto_fetch": true,
"auth_required": true,
"fallback": {
@@ -159,6 +164,7 @@
"type": "api",
"endpoint": "/api/admin/identity/policies",
"method": "GET",
"if": "{{(query.tab || _global.activeBoardSettingsTab || 'general') === 'identity_policies'}}",
"auto_fetch": true,
"auth_required": true,
"params": {
@@ -182,6 +188,7 @@
"type": "api",
"endpoint": "/api/identity/purposes",
"method": "GET",
"if": "{{(query.tab || _global.activeBoardSettingsTab || 'general') === 'identity_policies'}}",
"auto_fetch": true,
"auth_required": true,
"fallback": {
@@ -194,6 +201,7 @@
"type": "api",
"endpoint": "/api/admin/identity/providers",
"method": "GET",
"if": "{{(query.tab || _global.activeBoardSettingsTab || 'general') === 'identity_policies'}}",
"auto_fetch": true,
"auth_required": true,
"fallback": {
@@ -243,7 +251,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "sticky-tab-nav-responsive"
"className": "sticky-section-header"
},
"children": [
{
@@ -339,11 +347,13 @@
}
},
{
"handler": "replaceUrl",
"handler": "navigate",
"params": {
"path": "/admin/boards/settings",
"query": {
"tab": "{{$args[0]}}"
}
},
"replace": true
}
},
{
@@ -1,7 +1,7 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"identifier": "sirsoft-ecommerce",
"version": "1.0.0-beta.5",
"version": "1.0.3",
"components": {
"basic": [],
"composite": [],
@@ -1,2 +1 @@
var SirsoftEcommerce=function(xe){"use strict";const gt={KRW:0,JPY:0},_t={KRW:"₩",USD:"$",JPY:"¥",CNY:"元",EUR:"€",GBP:"£"},wt={KRW:1e3,JPY:100};function yt(r){if(!r)return 1;const c=r.base_unit??wt[r.code]??1;return Math.max(1,c)}function ht(r,c,e){const t=parseFloat(c)||1;if(t<=0)return r;const o=r/t;let a;switch(e){case"ceil":a=Math.ceil(o);break;case"floor":a=Math.floor(o);break;default:a=Math.round(o)}return a*t}function he(r,c){const e=typeof c=="string"?{code:c}:c,t=e.code,o=e.symbol&&e.symbol.length>0?e.symbol:_t[t]??"",a=e.decimal_places??gt[t]??2,n=r.toLocaleString(void 0,{minimumFractionDigits:a,maximumFractionDigits:a});return o?t==="KRW"||o==="₩"||o==="원"?n+"원":o+n:n+" "+t}function ae(r,c){const{basePrice:e,currencies:t}=r,o=parseFloat(String(e))||0;if(!t||!Array.isArray(t)||t.length===0)return{};const a=t.find(i=>i.is_default),n=yt(a),s={};for(const i of t){const l=i.code;if(i.is_default??!1)s[l]={price:o,formatted:he(o,i),is_default:!0,editable:!0};else if(i.exchange_rate&&i.exchange_rate>0){const d=o/n*i.exchange_rate,m=ht(d,i.rounding_unit||"0.01",i.rounding_method||"round");s[l]={price:m,formatted:he(m,i),is_default:!1,editable:!1,exchange_rate:i.exchange_rate}}}return s}const z=window.G7Core?.createLogger?.("Ecom:UpdateProduct")??{log:(...r)=>console.log("[Ecom:UpdateProduct]",...r),warn:(...r)=>console.warn("[Ecom:UpdateProduct]",...r),error:(...r)=>console.error("[Ecom:UpdateProduct]",...r)};function Ct(r,c){const e=r.params||{},{productId:t,field:o,dataSourceId:a="products"}=e;let{value:n}=e;if(n&&typeof n=="object"&&n.target!==void 0&&(n=n.target?.value,z.log("[updateProductField] Extracted value from event:",n)),!t||!o){z.warn("[updateProductField] Missing required params:",{productId:t,field:o});return}if(n===void 0){z.warn("[updateProductField] Value is undefined, skipping update");return}const s=window.G7Core;if(!s?.dataSource?.get||!s?.dataSource?.set){z.warn("[updateProductField] G7Core.dataSource API is not available");return}const i=s.dataSource.get(a);if(!i){console.warn(`[updateProductField] DataSource '${a}' not found`);return}const l=i.data?.data||[];if(!Array.isArray(l)||l.length===0){z.warn("[updateProductField] Products data is empty or invalid");return}const u=s.state.get()||{},d=u.modules?.["sirsoft-ecommerce"]?.language_currency?.currencies;let g=[...u._local?.productFieldErrors||[]];const f=l.map(C=>{if(String(C.id)===String(t)){const A=C[o];if(typeof n=="object"&&n!==null?JSON.stringify(A)===JSON.stringify(n):String(A)===String(n))return z.log(`[updateProductField] No change for product ${t}.${o}, skipping`),C;let L=n;const R=parseFloat(n)||0;if(o==="selling_price"){const F=parseFloat(C.list_price)||0;F>0&&R>F?(g.some(oe=>oe.productId===String(t)&&oe.field===o)||g.push({productId:String(t),field:"selling_price",message:"판매가는 정가를 초과할 수 없습니다."}),L=F):g=g.filter($=>!($.productId===String(t)&&$.field===o))}else g=g.filter(F=>!(F.productId===String(t)&&F.field===o));const x={...C,[o]:L,_modified:!0};return o==="selling_price"&&d&&Array.isArray(d)&&(x.multi_currency_selling_price=ae({basePrice:L,currencies:d}),z.log(`[updateProductField] Recalculated multi_currency_selling_price for product ${t}`)),x}return C});s.dataSource.set(a,{...i,data:{...i.data,data:f}});const w=s.state.getLocal()||{},y=new Set(w.modifiedProductIds||[]);y.add(t);const _={...w.modifiedProductFields||{}},p=String(t),S=new Set(_[p]||[]);S.add(o),_[p]=Array.from(S),s.state.setLocal({modifiedProductIds:Array.from(y),modifiedProductFields:_,productFieldErrors:g}),z.log(`[updateProductField] Updated product ${t}.${o} =`,n)}const K=window.G7Core?.createLogger?.("Ecom:UpdateOption")??{log:(...r)=>console.log("[Ecom:UpdateOption]",...r),warn:(...r)=>console.warn("[Ecom:UpdateOption]",...r),error:(...r)=>console.error("[Ecom:UpdateOption]",...r)};function bt(r,c){const e=r.params||{},{productId:t,optionId:o,field:a,value:n,dataSourceId:s="products"}=e;if(!t||!o||!a){K.warn("[updateOptionField] Missing required params:",{productId:t,optionId:o,field:a});return}const i=window.G7Core;if(!i?.dataSource?.get||!i?.dataSource?.set){K.warn("[updateOptionField] G7Core.dataSource API is not available");return}const l=i.dataSource.get(s);if(!l){console.warn(`[updateOptionField] DataSource '${s}' not found`);return}const u=l.data?.data||[];if(!Array.isArray(u)||u.length===0){K.warn("[updateOptionField] Products data is empty or invalid");return}const d=i.state.get()||{},m=d.modules?.["sirsoft-ecommerce"]?.language_currency?.currencies;let f=[...d._local?.optionFieldErrors||[]],w=!1;const y=u.map(L=>{if(String(L.id)===String(t)){const R=L.options||[],x=R.findIndex(D=>String(D.id)===String(o));if(x===-1)return K.warn("[updateOptionField] Option not found:",o),L;const F=R[x],$=F[a];if(typeof n=="object"&&n!==null?JSON.stringify($)===JSON.stringify(n):String($)===String(n))return K.log(`[updateOptionField] No change for option ${o}.${a}, skipping`),L;let le=n;const we=parseFloat(nLine truncated
//# sourceMappingURL=module.iife.js.map
@@ -1 +0,0 @@
{"version":3,"file":"module.iife.js","sources":["../../resources/js/handlers/calculateCurrencyPrices.ts","../../resources/js/handlers/updateProductField.ts","../../resources/js/handlers/updateOptionField.ts","../../resources/js/handlers/initPreferredCurrency.ts","../../resources/js/handlers/initPreferredShippingCountry.ts","../../resources/js/handlers/setDateRange.ts","../../resources/js/handlers/setDefaultOption.ts","../../resources/js/handlers/optionSelection.ts","../../resources/js/handlers/generateCopyProductCode.ts","../../resources/js/handlers/copyProduct.ts","../../resources/js/handlers/categoryHandlers.ts","../../resources/js/handlers/brandHandlers.ts","../../resources/js/handlers/priceHandlers.ts","../../resources/js/handlers/productOptionHandlers.ts","../../resources/js/handlers/optionHandlers.ts","../../resources/js/handlers/imageHandlers.ts","../../resources/js/handlers/descriptionHandlers.ts","../../resources/js/handlers/noticeHandlers.ts","../../resources/js/handlers/miscHandlers.ts","../../resources/js/handlers/labelHandlers.ts","../../resources/js/handlers/descriptionLocaleHandlers.ts","../../resources/js/handlers/bulkUpdateHandlers.ts","../../resources/js/handlers/orderHandlers.ts","../../resources/js/handlers/productListHandlers.ts","../../resources/js/handlers/orderDetailHandlers.ts","../../resources/js/handlers/cancelOrderHandlers.ts","../../resources/js/handlers/userCancelOrderHandlers.ts","../../resources/js/handlers/userConfirmOrderHandlers.ts","../../resources/js/handlers/userChangeShippingAddressHandlers.ts","../../resources/js/handlers/userReviewHandlers.ts","../../resources/js/handlers/shippingPolicyFormHandlers.ts","../../resources/js/handlers/initCategoryFromUrl.ts","../../resources/js/handlers/initBrandFromUrl.ts","../../resources/js/handlers/initCommonInfoFromUrl.ts","../../resources/js/handlers/initNoticeFromUrl.ts","../../resources/js/handlers/index.ts","../../resources/js/index.ts"],"sourcesContent":["// e2e:allow base_unit 환산 공식(÷base_unit) 정확성은 단위 테스트(calculateCurrencyPrices.test.ts: USD base ¥0 회귀 + KRW base 등가)로 전수 검증. 다통화 표시 회귀는 product-option-multicurrency-readonly.spec.ts 가 구조적으로 차단.\n/**\n * 실시간 환율 계산 핸들러\n *\n * 기본 통화 가격을 입력받아 환율 기반으로 다중 통화 가격을 계산합니다.\n * 옵션 가격 인라인 편집 시 외화 가격 실시간 업데이트에 사용됩니다.\n */\n\nimport type { ActionContext } from '../types';\n\ninterface Currency {\n code: string;\n name?: Record<string, string>;\n exchange_rate?: number | null;\n is_default?: boolean;\n rounding_unit?: string;\n rounding_method?: string;\n base_unit?: number;\n symbol?: string;\n decimal_places?: number;\n}\n\n/**\n * 통화 코드별 소수 자릿수 폴백 (설정에 decimal_places 가 없을 때만 사용).\n */\nconst DECIMAL_PLACES_FALLBACK: Record<string, number> = {\n KRW: 0,\n JPY: 0,\n};\n\n/**\n * 통화 코드별 기본 기호 폴백 (설정 symbol 이 비어 있을 때만 사용).\n * 위안화(CNY)는 엔화(¥)와 구분되도록 元 사용 (#402 3-3).\n */\nconst SYMBOL_FALLBACK: Record<string, string> = {\n KRW: '₩',\n USD: '$',\n JPY: '¥',\n CNY: '元',\n EUR: '€',\n GBP: '£',\n};\n\n/**\n * base_unit 미설정 통화의 폴백 (소액 통화만 묶음 단위). 백엔드 CurrencyConversionService 와 동일.\n */\nconst BASE_UNIT_FALLBACK: Record<string, number> = {\n KRW: 1000,\n JPY: 100,\n};\n\n/**\n * 통화의 base_unit(기본 통화일 때 환율 분모가 되는 1단위 금액)을 반환합니다.\n *\n * @param currency 통화 설정\n * @returns base_unit (최소 1)\n */\nfunction resolveBaseUnit(currency: Currency | undefined): number {\n if (!currency) {\n return 1;\n }\n const unit = currency.base_unit ?? BASE_UNIT_FALLBACK[currency.code] ?? 1;\n return Math.max(1, unit);\n}\n\ninterface CurrencyPrice {\n price: number;\n formatted: string;\n is_default: boolean;\n editable: boolean;\n exchange_rate?: number;\n}\n\ninterface CalculateCurrencyPricesParams {\n basePrice: number | string;\n currencies?: Currency[];\n}\n\n/**\n * 절사/반올림/올림을 적용합니다.\n *\n * @param price 가격\n * @param unit 절사 단위\n * @param method 방법 (floor, round, ceil)\n * @returns 처리된 가격\n */\nfunction applyRounding(price: number, unit: string, method: string): number {\n const unitValue = parseFloat(unit) || 1;\n if (unitValue <= 0) {\n return price;\n }\n\n const divided = price / unitValue;\n let rounded: number;\n\n switch (method) {\n case 'ceil':\n rounded = Math.ceil(divided);\n break;\n case 'floor':\n rounded = Math.floor(divided);\n break;\n default:\n rounded = Math.round(divided);\n }\n\n return rounded * unitLine truncated
@@ -0,0 +1,101 @@
/**
* @file admin-ecommerce-settings-tab-scoped-datasources.test.tsx
* @description 환경설정 데이터소스 탭 스코프 게이팅 회귀 가드
*
* 배경: 환경설정 진입 시 모든 탭의 데이터소스를 한꺼번에 fetch 했다(18건). 탭 본문은
* `if` 로 게이트되어 비활성 탭은 렌더조차 되지 않으므로, 그 데이터는 아무도 읽지 않는
* 순수 낭비다. 게다가 탭 전환(navigate replace:true → updateQueryParams)마다 전수 재fetch 되어
* 요청이 2배(36건)로 불어나고 탭 클릭이 1.7초 blocking 됐다.
*
* 엔진의 기본 메커니즘: 데이터소스의 `if` 를 DataSourceManager.filterByCondition 이 평가해
* fetch 대상에서 제외하고, TemplateApp.updateQueryParams 가 탭 전환 시 `if` 를 재평가해
* 새 탭의 소스만 fetch 한다. 따라서 탭 전용 소스는 반드시 `if` 게이트를 가져야 하며,
* 탭 전환은 navigate + replace:true 로 updateQueryParams 를 태워야 한다(replaceUrl 은
* history 만 교체하므로 `if` 재평가가 일어나지 않음).
*/
import { describe, it, expect } from 'vitest';
const layout = require('../../../layouts/admin/admin_ecommerce_settings.json');
interface AnyJson { [k: string]: any }
/** 모든 탭이 공유하는 소스 (폼 본체) — 항상 fetch */
const SHARED_SOURCE_IDS = ['settings'];
/** 탭 전용 소스 → 소유 탭 */
const TAB_SCOPED: Record<string, string[]> = {
seoStats: ['seo'],
carriers: ['shipping'],
ecommerceNotificationDefinitions: ['notification_definitions', 'mileage'],
availableChannels: ['notification_definitions'],
ecommerceIdentityPolicies: ['identity_policies'],
ecommerceIdentityPurposes: ['identity_policies'],
identityProviders: ['identity_policies'],
};
const sources: AnyJson[] = layout.data_sources;
const byId = (id: string) => sources.find(s => s.id === id);
describe('admin_ecommerce_settings — 데이터소스 탭 스코프 게이팅', () => {
it('공유 소스(settings)는 if 게이트 없이 항상 fetch 된다', () => {
for (const id of SHARED_SOURCE_IDS) {
const s = byId(id);
expect(s).toBeTruthy();
expect(s!.if).toBeUndefined();
expect(s!.auto_fetch).not.toBe(false);
}
});
it.each(Object.keys(TAB_SCOPED))('탭 전용 소스 %s 는 if 게이트를 가진다', (id) => {
const s = byId(id);
expect(s).toBeTruthy();
expect(typeof s!.if).toBe('string');
expect(s!.if.length).toBeGreaterThan(0);
});
it.each(Object.entries(TAB_SCOPED))('%s 의 if 가 소유 탭(%s)을 참조한다', (id, tabs) => {
const cond: string = byId(id)!.if;
for (const tab of tabs as string[]) {
expect(cond).toContain(`'${tab}'`);
}
// 활성 탭 판정에 query.tab 과 _global 을 모두 사용해야 딥링크/클릭 양쪽에서 동작
expect(cond).toContain('query.tab');
expect(cond).toContain('activeEcommerceSettingsTab');
});
it('탭 전용 소스는 auto_fetch:false 로 죽여두지 않는다 (if 선택 시 fetch 되어야 함)', () => {
for (const id of Object.keys(TAB_SCOPED)) {
expect(byId(id)!.auto_fetch).not.toBe(false);
}
});
// 탭 전환은 updateQueryParams 를 태워 `if` 를 재평가해야 한다.
// replaceUrl 은 history.replaceState 만 하므로 새 탭의 소스가 영원히 로드되지 않는다.
it('탭 전환은 navigate + replace:true 로 수행한다 (replaceUrl 금지)', () => {
const seqs: AnyJson[] = [];
const walk = (n: any) => {
if (!n || typeof n !== 'object') return;
if (Array.isArray(n)) { n.forEach(walk); return; }
if (n.handler === 'sequence' && Array.isArray(n.params?.actions)) seqs.push(n);
Object.values(n).forEach(walk);
};
walk(layout);
const tabSwitches = seqs.filter(s =>
(s.params.actions as AnyJson[]).some(
a => a?.handler === 'setState' && 'activeEcommerceSettingsTab' in (a.params ?? {})
)
);
expect(tabSwitches.length).toBeGreaterThan(0);
for (const seq of tabSwitches) {
const acts = seq.params.actions as AnyJson[];
expect(acts.some(a => a.handler === 'replaceUrl')).toBe(false);
const nav = acts.find(a => a.handler === 'navigate');
expect(nav).toBeTruthy();
expect(nav!.params.replace).toBe(true);
expect(nav!.params.query?.tab).toBeTruthy();
}
});
});
@@ -387,6 +387,9 @@ describe('유효기간 / 소멸 알림 카드', () => {
expect(empty).toBeTruthy();
});
// URL 갱신은 navigate + replace:true 로 수행한다 — updateQueryParams 를 거쳐야
// 데이터소스의 `if` 가 재평가되어 알림 탭 전용 소스가 로드된다.
// 가드: admin-ecommerce-settings-tab-scoped-datasources.test.tsx
it('알림 설정으로 이동 버튼이 환경설정 알림 탭으로 전환한다', () => {
const btn = findById(notificationCard, 'go_to_notification_settings');
const seq = btn.actions[0];
@@ -394,6 +397,7 @@ describe('유효기간 / 소멸 알림 카드', () => {
const navigate = seq.params.actions.find((a: any) => a.handler === 'navigate');
expect(navigate.params.path).toBe('/admin/ecommerce/settings');
expect(navigate.params.query.tab).toBe('notification_definitions');
expect(navigate.params.replace).toBe(true);
const setGlobal = seq.params.actions.find((a: any) => a.handler === 'setState');
expect(setGlobal.params.activeEcommerceSettingsTab).toBe('notification_definitions');
});
@@ -56,6 +56,7 @@
"type": "api",
"endpoint": "/api/modules/sirsoft-ecommerce/admin/settings/seo-cache-info",
"method": "GET",
"if": "{{(query.tab || _global.activeEcommerceSettingsTab || 'basic_info') === 'seo'}}",
"auto_fetch": true,
"auth_required": true
},
@@ -65,6 +66,7 @@
"type": "api",
"endpoint": "/api/modules/sirsoft-ecommerce/admin/shipping-carriers",
"method": "GET",
"if": "{{(query.tab || _global.activeEcommerceSettingsTab || 'basic_info') === 'shipping'}}",
"auto_fetch": true,
"auth_required": true,
"errorHandling": {
@@ -82,6 +84,7 @@
"type": "api",
"endpoint": "/api/admin/notification-definitions?extension_type=module&extension_identifier=sirsoft-ecommerce",
"method": "GET",
"if": "{{['notification_definitions', 'mileage'].includes(query.tab || _global.activeEcommerceSettingsTab || 'basic_info')}}",
"auto_fetch": true,
"auth_required": true,
"params": {
@@ -117,6 +120,7 @@
"type": "api",
"endpoint": "/api/admin/notification-channels",
"method": "GET",
"if": "{{(query.tab || _global.activeEcommerceSettingsTab || 'basic_info') === 'notification_definitions'}}",
"auto_fetch": true,
"auth_required": true,
"fallback": {
@@ -131,6 +135,7 @@
"type": "api",
"endpoint": "/api/admin/identity/policies",
"method": "GET",
"if": "{{(query.tab || _global.activeEcommerceSettingsTab || 'basic_info') === 'identity_policies'}}",
"auto_fetch": true,
"auth_required": true,
"params": {
@@ -155,6 +160,7 @@
"type": "api",
"endpoint": "/api/identity/purposes",
"method": "GET",
"if": "{{(query.tab || _global.activeEcommerceSettingsTab || 'basic_info') === 'identity_policies'}}",
"auto_fetch": true,
"auth_required": true,
"fallback": {
@@ -167,6 +173,7 @@
"type": "api",
"endpoint": "/api/admin/identity/providers",
"method": "GET",
"if": "{{(query.tab || _global.activeEcommerceSettingsTab || 'basic_info') === 'identity_policies'}}",
"auto_fetch": true,
"auth_required": true,
"fallback": {
@@ -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: {