feat: CSP 다음/카카오 도메인 허용 + 입고 성적서 파일 백엔드 연동 + 팝업 이미지 중앙정렬
- middleware CSP: *.kakao.com, *.kakaocdn.net 추가 (다음 주소찾기 차단 해결) - frame-src에 'self' 추가 - 공지 팝업 이미지 중앙정렬 ([&_img]:mx-auto) - HR 사원관리, 결재, 품목, 생산 등 다수 개선 - API 에러 핸들링 및 JSON 파싱 안정화
This commit is contained in:
@@ -179,6 +179,61 @@ After: 백엔드 어드민 UI → 직접 DB 저장 → API로 config 전달
|
||||
|
||||
> ⚠️ **백엔드 논의 필요**: JSON 구조의 세부 스펙 확정
|
||||
|
||||
#### 2-2. 백엔드 저장 방식: JSONB (확정)
|
||||
|
||||
> ✅ **확정**: 페이지 config는 PostgreSQL **JSONB** 타입으로 저장
|
||||
|
||||
| 항목 | JSON | JSONB (채택) |
|
||||
|------|------|:---:|
|
||||
| 저장 형태 | 텍스트 그대로 | 바이너리 (파싱된 형태) |
|
||||
| 읽기 속도 | 매번 파싱 필요 | 이미 파싱됨 → **빠름** |
|
||||
| 인덱싱 | ❌ 불가 | ✅ **GIN 인덱스 가능** |
|
||||
| 내부 검색 | ❌ 전체 꺼내서 비교 | ✅ **특정 키/값으로 쿼리** |
|
||||
| 부분 수정 | ❌ 전체 교체 | ✅ **특정 키만 업데이트** |
|
||||
|
||||
**JSONB가 필요한 이유 — 우리 시스템과의 연관**:
|
||||
|
||||
```sql
|
||||
-- 1. 테넌트별 특정 타입 페이지만 조회 (인덱싱)
|
||||
SELECT * FROM page_configs
|
||||
WHERE tenant_id = 282
|
||||
AND config->>'pageType' = 'list';
|
||||
|
||||
-- 2. 특정 필드 타입을 쓰는 페이지 검색 (내부 검색)
|
||||
SELECT * FROM page_configs
|
||||
WHERE config @> '{"layout":{"sections":[{"fields":[{"type":"reference"}]}]}}';
|
||||
|
||||
-- 3. 기준관리에서 섹션 하나만 수정 (부분 수정)
|
||||
UPDATE page_configs
|
||||
SET config = jsonb_set(config, '{layout,sections,0,title}', '"수정된 섹션명"');
|
||||
```
|
||||
|
||||
**JSONB 채택이 config 구조 설계에 미치는 영향**:
|
||||
|
||||
| 영향 | 설명 |
|
||||
|------|------|
|
||||
| **구조 단순화** | 하나의 큰 JSONB에 전체 config를 담아도 부분 쿼리/수정 가능 → 테이블 분리 최소화 |
|
||||
| **테넌트 분기** | JSONB 인덱스로 테넌트+pageType 조합 쿼리가 빠름 → 별도 테이블 불필요 |
|
||||
| **기준관리 UI** | 섹션 하나만 수정해도 전체 config를 다시 저장할 필요 없음 → UX 향상 |
|
||||
| **프론트 영향** | **없음** — 프론트는 동일한 JSON을 받아서 렌더링, 저장 방식 무관 |
|
||||
|
||||
```
|
||||
DB 테이블 구조 (제안):
|
||||
|
||||
page_configs
|
||||
├── id (PK)
|
||||
├── tenant_id (FK, 인덱스)
|
||||
├── slug (UNIQUE per tenant, 인덱스)
|
||||
├── config (JSONB) ← 페이지 config 전체
|
||||
├── created_at
|
||||
└── updated_at
|
||||
|
||||
GIN 인덱스: config에 대해 생성 → 내부 검색 고속화
|
||||
복합 인덱스: (tenant_id, slug) → 테넌트별 페이지 조회 최적화
|
||||
```
|
||||
|
||||
> ⚠️ **백엔드 논의 필요**: JSONB 기반 테이블 설계 세부 확정 (위 제안 구조 검토)
|
||||
|
||||
---
|
||||
|
||||
### 규칙 3: 정적 페이지 vs 동적 페이지 분류
|
||||
@@ -431,6 +486,8 @@ src/app/[locale]/(protected)/
|
||||
|
||||
### 규칙 11: API 엔드포인트 동적 매핑
|
||||
|
||||
#### 11-1. API 호출 유형
|
||||
|
||||
| API 유형 | config 키 | 용도 |
|
||||
|---------|----------|------|
|
||||
| `list` | `api.list` | 목록 조회 (GET) |
|
||||
@@ -441,19 +498,119 @@ src/app/[locale]/(protected)/
|
||||
| `export` | `api.export` | 엑셀 다운로드 (GET) |
|
||||
| `custom` | `api.custom[actionName]` | 커스텀 액션 |
|
||||
|
||||
```
|
||||
동적 페이지의 데이터 흐름:
|
||||
#### 11-2. 백엔드 API 제공 방식 (3가지 방향)
|
||||
|
||||
config.api.list = "/api/v1/orders"
|
||||
↓
|
||||
DynamicListPage → Server Action (buildApiUrl 사용)
|
||||
↓
|
||||
API 프록시 → 백엔드 → 응답
|
||||
↓
|
||||
테이블 렌더링 (config.columns 기준)
|
||||
동적 페이지는 **데이터를 어디서 가져올지도 동적**이어야 합니다.
|
||||
백엔드가 API를 어떤 방식으로 제공하느냐에 따라 3가지 방향이 있습니다.
|
||||
|
||||
| 방향 | 설명 | 장점 | 단점 |
|
||||
|------|------|------|------|
|
||||
| **A. 개별 API** | 페이지마다 전용 API 존재, config에 경로 명시 | 기존 API 재사용, 복잡한 로직 처리 가능 | 새 페이지마다 백엔드 개발 필요 |
|
||||
| **B. 범용 Entity API** | 하나의 엔드포인트가 entityType으로 분기 | 새 페이지 추가 시 백엔드 코드 변경 없음 | 복잡한 비즈니스 로직 처리 어려움 |
|
||||
| **C. 하이브리드 (권장)** | 단순 CRUD는 범용 API, 복잡한 로직은 전용 API | 양쪽 장점 모두 취함 | 두 방식 공존에 따른 관리 비용 |
|
||||
|
||||
**방향 A: 개별 API (config에 경로 포함)**
|
||||
```jsonc
|
||||
{
|
||||
"pageType": "list",
|
||||
"slug": "sales/order-management",
|
||||
"api": {
|
||||
"list": "/api/v1/orders",
|
||||
"detail": "/api/v1/orders/:id",
|
||||
"create": "/api/v1/orders",
|
||||
"delete": "/api/v1/orders/:id"
|
||||
}
|
||||
}
|
||||
```
|
||||
→ 기존에 이미 만들어둔 API를 그대로 config에 연결
|
||||
→ 견적 계산, 세금 처리 등 **비즈니스 로직이 있는 페이지에 적합**
|
||||
|
||||
**방향 B: 범용 Entity API**
|
||||
```jsonc
|
||||
{
|
||||
"pageType": "list",
|
||||
"slug": "master/equipment",
|
||||
"entityType": "equipment"
|
||||
}
|
||||
```
|
||||
```
|
||||
// 범용 API 1개로 모든 entity 처리
|
||||
GET /api/v1/entities/{entityType}
|
||||
GET /api/v1/entities/{entityType}/{id}
|
||||
POST /api/v1/entities/{entityType}
|
||||
PUT /api/v1/entities/{entityType}/{id}
|
||||
DELETE /api/v1/entities/{entityType}/{id}
|
||||
```
|
||||
→ 백엔드에서 entityType에 따라 테이블/모델 동적 매핑
|
||||
→ 단순 CRUD(거래처, 설비, 자재 등) **마스터 데이터 페이지에 적합**
|
||||
|
||||
**방향 C: 하이브리드 (권장)**
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ 단순 CRUD 페이지 (거래처, 설비, 자재 등) │
|
||||
│ → 방향 B: 범용 entity API │
|
||||
│ → config에 entityType만 지정 │
|
||||
│ → 새 페이지 추가 시 백엔드 코드 변경 없음 │
|
||||
│ → 동적 시스템의 최대 효과 │
|
||||
└──────────────────────────────────────────────────┘
|
||||
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ 비즈니스 로직 페이지 (견적, 생산, 세금계산서) │
|
||||
│ → 방향 A: 전용 API 경로를 config에 명시 │
|
||||
│ → 계산/검증/워크플로우 등 복잡한 로직 처리 │
|
||||
│ → 기존 API 재사용으로 마이그레이션 용이 │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
> ⚠️ **백엔드 논의 필요**: API 응답 구조 통일 (list는 `{ data: [], meta: { total, page } }` 등)
|
||||
#### 11-3. 프론트 처리 방식 (어느 방향이든 동일)
|
||||
|
||||
```
|
||||
프론트는 API 제공 방식에 무관하게 동일한 패턴으로 처리:
|
||||
|
||||
1. config에서 API 경로 결정
|
||||
├─ api.list 있으면 → 그 경로 사용 (방향 A)
|
||||
└─ entityType 있으면 → `/api/v1/entities/${entityType}` 생성 (방향 B)
|
||||
↓
|
||||
2. buildApiUrl(경로, params) ← 기존 유틸 재사용
|
||||
↓
|
||||
3. Server Action에서 API 프록시 호출
|
||||
↓
|
||||
4. 응답을 config.columns 기준으로 렌더링
|
||||
```
|
||||
|
||||
```typescript
|
||||
// 프론트 API 경로 결정 유틸 (예시)
|
||||
function resolveApiUrl(config: PageConfig, action: 'list' | 'detail' | 'create' | 'update' | 'delete') {
|
||||
// 방향 A: 전용 API 경로가 있으면 사용
|
||||
if (config.api?.[action]) {
|
||||
return config.api[action];
|
||||
}
|
||||
// 방향 B: entityType으로 범용 API 생성
|
||||
if (config.entityType) {
|
||||
const base = `/api/v1/entities/${config.entityType}`;
|
||||
if (action === 'list' || action === 'create') return base;
|
||||
return `${base}/:id`;
|
||||
}
|
||||
throw new Error(`No API config for action: ${action}`);
|
||||
}
|
||||
```
|
||||
|
||||
#### 11-4. API 응답 구조 통일
|
||||
|
||||
어느 방향이든 **응답 구조는 통일**되어야 프론트가 범용 처리 가능:
|
||||
|
||||
| API 유형 | 응답 구조 |
|
||||
|---------|----------|
|
||||
| list | `{ data: [...], meta: { total, current_page, per_page, last_page } }` |
|
||||
| detail | `{ data: { ... } }` |
|
||||
| create | `{ data: { id, ... }, message: "..." }` |
|
||||
| update | `{ data: { id, ... }, message: "..." }` |
|
||||
| delete | `{ message: "..." }` |
|
||||
|
||||
> ⚠️ **백엔드 논의 필요**:
|
||||
> - 범용 entity API 도입 여부 및 범위
|
||||
> - 기존 API 중 응답 구조가 통일되지 않은 것 정리
|
||||
> - 전용 API와 범용 API의 분류 기준 합의
|
||||
|
||||
---
|
||||
|
||||
@@ -497,27 +654,54 @@ DynamicItemForm의 DisplayCondition → visibility 타입으로 범용화
|
||||
DynamicItemForm의 ComputedField → computed 타입으로 범용화
|
||||
```
|
||||
|
||||
> ⚠️ **백엔드 논의 필요**: 복잡한 계산식(견적 할인율 등)은 백엔드 API 위임 vs 프론트 formula
|
||||
> ✅ **확정**: 복잡한 계산식(견적 할인율 등)은 **백엔드에서 전부 처리**하여 결과만 전달
|
||||
|
||||
---
|
||||
|
||||
### 규칙 14: 권한 통합
|
||||
|
||||
| 권한 레벨 | 적용 대상 | 동작 |
|
||||
|----------|----------|------|
|
||||
| 페이지 | 메뉴/라우트 | 메뉴에 없으면 접근 불가 (기존 방식 유지) |
|
||||
| 액션 | 버튼/기능 | 삭제, 엑셀 다운로드 등 특정 액션 |
|
||||
| 필드 | 개별 입력 항목 | 보기/수정 권한 분리 |
|
||||
#### 14-1. 현재 권한 시스템 검증 결과
|
||||
|
||||
✅ **현재 권한 시스템으로 동적 페이지도 컨트롤 가능** (검증 완료)
|
||||
|
||||
현재 권한 시스템이 **메뉴 ID 기반 + URL 패턴 매칭**으로 동작하므로, 페이지가 정적이든 동적이든 해당 URL이 menu 테이블에 등록되어 있으면 권한 관리 페이지에서 동일하게 컨트롤됩니다.
|
||||
|
||||
```
|
||||
권한 적용 흐름:
|
||||
현재 (정적 페이지):
|
||||
백엔드 menu 테이블에 URL 등록 → 권한 매트릭스 체크박스 on/off
|
||||
→ PermissionGate가 URL 매칭 → 접근 허용/차단
|
||||
|
||||
1. 페이지 접근: 메뉴 API에 없으면 → 접근 차단 (기존)
|
||||
2. 액션 권한: config.permissions.actionLevel → 버튼 표시/숨김
|
||||
3. 필드 권한: config.permissions.fieldLevel → 읽기전용/숨김 처리
|
||||
동적 페이지도 동일:
|
||||
백엔드 menu 테이블에 동적 페이지 URL(slug) 등록
|
||||
→ 권한 매트릭스에서 동일하게 체크박스 on/off
|
||||
→ PermissionGate가 URL 매칭 → 동일하게 동작
|
||||
```
|
||||
|
||||
> ⚠️ **백엔드 논의 필요**: 권한 정보를 페이지 config에 포함 vs 별도 권한 API
|
||||
#### 14-2. 권한 레벨별 동적 페이지 호환성
|
||||
|
||||
| 권한 레벨 | 현재 지원 | 동적 페이지 호환 | 사용 컴포넌트 |
|
||||
|----------|:---:|:---:|------|
|
||||
| 페이지 접근 (view) | ✅ | ✅ | `PermissionGate` (URL 매칭) |
|
||||
| 생성 (create) | ✅ | ✅ | `usePermission()` / `PermissionGuard` |
|
||||
| 수정 (update) | ✅ | ✅ | `usePermission()` / `PermissionGuard` |
|
||||
| 삭제 (delete) | ✅ | ✅ | `usePermission()` / `PermissionGuard` |
|
||||
| 승인 (approve) | ✅ | ✅ | `usePermission()` / `PermissionGuard` |
|
||||
| 내보내기 (export) | ✅ | ✅ | `usePermission()` / `PermissionGuard` |
|
||||
| 관리 (manage) | ✅ | ✅ | `usePermission()` / `PermissionGuard` |
|
||||
| **필드 단위 권한** | ❌ | ❌ | 현재 미지원 → **v2 고려사항** |
|
||||
|
||||
#### 14-3. 권한 적용 흐름
|
||||
|
||||
```
|
||||
권한 적용 흐름 (정적/동적 공통):
|
||||
|
||||
1. 페이지 접근: PermissionGate → URL longest prefix 매칭 → view 권한 확인
|
||||
2. 액션 권한: usePermission() → canCreate/canDelete 등 → 버튼 표시/숨김
|
||||
3. 필드 권한: 현재 미지원 (v2에서 config.permissions.fieldLevel 추가 시 구현)
|
||||
```
|
||||
|
||||
> ⚠️ **백엔드 논의 필요**: 동적 페이지 URL(slug)을 menu 테이블에 자동 등록하는 방안
|
||||
> (기준관리에서 페이지 생성 시 → menu 테이블에도 자동 연동?)
|
||||
|
||||
---
|
||||
|
||||
@@ -544,25 +728,23 @@ DynamicItemForm의 ComputedField → computed 타입으로 범용화
|
||||
|
||||
---
|
||||
|
||||
### 규칙 16: 비즈니스 로직 처리 (하이브리드)
|
||||
### 규칙 16: 비즈니스 로직 처리
|
||||
|
||||
> ✅ **확정**: 복잡한 계산 수식은 **백엔드에서 전부 처리**하여 결과만 전달
|
||||
|
||||
| 로직 복잡도 | 처리 방식 | 예시 |
|
||||
|------------|----------|------|
|
||||
| 단순 계산 | config formula (프론트) | 수량 × 단가 = 금액 |
|
||||
| 중간 로직 | 등록된 로직 블록 (프론트) | 세금 계산, 할인율 적용 |
|
||||
| 복잡한 로직 | 백엔드 API 위임 | 견적 복합 계산, 재고 검증 |
|
||||
| 복잡한 계산 | **백엔드 API** | 견적 할인, 세금, 재고 검증 등 |
|
||||
|
||||
```jsonc
|
||||
// config에서 로직 블록 지정
|
||||
// config에서 로직 지정
|
||||
{
|
||||
"businessLogic": {
|
||||
// 단순: 프론트 formula
|
||||
// 단순: 프론트 formula (기존 ComputedField 재사용)
|
||||
"amount": { "type": "formula", "expression": "quantity * unitPrice" },
|
||||
|
||||
// 중간: 프론트 등록 블록
|
||||
"tax": { "type": "block", "blockId": "taxCalculation" },
|
||||
|
||||
// 복잡: 백엔드 위임
|
||||
// 복잡: 백엔드 위임 (확정)
|
||||
"totalDiscount": {
|
||||
"type": "api",
|
||||
"endpoint": "/api/v1/quotes/:id/calculate-discount",
|
||||
@@ -573,17 +755,7 @@ DynamicItemForm의 ComputedField → computed 타입으로 범용화
|
||||
}
|
||||
```
|
||||
|
||||
**프론트 로직 블록 레지스트리**:
|
||||
|
||||
```
|
||||
src/components/dynamic/logic-blocks/
|
||||
├── taxCalculation.ts ← 세금 계산
|
||||
├── currencyConversion.ts ← 환율 변환
|
||||
├── stockValidation.ts ← 재고 검증 (API 호출)
|
||||
└── registry.ts ← 블록 등록/조회
|
||||
```
|
||||
|
||||
> 새 블록이 필요할 때만 개발자 개입 → 등록 후 config에서 선택 사용
|
||||
프론트는 단순 사칙연산(ComputedField)만 담당하고, 그 외 모든 비즈니스 로직은 백엔드 API로 위임합니다.
|
||||
|
||||
---
|
||||
|
||||
@@ -635,37 +807,88 @@ src/components/dynamic/logic-blocks/
|
||||
|
||||
---
|
||||
|
||||
## 5. 백엔드 논의 체크리스트
|
||||
## 5. 논의 현황 정리
|
||||
|
||||
백엔드와 확정해야 할 사항:
|
||||
### 확정 사항
|
||||
|
||||
- [ ] **page-config API 스펙**: `GET /api/v1/page-configs/{slug}` 응답 구조
|
||||
- [ ] **기준관리 어드민 UI**: mng에서 레이아웃/섹션/필드 등록 화면 설계
|
||||
- [ ] **JSON config 구조**: 위 제안 구조 검토 및 수정
|
||||
- [ ] **API 응답 통일**: list/detail/create/update/delete 응답 포맷 표준화
|
||||
- [ ] **권한 통합 방식**: config에 포함 vs 별도 API
|
||||
- [ ] **분기 우선순위**: 테넌트 → 부서 → 역할 → 사용자 오버라이드 정책
|
||||
- [ ] **복잡한 비즈니스 로직**: 프론트 formula vs 백엔드 API 위임 기준
|
||||
- [ ] **캐시 무효화**: 기준관리 변경 시 프론트 캐시 갱신 방법 (polling vs push)
|
||||
- [ ] **프리페치 범위**: 로그인 시 전체 config vs 페이지 접근 시 개별 로드
|
||||
- [ ] **검증 규칙 표현**: JSON validation 스펙 확정
|
||||
- [ ] **의존성 표현**: visibility/computed/cascade 등 JSON 스펙 확정
|
||||
- [ ] **마이그레이션 순서**: 어떤 페이지부터 동적 전환할지
|
||||
| 항목 | 확정 내용 | 비고 |
|
||||
|------|----------|------|
|
||||
| API 제공 방식 | 하이브리드 (C) — 단순 CRUD는 범용, 복잡 로직은 전용 | 범용 API 세분화 가능성 있음 |
|
||||
| 복잡한 계산 수식 | 백엔드에서 전부 처리, 결과만 전달 | 프론트는 단순 사칙연산만 |
|
||||
| 권한 관리 호환성 | 현재 권한 시스템으로 동적 페이지 컨트롤 가능 | 메뉴 ID + URL 패턴 매칭 방식 |
|
||||
| 기존 동적 필드 재사용 | DynamicFieldRenderer 14종 등 90%+ 재사용 가능 | 기준관리 UI가 mng로 이동해도 렌더링 컴포넌트 유지 |
|
||||
| DB 저장 방식 | PostgreSQL **JSONB** 사용 | 인덱싱/부분수정/내부검색 가능, 프론트 영향 없음 |
|
||||
|
||||
### 협의 필요 사항
|
||||
|
||||
| 항목 | 현재 상태 | 논의 포인트 |
|
||||
|------|----------|------------|
|
||||
| JSON config 세부 구조 | 제안 구조 작성됨 (규칙 2 참조) | 회의에서 세부 항목 결정 후 확정 |
|
||||
| 정적/동적 페이지 분류 | 초안 목록 작성됨 (규칙 3 참조) | 어떤 페이지를 정적으로 남길지 최종 확정 |
|
||||
| 테넌트 하위 분기 정책 | 개념 정리됨 (규칙 7 참조) | 테넌트→부서→역할 오버라이드 정책, config를 최종 결과물로 줄지 프론트가 조합할지 |
|
||||
| 동적 라우팅 전략 | catch-all 방식 제안 (규칙 10 참조) | 기존 정적 페이지와의 공존/전환 전략 |
|
||||
| 범용 entity API 범위 | 하이브리드 방향 합의 | 페이지 렌더링 분기에 따라 범용 API 세분화 가능 |
|
||||
| page-config API 스펙 | 미정 | `GET /api/v1/page-configs/{slug}` 응답 구조 |
|
||||
| 기준관리 어드민 UI | 미정 | mng에서 레이아웃/섹션/필드 등록 화면 설계 |
|
||||
| API 응답 통일 | 미정 | list/detail/create/update/delete 응답 포맷 표준화 |
|
||||
| 캐시 무효화 | 미정 | 기준관리 변경 시 프론트 캐시 갱신 방법 (polling vs push) |
|
||||
| 프리페치 범위 | 미정 | 로그인 시 전체 config vs 페이지 접근 시 개별 로드 |
|
||||
| 검증/의존성 JSON 스펙 | 제안 구조 작성됨 (규칙 12, 13 참조) | 세부 스펙 확정 |
|
||||
| 마이그레이션 순서 | Phase 0~4 제안 (규칙 17 참조) | 어떤 페이지부터 동적 전환할지 |
|
||||
| 동적 페이지 → menu 자동 등록 | 미정 | 기준관리에서 페이지 생성 시 menu 테이블 자동 연동 방안 |
|
||||
| 필드 단위 권한 | 현재 미지원 | v2 고려사항 (필요 시 추가 개발) |
|
||||
|
||||
---
|
||||
|
||||
## 6. 관련 문서
|
||||
## 6. 기존 자산 재사용 현황
|
||||
|
||||
### 즉시 재사용 가능 (코드 변경 없음)
|
||||
|
||||
| 자산 | 현재 용도 | 동적 시스템 역할 | 재사용도 |
|
||||
|------|----------|----------------|:---:|
|
||||
| DynamicFieldRenderer (14종) | 품목 폼 필드 | 모든 동적 폼 필드 | 100% |
|
||||
| DynamicTableSection | 품목 BOM 테이블 | 모든 동적 테이블 | 99% |
|
||||
| DisplayCondition (9개 연산자) | 품목 조건부 표시 | 범용 visibility 규칙 | 100% |
|
||||
| ComputedField | 품목 자동 계산 | 범용 단순 계산 | 100% |
|
||||
| Reference Sources 프리셋 | 거래처/품목 등 조회 | 새 source 추가만으로 확장 | 100% |
|
||||
| TenantAwareCache | 캐시 격리 | pageConfigStore 캐시 | 100% |
|
||||
| menuRefresh (해시 비교) | 메뉴 갱신 | config 변경 감지 | 100% |
|
||||
| buildApiUrl | URL 빌더 | 동적 API 호출 | 100% |
|
||||
| PermissionGate / usePermission | 정적 페이지 권한 | 동적 페이지 권한 (동일) | 100% |
|
||||
|
||||
### 범용화 필요 (약간의 리팩토링)
|
||||
|
||||
| 자산 | 변경 사항 |
|
||||
|------|----------|
|
||||
| useDynamicFormState | API URL을 파라미터로 받도록 |
|
||||
| useFormStructure | 품목 전용 API → 범용 API 경로 |
|
||||
| types.ts | `ItemFieldResponse` → `DynamicFieldResponse` 리네이밍 |
|
||||
|
||||
### 신규 개발 필요
|
||||
|
||||
| 자산 | 역할 |
|
||||
|------|------|
|
||||
| DynamicListPage | 동적 리스트 페이지 렌더러 (UniversalListPage 기반) |
|
||||
| DynamicDetailPage | 동적 상세 페이지 렌더러 (IntegratedDetailTemplate 기반) |
|
||||
| DynamicDashboardPage | 동적 대시보드 렌더러 |
|
||||
| pageConfigStore | 페이지 config Zustand 스토어 |
|
||||
| catch-all route | `[...slug]/page.tsx` 동적 라우터 |
|
||||
| resolveApiUrl | API 경로 결정 유틸 (개별/범용 분기) |
|
||||
|
||||
---
|
||||
|
||||
## 7. 관련 문서
|
||||
|
||||
| 문서 | 위치 | 내용 |
|
||||
|------|------|------|
|
||||
| 동적 렌더링 플랫폼 비전 | `architecture/[VISION-2026-02-19]` | 전체 비전 및 자산 현황 |
|
||||
| 멀티테넌시 최적화 로드맵 | `architecture/[PLAN-2026-02-06]` | 테넌트 격리/최적화 8 Phase |
|
||||
| 동적 필드 타입 설계 | `architecture/[DESIGN-2026-02-11]` | 4-Level 구조, 14종 필드 |
|
||||
| 동적 필드 구현 현황 | `architecture/[IMPL-2026-02-11]` | Phase 1~3 프론트 구현 완료 |
|
||||
| 백엔드 API 스펙 | `item-master/[API-REQUEST-2026-02-12]` | 동적 필드 타입 백엔드 요청서 |
|
||||
| 동적 렌더링 플랫폼 비전 | `claudedocs/architecture/[VISION-2026-02-19]` | 전체 비전 및 자산 현황 |
|
||||
| 멀티테넌시 최적화 로드맵 | `claudedocs/architecture/[PLAN-2026-02-06]` | 테넌트 격리/최적화 8 Phase |
|
||||
| 동적 필드 타입 설계 | `claudedocs/architecture/[DESIGN-2026-02-11]` | 4-Level 구조, 14종 필드 |
|
||||
| 동적 필드 구현 현황 | `claudedocs/architecture/[IMPL-2026-02-11]` | Phase 1~3 프론트 구현 완료 |
|
||||
| 백엔드 API 스펙 | `claudedocs/item-master/[API-REQUEST-2026-02-12]` | 동적 필드 타입 백엔드 요청서 |
|
||||
|
||||
---
|
||||
|
||||
**문서 버전**: 1.0 (초안)
|
||||
**문서 버전**: 1.2
|
||||
**마지막 업데이트**: 2026-03-11
|
||||
**다음 단계**: 백엔드와 논의 → 체크리스트 항목 확정 → v2.0 작성
|
||||
**다음 단계**: 백엔드 회의 → 협의 필요 항목 확정 → v2.0 작성 → `sam-docs/frontend/v2/`에 최종본 등록
|
||||
|
||||
@@ -6,6 +6,7 @@ const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
|
||||
const nextConfig: NextConfig = {
|
||||
reactStrictMode: false, // 🧪 TEST: Strict Mode 비활성화로 중복 요청 테스트
|
||||
turbopack: {}, // ✅ CRITICAL: Next.js 15 + next-intl compatibility
|
||||
allowedDevOrigins: ['192.168.0.*'], // 로컬 네트워크 기기 접속 허용
|
||||
serverExternalPackages: ['puppeteer'], // PDF 생성용 - Webpack 번들 제외
|
||||
images: {
|
||||
remotePatterns: [
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"dev": "node scripts/validate-next-cache.mjs && NODE_OPTIONS='--require ./scripts/patch-json-parse.cjs' next dev",
|
||||
"build": "next build",
|
||||
"build:restart": "lsof -ti:3000 | xargs kill 2>/dev/null; next build && next start &",
|
||||
"start": "next start -H 0.0.0.0",
|
||||
|
||||
46
scripts/patch-json-parse.cjs
Normal file
46
scripts/patch-json-parse.cjs
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* JSON.parse 글로벌 패치 - macOS 26 파일시스템 손상 대응
|
||||
*
|
||||
* macOS 26에서 atomic write(tmp + rename)가 실패하면
|
||||
* .next/prerender-manifest.json 등의 파일에 데이터가 중복 기록됨.
|
||||
* 이로 인해 "Unexpected non-whitespace character after JSON at position N" 발생.
|
||||
*
|
||||
* 이 패치는 JSON.parse 실패 시 유효한 JSON 부분만 추출하여 자동 복구.
|
||||
* NODE_OPTIONS='--require ./scripts/patch-json-parse.cjs' 로 로드.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const originalParse = JSON.parse;
|
||||
|
||||
JSON.parse = function patchedJsonParse(text, reviver) {
|
||||
try {
|
||||
return originalParse.call(this, text, reviver);
|
||||
} catch (e) {
|
||||
if (e instanceof SyntaxError && typeof text === 'string') {
|
||||
// "Unexpected non-whitespace character after JSON at position N"
|
||||
// → position N까지가 유효한 JSON
|
||||
const match = e.message.match(/after JSON at position\s+(\d+)/);
|
||||
if (match) {
|
||||
const pos = parseInt(match[1], 10);
|
||||
if (pos > 0) {
|
||||
try {
|
||||
const result = originalParse.call(this, text.substring(0, pos), reviver);
|
||||
// 한 번만 경고 (같은 position이면 반복 출력 방지)
|
||||
if (!patchedJsonParse._warned) patchedJsonParse._warned = new Set();
|
||||
const key = pos + ':' + text.length;
|
||||
if (!patchedJsonParse._warned.has(key)) {
|
||||
patchedJsonParse._warned.add(key);
|
||||
console.warn(
|
||||
`[patch-json-parse] macOS 파일 손상 자동 복구 (position ${pos}, total ${text.length} bytes)`
|
||||
);
|
||||
}
|
||||
return result;
|
||||
} catch {
|
||||
// truncation으로도 실패하면 원래 에러 throw
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
49
scripts/validate-next-cache.mjs
Normal file
49
scripts/validate-next-cache.mjs
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* .next 빌드 캐시 무결성 검증
|
||||
*
|
||||
* macOS 26 파일시스템 이슈로 .next/ 내 JSON 파일이 손상될 수 있음.
|
||||
* (atomic write 실패 → 데이터 중복 기록)
|
||||
* dev 서버 시작 전 자동 검증하여 손상 시 .next 삭제.
|
||||
*/
|
||||
import { readFileSync, rmSync, existsSync, readdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
const NEXT_DIR = '.next';
|
||||
|
||||
if (!existsSync(NEXT_DIR)) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const jsonFiles = [];
|
||||
try {
|
||||
// .next/ 루트의 JSON 파일들
|
||||
for (const f of readdirSync(NEXT_DIR)) {
|
||||
if (f.endsWith('.json')) jsonFiles.push(join(NEXT_DIR, f));
|
||||
}
|
||||
// .next/server/ 의 JSON 파일들
|
||||
const serverDir = join(NEXT_DIR, 'server');
|
||||
if (existsSync(serverDir)) {
|
||||
for (const f of readdirSync(serverDir)) {
|
||||
if (f.endsWith('.json')) jsonFiles.push(join(serverDir, f));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 디렉토리 읽기 실패 시 무시
|
||||
}
|
||||
|
||||
let corrupted = false;
|
||||
for (const file of jsonFiles) {
|
||||
try {
|
||||
const content = readFileSync(file, 'utf8');
|
||||
JSON.parse(content);
|
||||
} catch (e) {
|
||||
console.warn(`⚠️ 손상된 캐시 발견: ${file}`);
|
||||
console.warn(` ${e.message}`);
|
||||
corrupted = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (corrupted) {
|
||||
console.warn('🗑️ .next 캐시를 삭제하고 재빌드합니다...');
|
||||
rmSync(NEXT_DIR, { recursive: true, force: true });
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { authenticatedFetch } from '@/lib/api/authenticated-fetch';
|
||||
import { stripJsonTrailingData } from '@/lib/api/safe-json-parse';
|
||||
|
||||
/**
|
||||
* 🔵 Catch-All API Proxy (HttpOnly Cookie Pattern)
|
||||
@@ -190,7 +191,7 @@ async function proxyRequest(
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const responseData = await backendResponse.text();
|
||||
let responseData = await backendResponse.text();
|
||||
|
||||
// 백엔드가 HTML 에러 페이지를 반환한 경우 (404/500 등)
|
||||
// HTML을 그대로 전달하면 클라이언트 response.json()에서 SyntaxError 발생
|
||||
@@ -208,6 +209,10 @@ async function proxyRequest(
|
||||
{ status }
|
||||
);
|
||||
} else {
|
||||
// PHP trailing output 제거 (JSON 뒤에 warning/error 텍스트가 붙는 경우)
|
||||
if (responseContentType.includes('application/json') || responseData.trimStart().startsWith('{') || responseData.trimStart().startsWith('[')) {
|
||||
responseData = stripJsonTrailingData(responseData);
|
||||
}
|
||||
clientResponse = new NextResponse(responseData, {
|
||||
status: backendResponse.status,
|
||||
headers: {
|
||||
|
||||
@@ -171,7 +171,7 @@ export async function uploadFiles(files: File[]): Promise<{
|
||||
uploadedFiles.push({
|
||||
id: result.data.id,
|
||||
name: result.data.display_name || file.name,
|
||||
url: `${process.env.NEXT_PUBLIC_API_URL}/api/v1/files/${result.data.id}/download`,
|
||||
url: `/api/proxy/files/${result.data.id}/download`,
|
||||
size: result.data.file_size,
|
||||
mime_type: result.data.mime_type,
|
||||
});
|
||||
@@ -589,7 +589,7 @@ function transformApiToFormData(apiData: {
|
||||
// URL이 없거나 상대 경로인 경우 다운로드 URL 생성
|
||||
url: f.url?.startsWith('http')
|
||||
? f.url
|
||||
: `${process.env.NEXT_PUBLIC_API_URL}/api/v1/files/${f.id}/download`,
|
||||
: `/api/proxy/files/${f.id}/download`,
|
||||
size: f.size,
|
||||
mime_type: f.mime_type,
|
||||
}));
|
||||
|
||||
@@ -112,8 +112,8 @@ export function NoticePopupModal({ popup, open, onOpenChange }: NoticePopupModal
|
||||
{/* 제목 */}
|
||||
<h3 className="text-base font-medium mb-4">{popup.title}</h3>
|
||||
|
||||
{/* 이미지 영역 */}
|
||||
{popup.imageUrl ? (
|
||||
{/* 이미지 영역 - imageUrl이 있을 때만 표시 */}
|
||||
{popup.imageUrl && (
|
||||
<div className="relative w-full aspect-[4/3] mb-4 rounded-md overflow-hidden border bg-muted">
|
||||
<img
|
||||
src={popup.imageUrl}
|
||||
@@ -121,17 +121,13 @@ export function NoticePopupModal({ popup, open, onOpenChange }: NoticePopupModal
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full aspect-[4/3] mb-4 rounded-md border bg-muted flex items-center justify-center">
|
||||
<span className="text-muted-foreground text-sm">IMG</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 내용 */}
|
||||
<div className="text-sm text-foreground mb-6">
|
||||
<p className="text-muted-foreground mb-2">내용</p>
|
||||
<div
|
||||
className="prose prose-sm max-w-none"
|
||||
className="prose prose-sm max-w-none [&_img]:mx-auto [&_img]:block"
|
||||
dangerouslySetInnerHTML={{ __html: sanitizeHTML(popup.content) }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -43,7 +43,7 @@ import {
|
||||
EMPLOYEE_STATUS_LABELS,
|
||||
DEFAULT_FIELD_SETTINGS,
|
||||
} from './types';
|
||||
import { getPositions, getDepartments, uploadProfileImage, type PositionItem, type DepartmentItem } from './actions';
|
||||
import { getPositions, getDepartments, type PositionItem, type DepartmentItem } from './actions';
|
||||
import { extractDigits } from '@/lib/formatters';
|
||||
|
||||
// 부서 트리 구조 타입
|
||||
@@ -578,19 +578,33 @@ export function EmployeeForm({
|
||||
// 미리보기 즉시 표시
|
||||
const previewUrl = URL.createObjectURL(file);
|
||||
handleChange('profileImage', previewUrl);
|
||||
// 서버에 업로드 (FormData로 감싸서 전송)
|
||||
const uploadFormData = new FormData();
|
||||
uploadFormData.append('file', file);
|
||||
const result = await uploadProfileImage(uploadFormData);
|
||||
if (result.success && result.data?.url) {
|
||||
// 업로드 성공 시 서버 URL로 업데이트
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
handleChange('profileImage', result.data.url);
|
||||
} else {
|
||||
// 업로드 실패 시 미리보기 제거 및 에러 표시
|
||||
try {
|
||||
// 프록시를 통해 직접 업로드 (서버 액션 경유 시 FormData File 손실 방지)
|
||||
const uploadFormData = new FormData();
|
||||
uploadFormData.append('file', file);
|
||||
uploadFormData.append('directory', 'employees/profiles');
|
||||
const response = await fetch('/api/proxy/files/upload', {
|
||||
method: 'POST',
|
||||
body: uploadFormData,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`업로드 실패: ${response.status}`);
|
||||
}
|
||||
const result = await response.json();
|
||||
if (result.success && result.data) {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
const fileId = result.data.id;
|
||||
const viewUrl = fileId
|
||||
? `/api/proxy/files/${fileId}/view`
|
||||
: result.data.file_path || '';
|
||||
handleChange('profileImage', viewUrl);
|
||||
} else {
|
||||
throw new Error(result.message || '업로드 실패');
|
||||
}
|
||||
} catch (err) {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
handleChange('profileImage', '');
|
||||
toast.error(result.error || '이미지 업로드에 실패했습니다.');
|
||||
toast.error(err instanceof Error ? err.message : '이미지 업로드에 실패했습니다.');
|
||||
}
|
||||
}}
|
||||
onRemove={() => handleChange('profileImage', '')}
|
||||
|
||||
@@ -213,7 +213,7 @@ export async function getDepartments(): Promise<DepartmentItem[]> {
|
||||
|
||||
export async function uploadProfileImage(inputFormData: FormData): Promise<{
|
||||
success: boolean;
|
||||
data?: { url: string; path: string };
|
||||
data?: { url: string; path: string; fileId?: number };
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}> {
|
||||
@@ -243,18 +243,21 @@ export async function uploadProfileImage(inputFormData: FormData): Promise<{
|
||||
const result = await response.json();
|
||||
if (!result.success) return { success: false, error: result.message || '파일 업로드에 실패했습니다.' };
|
||||
|
||||
const uploadedPath = result.data?.file_path || result.data?.path || result.data?.url;
|
||||
if (!uploadedPath) return { success: false, error: '업로드된 파일 경로를 가져올 수 없습니다.' };
|
||||
// R2 전환: /storage/ 직접 접근 불가 → /api/proxy/files/{id}/view 사용
|
||||
const fileId = result.data?.id;
|
||||
const uploadedPath = result.data?.file_path || result.data?.path || '';
|
||||
|
||||
const storagePath = uploadedPath.startsWith('/storage/')
|
||||
? uploadedPath
|
||||
: `/storage/tenants/${uploadedPath}`;
|
||||
// file_id가 있으면 프록시 경로 사용, 없으면 fallback
|
||||
const viewUrl = fileId
|
||||
? `/api/proxy/files/${fileId}/view`
|
||||
: uploadedPath;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
url: `${API_URL}${storagePath}`,
|
||||
url: viewUrl,
|
||||
path: uploadedPath,
|
||||
fileId,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
@@ -27,7 +27,8 @@ const API_URL = process.env.NEXT_PUBLIC_API_URL || '';
|
||||
* 프로필 이미지 경로를 전체 URL로 변환
|
||||
* - 이미 전체 URL이면 그대로 반환
|
||||
* - base64 data URL이면 그대로 반환
|
||||
* - 상대 경로면 API URL + /storage/tenants/ 붙여서 반환
|
||||
* - /api/proxy/files/ 경로면 그대로 반환
|
||||
* - 상대 경로면 그대로 반환 (R2 전환 후 직접 접근 불가)
|
||||
*/
|
||||
export function getProfileImageUrl(path: string | null | undefined): string | undefined {
|
||||
if (!path) return undefined;
|
||||
@@ -42,8 +43,14 @@ export function getProfileImageUrl(path: string | null | undefined): string | un
|
||||
return path;
|
||||
}
|
||||
|
||||
// 상대 경로인 경우 API URL과 결합 (tenants 디렉토리 사용)
|
||||
return `${API_URL}/storage/tenants/${path}`;
|
||||
// 프록시 경로인 경우 (/api/proxy/files/{id}/view)
|
||||
if (path.startsWith('/api/proxy/')) {
|
||||
return path;
|
||||
}
|
||||
|
||||
// 상대 경로인 경우 — R2 전환 후 /storage/tenants/ 직접 접근 불가
|
||||
// 경로만 보존하고 표시는 프록시 통해서 처리
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,13 +72,23 @@ export function extractRelativePath(path: string | null | undefined): string | n
|
||||
return null;
|
||||
}
|
||||
|
||||
// 프록시 경로인 경우 (/api/proxy/files/{id}/view) - 그대로 반환
|
||||
if (path.startsWith('/api/proxy/')) {
|
||||
return path;
|
||||
}
|
||||
|
||||
// 전체 URL인 경우 상대 경로 추출
|
||||
if (path.startsWith('http://') || path.startsWith('https://')) {
|
||||
// /storage/tenants/ 이후의 경로 추출
|
||||
// /storage/tenants/ 이후의 경로 추출 (레거시)
|
||||
const match = path.match(/\/storage\/tenants\/(.+)$/);
|
||||
if (match) {
|
||||
return match[1];
|
||||
}
|
||||
// /api/proxy/files/ 경로 추출
|
||||
const proxyMatch = path.match(/(\/api\/proxy\/files\/.+)$/);
|
||||
if (proxyMatch) {
|
||||
return proxyMatch[1];
|
||||
}
|
||||
// 매칭 실패 시 null 반환
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -82,9 +82,9 @@ function getStorageUrl(path: string | undefined): string | null {
|
||||
if (path.startsWith('http://') || path.startsWith('https://')) {
|
||||
return path;
|
||||
}
|
||||
// 상대 경로인 경우
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
|
||||
return `${apiUrl}/storage/${path}`;
|
||||
// R2 전환 후 /storage/ 직접 접근 불가 → 레거시 경로는 빈 값 반환
|
||||
// bendingDiagramFileId가 있으면 /view 엔드포인트를 사용하므로 여기는 폴백 전용
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function ItemDetailClient({ item }: ItemDetailClientProps) {
|
||||
@@ -372,7 +372,7 @@ export default function ItemDetailClient({ item }: ItemDetailClientProps) {
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={item.bendingDiagramFileId
|
||||
? `/api/proxy/files/${item.bendingDiagramFileId}/download`
|
||||
? `/api/proxy/files/${item.bendingDiagramFileId}/view`
|
||||
: getStorageUrl(item.bendingDiagram) || ''
|
||||
}
|
||||
alt="전개도"
|
||||
|
||||
@@ -191,7 +191,7 @@ export default function BendingDiagramSection({
|
||||
)}
|
||||
<div className="border rounded bg-white p-2">
|
||||
<img
|
||||
src={`/api/proxy/files/${existingBendingDiagramFileId}/download`}
|
||||
src={`/api/proxy/files/${existingBendingDiagramFileId}/view`}
|
||||
alt="기존 전개도"
|
||||
className="max-w-full h-auto max-h-96 mx-auto"
|
||||
onError={(e) => {
|
||||
|
||||
@@ -1851,7 +1851,7 @@ export async function uploadInspectionFiles(files: File[]): Promise<{
|
||||
uploadedFiles.push({
|
||||
id: result.data.id,
|
||||
name: result.data.display_name || file.name,
|
||||
url: buildApiUrl(`/api/v1/files/${result.data.id}/download`),
|
||||
url: `/api/proxy/files/${result.data.id}/download`,
|
||||
size: result.data.file_size,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -68,15 +68,16 @@ interface TemplateInspectionContentProps {
|
||||
|
||||
// ===== 유틸 =====
|
||||
|
||||
/** API 저장소 이미지 URL 생성 (사원관리와 동일 패턴) */
|
||||
function getImageUrl(path: string | null | undefined): string {
|
||||
/** API 저장소 이미지 URL 생성 — R2 전환 후 프록시 사용 */
|
||||
function getImageUrl(path: string | null | undefined, fileId?: number | null): string {
|
||||
if (!path && !fileId) return '';
|
||||
// file_id가 있으면 프록시 경로 사용
|
||||
if (fileId) return `/api/proxy/files/${fileId}/view`;
|
||||
if (!path) return '';
|
||||
if (path.startsWith('http://') || path.startsWith('https://')) return path;
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
|
||||
// tenant storage 경로 (숫자/로 시작: {tenant_id}/temp/...)
|
||||
if (/^\d+\//.test(path)) return `${apiUrl}/storage/tenants/${path}`;
|
||||
// 레거시 경로 (document-templates/xxx.jpg 등)
|
||||
return `${apiUrl}/storage/${path}`;
|
||||
if (path.startsWith('/api/proxy/')) return path;
|
||||
// R2 전환 후 /storage/ 직접 접근 불가 — 경로만 반환 (fallback)
|
||||
return path;
|
||||
}
|
||||
|
||||
/** field_values.reference_attribute에서 작업 아이템의 실제 치수를 resolve */
|
||||
|
||||
@@ -388,7 +388,7 @@ export const FqcDocumentContent = forwardRef<FqcDocumentContentRef, FqcDocumentC
|
||||
<div className="p-4 flex items-center justify-center min-h-[100px]">
|
||||
{section.imagePath ? (
|
||||
<img
|
||||
src={`${process.env.NEXT_PUBLIC_API_URL}/storage/${section.imagePath}`}
|
||||
src={section.imagePath || ''}
|
||||
alt={section.name}
|
||||
className="max-h-[300px] max-w-full object-contain"
|
||||
/>
|
||||
|
||||
@@ -7,8 +7,9 @@ import type { AccountInfo, TermsAgreement, MarketingConsent } from './types';
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||
|
||||
/**
|
||||
* 상대 경로를 절대 URL로 변환
|
||||
* /storage/... 또는 1/temp/... → https://api.example.com/storage/tenants/...
|
||||
* 상대 경로를 표시 가능한 URL로 변환
|
||||
* R2 전환 후: /api/proxy/files/{id}/view 사용
|
||||
* 레거시 경로는 그대로 반환 (표시 불가할 수 있음)
|
||||
*/
|
||||
function toAbsoluteUrl(path: string | undefined): string | undefined {
|
||||
if (!path) return undefined;
|
||||
@@ -16,12 +17,12 @@ function toAbsoluteUrl(path: string | undefined): string | undefined {
|
||||
if (path.startsWith('http://') || path.startsWith('https://')) {
|
||||
return path;
|
||||
}
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
|
||||
// /storage/로 시작하면 그대로, 아니면 /storage/tenants/ 붙이기
|
||||
if (path.startsWith('/storage/')) {
|
||||
return `${apiUrl}${path}`;
|
||||
// 프록시 경로면 그대로 반환
|
||||
if (path.startsWith('/api/proxy/')) {
|
||||
return path;
|
||||
}
|
||||
return `${apiUrl}/storage/tenants/${path}`;
|
||||
// R2 전환 후 /storage/ 직접 접근 불가 — 경로만 보존
|
||||
return path;
|
||||
}
|
||||
|
||||
// ===== 계정 정보 조회 =====
|
||||
@@ -142,12 +143,14 @@ export async function uploadProfileImage(formData: FormData): Promise<{
|
||||
if (updateResult.__authError) return { success: false, __authError: true };
|
||||
if (!updateResult.success) return { success: false, error: updateResult.error };
|
||||
|
||||
const storagePath = uploadedPath.startsWith('/storage/')
|
||||
? uploadedPath
|
||||
: `/storage/tenants/${uploadedPath}`;
|
||||
// R2 전환: file_id 기반 프록시 경로 사용
|
||||
const fileId = uploadResult.data.id;
|
||||
const viewUrl = fileId
|
||||
? `/api/proxy/files/${fileId}/view`
|
||||
: uploadedPath;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { imageUrl: toAbsoluteUrl(storagePath) || '' },
|
||||
data: { imageUrl: viewUrl },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -168,13 +168,13 @@ export function FileList({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 새 탭에서 열기 */}
|
||||
{file.url && (
|
||||
{/* 새 탭에서 열기 (인라인 뷰) */}
|
||||
{file.id && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size={compact ? 'sm' : 'default'}
|
||||
onClick={() => window.open(file.url, '_blank')}
|
||||
onClick={() => window.open(`/api/proxy/files/${file.id}/view`, '_blank')}
|
||||
className={cn('text-gray-600 hover:text-gray-700', compact && 'h-7 w-7 p-0')}
|
||||
title="새 탭에서 열기"
|
||||
>
|
||||
|
||||
@@ -114,11 +114,12 @@ export async function executeServerAction<TApi = unknown, TResult = TApi>(
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// JSON 파싱 (백엔드가 HTML 에러 페이지 반환 시 안전 처리)
|
||||
// JSON 파싱 (PHP trailing output + HTML 에러 페이지 안전 처리)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let result: any;
|
||||
try {
|
||||
result = await response.json();
|
||||
const { safeResponseJson } = await import('./safe-json-parse');
|
||||
result = await safeResponseJson(response);
|
||||
} catch {
|
||||
const status = response.status;
|
||||
console.error(`[executeServerAction] JSON 파싱 실패 (${method} ${url}, status: ${status})`);
|
||||
|
||||
@@ -183,7 +183,19 @@ class ServerApiClient {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
// 방어적 JSON 파싱: PHP 백엔드가 JSON 뒤에 경고/에러 텍스트를 붙여 보내는 경우 대응
|
||||
const text = await response.text();
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (parseError) {
|
||||
// JSON 뒤에 trailing garbage가 있는 경우 복구 시도
|
||||
const match = text.match(/^(\{[\s\S]*\}|\[[\s\S]*\])/);
|
||||
if (match) {
|
||||
console.warn('[ServerApiClient] Response contained trailing data, recovering:', text.slice(match[1].length, match[1].length + 100));
|
||||
return JSON.parse(match[1]);
|
||||
}
|
||||
throw parseError;
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
throw error;
|
||||
|
||||
@@ -74,7 +74,9 @@ async function doRefreshToken(refreshToken: string): Promise<RefreshResult> {
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
// 방어적 JSON 파싱: PHP가 JSON 뒤에 warning을 붙여 보내는 경우 대응
|
||||
const { safeResponseJson } = await import('./safe-json-parse');
|
||||
const data = await safeResponseJson<{ access_token: string; refresh_token: string; expires_in: number }>(response);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
||||
98
src/lib/api/safe-json-parse.ts
Normal file
98
src/lib/api/safe-json-parse.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* 방어적 JSON 파싱 유틸리티
|
||||
*
|
||||
* PHP 백엔드가 JSON 출력 후 warning/error 텍스트를 붙여 보내는 경우 대응.
|
||||
* 예: {"success":true,"data":[...]}<br />Warning: Undefined variable...
|
||||
*
|
||||
* JSON.parse는 유효한 JSON 이후 trailing data가 있으면
|
||||
* "Unexpected non-whitespace character after JSON at position N" 에러를 발생시킴.
|
||||
* 이때 position N까지가 유효한 JSON이므로, 해당 지점까지 잘라서 재파싱.
|
||||
*/
|
||||
|
||||
/**
|
||||
* PHP trailing output을 제거하고 JSON 파싱
|
||||
*
|
||||
* @param text - JSON 문자열 (trailing garbage 포함 가능)
|
||||
* @returns 파싱된 JSON 객체
|
||||
* @throws SyntaxError - 유효한 JSON이 아닌 경우 (복구 불가)
|
||||
*/
|
||||
export function safeJsonParse<T = unknown>(text: string): T {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (e) {
|
||||
if (!(e instanceof SyntaxError)) throw e;
|
||||
|
||||
// "Unexpected ... after JSON at position N" 패턴에서 position 추출
|
||||
const posMatch = e.message.match(/position\s+(\d+)/i);
|
||||
if (posMatch) {
|
||||
const pos = parseInt(posMatch[1], 10);
|
||||
if (pos > 0) {
|
||||
try {
|
||||
const result = JSON.parse(text.substring(0, pos)) as T;
|
||||
console.warn(
|
||||
`[safeJsonParse] PHP trailing output detected (${text.length - pos} bytes). ` +
|
||||
`Trailing: ${text.substring(pos, pos + 200).replace(/\n/g, '\\n')}`
|
||||
);
|
||||
return result;
|
||||
} catch {
|
||||
// truncation으로도 실패하면 원래 에러 throw
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Response 객체에서 방어적으로 JSON 파싱
|
||||
*
|
||||
* response.json() 대신 response.text() + safeJsonParse() 사용.
|
||||
* PHP trailing output이 있어도 유효한 JSON을 복구.
|
||||
*/
|
||||
export async function safeResponseJson<T = unknown>(response: Response): Promise<T> {
|
||||
const text = await response.text();
|
||||
return safeJsonParse<T>(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON 텍스트에서 trailing garbage 제거
|
||||
*
|
||||
* 프록시에서 사용: 파싱하지 않고 텍스트 레벨에서 trailing data만 제거.
|
||||
* JSON이 아니거나 trailing data가 없으면 원본 그대로 반환.
|
||||
*/
|
||||
export function stripJsonTrailingData(text: string): string {
|
||||
// JSON이 아닌 경우 원본 반환
|
||||
const trimmed = text.trimStart();
|
||||
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
|
||||
return text;
|
||||
}
|
||||
|
||||
try {
|
||||
// 정상 파싱 되면 trailing data 없음
|
||||
JSON.parse(text);
|
||||
return text;
|
||||
} catch (e) {
|
||||
if (!(e instanceof SyntaxError)) return text;
|
||||
|
||||
const posMatch = e.message.match(/position\s+(\d+)/i);
|
||||
if (posMatch) {
|
||||
const pos = parseInt(posMatch[1], 10);
|
||||
if (pos > 0) {
|
||||
const truncated = text.substring(0, pos);
|
||||
try {
|
||||
// truncated가 유효한 JSON인지 확인
|
||||
JSON.parse(truncated);
|
||||
console.warn(
|
||||
`[stripJsonTrailingData] Removed ${text.length - pos} bytes of trailing data`
|
||||
);
|
||||
return truncated;
|
||||
} catch {
|
||||
// 복구 불가 → 원본 반환
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
}
|
||||
@@ -51,12 +51,10 @@ export async function downloadFileById(fileId: number, fileName?: string): Promi
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 경로로 새 탭에서 열기 (미리보기용)
|
||||
* @param filePath 파일 경로
|
||||
* 파일 ID로 새 탭에서 열기 (인라인 미리보기)
|
||||
* R2 스토리지 전환으로 /storage/ 직접 접근 불가 → /view 엔드포인트 사용
|
||||
* @param fileId 파일 ID
|
||||
*/
|
||||
export function openFileInNewTab(filePath: string): void {
|
||||
// 백엔드 파일 서빙 URL 구성
|
||||
const baseUrl = process.env.NEXT_PUBLIC_API_URL || '';
|
||||
const fileUrl = `${baseUrl}/storage/${filePath}`;
|
||||
window.open(fileUrl, '_blank');
|
||||
export function openFileInNewTab(fileId: number): void {
|
||||
window.open(`/api/proxy/files/${fileId}/view`, '_blank');
|
||||
}
|
||||
@@ -199,6 +199,23 @@ export async function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
const userAgent = request.headers.get('user-agent') || '';
|
||||
|
||||
// 🚨 -2️⃣ Server Action 요청 처리
|
||||
// Server Action POST 요청은 Next-Action 헤더를 가짐
|
||||
// intlMiddleware가 RSC 응답을 간섭하면 직렬화가 깨지므로
|
||||
// locale만 수동 rewrite하고 intlMiddleware는 건너뜀
|
||||
if (request.headers.get('next-action')) {
|
||||
// 기본 locale(ko)은 URL에 없으므로 locale prefix 추가하여 rewrite
|
||||
const hasLocale = locales.some(
|
||||
(l) => pathname.startsWith(`/${l}/`) || pathname === `/${l}`
|
||||
);
|
||||
if (!hasLocale) {
|
||||
const url = request.nextUrl.clone();
|
||||
url.pathname = `/${defaultLocale}${pathname}`;
|
||||
return NextResponse.rewrite(url);
|
||||
}
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// 🚨 -1️⃣ Next.js 내부 요청 필터링
|
||||
// 동적 라우트 세그먼트가 리터럴로 포함된 요청은 Next.js 내부 컴파일/prefetch
|
||||
// 예: /[locale]/settings/... 형태의 요청은 실제 사용자 요청이 아님
|
||||
@@ -307,12 +324,12 @@ export async function middleware(request: NextRequest) {
|
||||
intlResponse.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
intlResponse.headers.set('Content-Security-Policy', [
|
||||
"default-src 'self'",
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://maps.googleapis.com *.daumcdn.net",
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://maps.googleapis.com *.daumcdn.net *.kakaocdn.net *.kakao.com",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: blob: https:",
|
||||
"font-src 'self' data: https://fonts.gstatic.com",
|
||||
"connect-src 'self' https://maps.googleapis.com *.daum.net *.daumcdn.net",
|
||||
"frame-src *.daum.net *.daumcdn.net",
|
||||
"connect-src 'self' https://maps.googleapis.com *.daum.net *.daumcdn.net *.kakao.com *.kakaocdn.net",
|
||||
"frame-src 'self' *.daum.net *.daumcdn.net *.kakao.com *.kakaocdn.net",
|
||||
"frame-ancestors 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
|
||||
Reference in New Issue
Block a user