fix(upload): decodeURIComponent(escape) 우선 한글 파일명 복원, defParamCharset utf8 제거
- lib/decode-upload-filename.js: 전형적 Latin-1 래핑 UTF-8 복원 - multer 경영성과 업로드는 기본 latin1 + 서버에서 decodeUploadFilename - utf8 defParamCharset는 이중 디코딩으로 á 패턴 등이 날 수 있어 제거 Made-with: Cursor
This commit is contained in:
@@ -42,7 +42,7 @@
|
||||
- 학습센터 UI (좌측 메뉴 + 상단 헤더 + 강의 카드 레이아웃)
|
||||
- **AI 탐색** (`/ai-explore`): 전체 너비 레이아웃, AI 서비스 카드(프롬프트·회의록 등). 검색창에 **「프롬프트」**가 포함된 채 검색(Enter) 시 프롬프트 라이브러리로 이동
|
||||
- **대시보드** (`/dashboard`): AI 탐색과 유사한 카드 그리드·검색으로 대시보드를 모아 표시. 첫 카드 **경영성과 대시보드**는 `/dashboard/business-performance`로 연결
|
||||
- **경영성과 대시보드** (`/dashboard/business-performance`): 상단 **엑셀 업로드**(`.xlsx`, 매출일보 시트) → DB(`mgmt_perf_uploads` / `mgmt_perf_snapshots`) 또는 DB 미연결 시 `data/mgmt-perf-last-state.json`에 스냅샷 저장. 하단 **대시보드 조회**는 동일 페이지에 Chart.js 템플릿을 인라인으로 렌더(iframe 제거, 별도 `/dashboard/business-performance/embed`는 직접 열람용으로 유지). Express에서 **`/mgmt-perf/*` → `public/mgmt-perf/`** 정적 제공이 등록되어 있어 `dashboard-app.js`·`chart.umd.min.js`(CDN 대신 동봉)가 항상 같은 오리진에서 로드됩니다. 업로드 시 **한글 파일명**은 Multer **`defParamCharset: 'utf8'`**(Busboy가 `Content-Disposition`의 `filename`을 UTF-8로 해석)과, 예외 시 **`decodeMultipartFilename`**(Latin-1로 잘못 들어온 바이트를 UTF-8로 재해석)로 보정 후 저장합니다. 탭 전환·차트 렌더는 **ASCII 섹션 id**(`mgmt-sec-sales` 등)와 `state.currentSection`으로 동기화합니다. 리버스 프록시 사용 시 업로드 실패하면 **`client_max_body_size`**(예: 64m)와 **`/api/`·`/mgmt-perf/` → Node** 전달 여부를 확인. 엑셀 집계 치환은 `npm install`로 `xlsx` 설치 후 서버 재시작.
|
||||
- **경영성과 대시보드** (`/dashboard/business-performance`): 상단 **엑셀 업로드**(`.xlsx`, 매출일보 시트) → DB(`mgmt_perf_uploads` / `mgmt_perf_snapshots`) 또는 DB 미연결 시 `data/mgmt-perf-last-state.json`에 스냅샷 저장. 하단 **대시보드 조회**는 동일 페이지에 Chart.js 템플릿을 인라인으로 렌더(iframe 제거, 별도 `/dashboard/business-performance/embed`는 직접 열람용으로 유지). Express에서 **`/mgmt-perf/*` → `public/mgmt-perf/`** 정적 제공이 등록되어 있어 `dashboard-app.js`·`chart.umd.min.js`(CDN 대신 동봉)가 항상 같은 오리진에서 로드됩니다. 업로드 시 **한글 파일명**은 multer 기본(`defParamCharset` 생략 시 latin1)으로 온 `originalname`을 **`lib/decode-upload-filename.js`**의 `decodeUploadFilename`으로 보정합니다(`decodeURIComponent(escape(...))` 우선, 이어서 `Buffer` latin1→utf8). Busboy에 `defParamCharset: 'utf8'`를 켜면 이중 디코딩으로 깨질 수 있어 두지 않습니다. 탭 전환·차트 렌더는 **ASCII 섹션 id**(`mgmt-sec-sales` 등)와 `state.currentSection`으로 동기화합니다. 리버스 프록시 사용 시 업로드 실패하면 **`client_max_body_size`**(예: 64m)와 **`/api/`·`/mgmt-perf/` → Node** 전달 여부를 확인. 엑셀 집계 치환은 `npm install`로 `xlsx` 설치 후 서버 재시작.
|
||||
- **경영성과 데이터 확인**: 브라우저에서 `GET /api/mgmt-perf/status`(JSON)로 최근 스냅샷의 `payloadKeys`, `_uploadMeta`(행 수 등)를 확인할 수 있습니다. **현재 구현**은 엑셀에서 **매출일보 행 수·시트명만** `payload._uploadMeta`에 넣고, **차트 수치는 기본 시드 JSON**(`data/mgmt-perf-default-payload.json`)을 씁니다. 5,000행이어도 차트가 엑셀 집계와 일치하려면 **별도 집계·매핑 로직**이 필요합니다.
|
||||
- **대시보드 메뉴 접근**: `.env`의 `DASHBOARD_MENU_ALLOWED_EMAILS`에 **쉼표로 구분한 OPS 로그인 이메일**만 좌측 **대시보드** 메뉴·`/dashboard`·경영성과 API가 보입니다. 목록이 비어 있으면 누구에게도 표시되지 않습니다. 로컬(DEV)에서 관리자 토큰만 쓰는 경우 `DASHBOARD_MENU_DEV_USE_MEETING_EMAIL=1`과 `MEETING_DEV_EMAIL`을 허용 목록과 맞추면 대조됩니다.
|
||||
- **프롬프트 라이브러리** (`/ai-explore/prompts`): 업무별 기본 프롬프트 카드 선택·미리보기·클립보드 복사 (`data/company-prompts.json`). **좌측 메뉴(채팅·AI·AI 성공 사례·학습센터·AX 과제 신청)는 관리자 여부와 관계없이 접근 가능**(강의 삭제·관리자 대시보드 등 일부 기능은 관리자 모드에서만)
|
||||
|
||||
42
lib/decode-upload-filename.js
Normal file
42
lib/decode-upload-filename.js
Normal file
@@ -0,0 +1,42 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* multipart `filename` 복원.
|
||||
*
|
||||
* 브라우저는 UTF-8 파일명을 `Content-Disposition`에 넣을 때, 바이트를 ISO-8859-1(Latin-1) 코드 포인트
|
||||
* (U+00xx) 한 글자씩으로 보내는 경우가 많습니다. 이 경우 `decodeURIComponent(escape(s))` 또는
|
||||
* `Buffer.from(s, "latin1").toString("utf8")`로 UTF-8 문자열로 되돌릴 수 있습니다.
|
||||
*
|
||||
* 일부 환경에서는 이미 잘못 합쳐진 유니코드(예: U+00E1 등)만 남아 복구가 불가능할 수 있습니다.
|
||||
*
|
||||
* @param {string} name multer `originalname`
|
||||
* @returns {string}
|
||||
*/
|
||||
function decodeUploadFilename(name) {
|
||||
if (name == null || typeof name !== "string") return "";
|
||||
const raw = name.trim();
|
||||
if (!raw) return "";
|
||||
const nfc = raw.normalize("NFC");
|
||||
if (/[\uAC00-\uD7A3]/.test(nfc)) return nfc;
|
||||
|
||||
try {
|
||||
const viaEscape = decodeURIComponent(escape(nfc));
|
||||
if (!viaEscape.includes("\uFFFD") && viaEscape !== nfc) {
|
||||
return viaEscape.normalize("NFC");
|
||||
}
|
||||
} catch (_) {
|
||||
/* escape가 만들어 낸 % 시퀀스가 URI로 부적합한 경우 */
|
||||
}
|
||||
|
||||
try {
|
||||
const buf = Buffer.from(nfc, "latin1");
|
||||
const asUtf8 = buf.toString("utf8");
|
||||
if (!asUtf8.includes("\uFFFD")) {
|
||||
return asUtf8.normalize("NFC");
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
return nfc;
|
||||
}
|
||||
|
||||
module.exports = { decodeUploadFilename };
|
||||
24
server.js
24
server.js
@@ -27,24 +27,7 @@ const {
|
||||
} = require("./lib/ops-state");
|
||||
const { fetchOpenGraphImageUrl } = require("./lib/link-preview");
|
||||
const mgmtPerf = require("./lib/mgmt-perf");
|
||||
|
||||
/**
|
||||
* multipart `filename`이 Latin-1로 잘못 해석된 UTF-8 바이트일 때 복원합니다.
|
||||
* 이미 한글이 올바른 유니코드로 들어온 경우는 그대로 둡니다.
|
||||
*/
|
||||
function decodeMultipartFilename(name) {
|
||||
if (name == null || typeof name !== "string") return "";
|
||||
// 이미 한글 등 BMP가 올바르게 들어온 경우(멀티바이트 그대로) 덮어쓰지 않음
|
||||
if (/[\uAC00-\uD7A3]/.test(name)) return name;
|
||||
try {
|
||||
const dec = Buffer.from(name, "latin1").toString("utf8");
|
||||
// UTF-8이 Latin-1로 잘못 해석된 경우 복원. ASCII-only 파일명은 dec ≈ name.
|
||||
if (dec && !dec.includes("\uFFFD")) return dec;
|
||||
} catch (_) {
|
||||
/* ignore */
|
||||
}
|
||||
return name;
|
||||
}
|
||||
const { decodeUploadFilename } = require("./lib/decode-upload-filename");
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 8030;
|
||||
@@ -569,8 +552,7 @@ const mgmtPerfStorage = multer.diskStorage({
|
||||
const uploadMgmtPerfExcel = multer({
|
||||
storage: mgmtPerfStorage,
|
||||
limits: { fileSize: 55 * 1024 * 1024 },
|
||||
/** Busboy 기본은 latin1; `filename="..."` 안의 UTF-8 바이트를 올바르게 UTF-8 문자열로 씁니다. */
|
||||
defParamCharset: "utf8",
|
||||
/** 기본(latin1): `filename`을 바이트→U+00xx 문자열로 두고 `decodeUploadFilename`에서 UTF-8 복원. `utf8`이면 이중 디코딩·깨짐이 날 수 있음. */
|
||||
fileFilter: (_, file, cb) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
if (ext !== ".xlsx") {
|
||||
@@ -1263,7 +1245,7 @@ app.post(
|
||||
const payload = mgmtPerf.buildPayloadFromWorkbook(buf, defaultPayload);
|
||||
await mgmtPerf.saveUploadAndSnapshot(pgPool, {
|
||||
userEmail: email,
|
||||
originalFilename: decodeMultipartFilename(req.file.originalname),
|
||||
originalFilename: decodeUploadFilename(req.file.originalname),
|
||||
filePath: req.file.path,
|
||||
fiscalYear,
|
||||
quarter,
|
||||
|
||||
Reference in New Issue
Block a user