// === API clients === // SECURITY: credentials live in localStorage only. Never logged. Never sent anywhere except the configured endpoints. const ISVM_BASE = "https://web-production-9e9d.up.railway.app"; const NAVER_API_BASE = "https://api.commerce.naver.com"; // --- Credential storage (localStorage) --- const CRED_KEY = "smartfarm.creds.v1"; function loadCreds() { try { return JSON.parse(localStorage.getItem(CRED_KEY) || "{}"); } catch { return {}; } } function saveCreds(c) { localStorage.setItem(CRED_KEY, JSON.stringify(c)); } function clearCreds() { localStorage.removeItem(CRED_KEY); } // --- ISVM client --- async function isvmHealth() { try { const res = await fetch(`${ISVM_BASE}/health`, { method: "GET" }); return res.ok; } catch { return false; } } async function isvmSearch({ keyword, limit = 20, sort = "sales" }) { const res = await fetch(`${ISVM_BASE}/api/search`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ keyword, limit, sort }), }); if (!res.ok) throw new Error(`ISVM search failed: HTTP ${res.status}`); const json = await res.json(); if (!json.success) throw new Error(json.error || "ISVM 검색 실패"); return json.data || []; } // ISVM 상품의 모든 이미지 URL 조회 (메인 + 상세 + 추가) // 우선 우리 프록시의 직접 스크래이프 라우트 사용 (Railway crawler 회귀 대응). // 실패 시 Railway crawler API로 fallback. async function isvmFetchProductImages(productCode) { const proxyBase = _proxyBase(); // 1차: 프록시 직접 스크래이프 (모든 productsNew/ 이미지 포함) if (proxyBase) { try { const r = await fetch(`${proxyBase}/isvm/extract-images/${encodeURIComponent(productCode)}`); if (r.ok) { const json = await r.json(); if (json.success && Array.isArray(json.data) && json.data.length > 0) { return json.data; } } } catch (e) { console.warn("[isvm proxy scrape 실패, Railway crawler로 fallback]", e.message); } } // 2차 fallback: Railway crawler (상품코드 파일명 매칭만 — 누락 발생 가능) const res = await fetch(`${ISVM_BASE}/api/product-images`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ product_code: productCode }), }); if (!res.ok) throw new Error(`ISVM 다중 이미지 조회 실패: HTTP ${res.status}`); const json = await res.json(); if (!json.success) throw new Error(json.error || "ISVM 다중 이미지 조회 실패"); return json.data || []; } // Add products with batching (10 at a time per spec) async function isvmAddProducts(codes, onProgress) { const BATCH = 10; const merged = []; let totalSuccess = 0; for (let i = 0; i < codes.length; i += BATCH) { const batch = codes.slice(i, i + BATCH); const res = await fetch(`${ISVM_BASE}/api/add-products`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ product_codes: batch, start_order: i + 1 }), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const json = await res.json(); const results = json.data?.results || []; merged.push(...results); totalSuccess += json.data?.success_count || 0; onProgress && onProgress({ done: Math.min(i + BATCH, codes.length), total: codes.length, results: merged }); } return { success_count: totalSuccess, total_count: codes.length, results: merged }; } // --- Naver Commerce API payload builder --- // We CANNOT call Naver from the browser directly: // 1) bcrypt signing requires the client_secret in plaintext (must stay on server) // 2) Naver whitelists server IPs // 3) CORS not allowed for browser origins // So we build the payload here and the user posts via their own backend proxy. function buildNaverTokenPayload(clientId, timestamp = Date.now()) { return { method: "POST", url: `${NAVER_API_BASE}/external/v1/oauth2/token`, contentType: "application/x-www-form-urlencoded", formData: { client_id: clientId, timestamp: String(timestamp), grant_type: "client_credentials", type: "SELF", // client_secret_sign generated server-side: // base64( bcrypt( client_id + "_" + timestamp, client_secret ) ) client_secret_sign: "", }, note: "client_secret_sign은 서버에서 bcrypt(`${client_id}_${timestamp}`, client_secret)을 base64 인코딩하여 생성해야 합니다.", }; } // Map ISVM/local product → Naver Commerce product registration payload function buildNaverProductPayload(product, options = {}) { const { leafCategoryId = "50000000", // user must override deliveryCompany = "CJGLS", baseFee = 3000, afterServicePhone = "1588-0000", afterServiceGuide = "구매 후 7일 이내 미사용 제품 교환/환불 가능", sellerOriginCode = "0200037", } = options; return { originProduct: { statusType: "SALE", saleType: "NEW", leafCategoryId, name: product.name, detailContent: `
${(product.desc || product.name).replace(/[<>]/g, "")}
`, images: { representativeImage: { url: product.image_url || "" }, optionalImages: product.images || [], }, salePrice: product.salePrice ?? product.suggestedPrice ?? product.price, stockQuantity: product.stock || 0, deliveryInfo: { deliveryType: "DELIVERY", deliveryAttributeType: "NORMAL", deliveryCompany, deliveryBundleGroupUsable: false, deliveryFee: { deliveryFeeType: "PAID", baseFee, deliveryFeePayType: "COLLECT_OR_PREPAID", deliveryAreaType: "AREA_2", area2extraFee: 5000, area3extraFee: 10000, }, }, detailAttribute: { afterServiceInfo: { afterServiceTelephoneNumber: afterServicePhone, afterServiceGuideContent: afterServiceGuide, }, originAreaInfo: { originAreaCode: sellerOriginCode, importer: "", content: "", plural: false }, sellerCodeInfo: { sellerManagementCode: product.product_code || product.sku || "", sellerBarcode: "", }, purchaseQuantityInfo: { minPurchaseQuantity: 1, maxPurchaseQuantityPerId: 0, maxPurchaseQuantityPerOrder: 0, }, naverShoppingSearchInfo: { // modelName 자동 입력 안 함 — 항상 틀린 값이 들어가서 차라리 빈 값 modelName: "", brandName: product.brand || "", manufacturerName: product.brand || "", }, }, customerBenefit: {}, }, smartstoreChannelProduct: { channelProductName: product.name, naverShoppingRegistration: true, channelProductDisplayStatusType: "ON", }, }; } // --- Naver Commerce client (백엔드 프록시 경유) --- function _proxyBase() { const c = loadCreds(); return (c.proxy_url || "").replace(/\/+$/, ""); } function _proxyHeaders() { const c = loadCreds(); return { "X-Client-Id": c.naver_client_id || "", "X-Client-Secret": c.naver_client_secret || "", }; } function naverConfigured() { const c = loadCreds(); return Boolean(c.proxy_url && c.naver_client_id && c.naver_client_secret); } // 네이버 channelProduct 1개 → 내부 product 모델 function naverToInternalProduct(cp) { const isOutOfStock = cp.statusType === "OUTOFSTOCK" || (cp.stockQuantity || 0) === 0; const rootCategory = (cp.wholeCategoryName || "").split(">")[0] || "기타"; return { id: String(cp.originProductNo), originProductNo: cp.originProductNo, channelProductNo: cp.channelProductNo, name: cp.name || "", category: rootCategory, categoryFull: cp.wholeCategoryName || "", naverCategoryId: cp.categoryId || "", price: cp.salePrice || 0, discountedPrice: cp.discountedPrice || 0, stock: cp.stockQuantity || 0, status: isOutOfStock ? "outofstock" : "active", imageUrl: cp.representativeImage?.url || "", regDate: cp.regDate || "", modifiedDate: cp.modifiedDate || "", // 네이버 search 응답엔 없는 필드 (호환 위해 빈 값) costPrice: 0, sales30d: 0, views30d: 0, rating: 0, reviews: 0, sku: cp.channelProductNo ? String(cp.channelProductNo) : "", brand: "", desc: "", tone: (cp.originProductNo || 0) % 9, }; } function _flattenNaverProducts(naverResponse) { const out = []; for (const item of naverResponse.contents || []) { for (const cp of item.channelProducts || []) { out.push(naverToInternalProduct(cp)); } } return out; } async function naverFetchProducts({ page = 1, size = 100, searchKeyword, productStatusTypes } = {}) { if (!naverConfigured()) throw new Error("API 자격증명이 설정되지 않았습니다 (API 설정 메뉴에서 입력)"); const params = new URLSearchParams({ page: String(page), size: String(size) }); if (searchKeyword) params.set("searchKeyword", searchKeyword); if (productStatusTypes && productStatusTypes.length) params.set("productStatusTypes", productStatusTypes.join(",")); // 캐시 무효화: 브라우저가 동일 URL을 캐시하지 않도록 timestamp 추가 params.set("_ts", String(Date.now())); const url = `${_proxyBase()}/naver/products?${params.toString()}`; const res = await fetch(url, { headers: _proxyHeaders(), cache: "no-store" }); if (!res.ok) { const errText = await res.text().catch(() => ""); throw new Error(`네이버 상품 조회 실패 (HTTP ${res.status}) ${errText.slice(0, 200)}`); } const json = await res.json(); return { products: _flattenNaverProducts(json), page: json.page, size: json.size, totalElements: json.totalElements, totalPages: json.totalPages, raw: json, }; } // 원상품 전체 조회 (가격 수정 전 GET-then-PUT 패턴) async function naverFetchOriginProduct(originProductNo) { if (!naverConfigured()) throw new Error("API 자격증명이 설정되지 않았습니다"); const url = `${_proxyBase()}/naver/origin-products/${originProductNo}?_ts=${Date.now()}`; const res = await fetch(url, { headers: _proxyHeaders(), cache: "no-store" }); if (!res.ok) { const errText = await res.text().catch(() => ""); throw new Error(`원상품 조회 실패 (HTTP ${res.status}) ${errText.slice(0, 200)}`); } return res.json(); } // 원상품 전체 수정 (GET 결과를 받아 salePrice 등만 수정한 객체 그대로 전달) async function naverUpdateOriginProduct(originProductNo, fullPayload) { if (!naverConfigured()) throw new Error("API 자격증명이 설정되지 않았습니다"); const c = loadCreds(); const url = `${_proxyBase()}/naver/origin-products/${originProductNo}`; const res = await fetch(url, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ clientId: c.naver_client_id, clientSecret: c.naver_client_secret, payload: fullPayload, }), }); const json = await res.json(); if (!res.ok) { throw new Error(`원상품 수정 실패 (HTTP ${res.status}) ${JSON.stringify(json).slice(0, 300)}`); } return json; } // 가격만 변경하는 패턴: GET → salePrice 교체 → PUT async function naverChangePrice(originProductNo, newSalePrice) { return naverChangeProductPricing(originProductNo, { salePrice: newSalePrice }); } // 통합 상품 수정. changes의 키가 undefined면 변경 안 함. // changes = { salePrice?, immediateDiscount? (0=제거), stockQuantity?, name?, statusType? } async function naverUpdateProduct(originProductNo, changes) { const current = await naverFetchOriginProduct(originProductNo); if (!current.originProduct) throw new Error("응답에 originProduct가 없음"); const updated = JSON.parse(JSON.stringify(current)); if (changes.salePrice !== undefined) updated.originProduct.salePrice = changes.salePrice; if (changes.stockQuantity !== undefined) updated.originProduct.stockQuantity = changes.stockQuantity; if (changes.name !== undefined) { updated.originProduct.name = changes.name; // 전용상품명 비활성화 — 채널 전용 이름이 채워져 있으면 originProduct.name 바꿔도 채널 표시는 그대로. // 비워두면 originProduct.name이 모든 채널에 sync됨. (사용자 발견 — 전용상품명 Y가 sync 안 되는 원인) if (updated.smartstoreChannelProduct) { updated.smartstoreChannelProduct.channelProductName = ""; } if (updated.windowChannelProduct) { updated.windowChannelProduct.channelProductName = ""; } } if (changes.statusType !== undefined) updated.originProduct.statusType = changes.statusType; if (changes.detailContent !== undefined) updated.originProduct.detailContent = changes.detailContent; if (changes.immediateDiscount !== undefined) { if (!updated.originProduct.customerBenefit) updated.originProduct.customerBenefit = {}; if (changes.immediateDiscount > 0) { updated.originProduct.customerBenefit.immediateDiscountPolicy = { ...(updated.originProduct.customerBenefit.immediateDiscountPolicy || {}), discountMethod: { value: changes.immediateDiscount, unitType: "WON" }, }; } else { delete updated.originProduct.customerBenefit.immediateDiscountPolicy; } } const result = await naverUpdateOriginProduct(originProductNo, updated); return { changes, result }; } // 가격/즉시할인만 변경하는 래퍼 (기존 호환) async function naverChangeProductPricing(originProductNo, changes) { return naverUpdateProduct(originProductNo, changes); } // --- Naver Orders client --- function _fmtKST(d) { const kst = new Date(d.getTime() + 9 * 3600 * 1000); return kst.toISOString().replace("Z", "+09:00").replace(/\.\d{3}/, ".000"); } // 주문 상태 매핑 (네이버 → 내부) function naverOrderStatusToInternal(productOrderStatus, claimStatus) { if (claimStatus === "CANCEL_DONE" || claimStatus === "CANCEL_REQUEST") return "canceled"; if (claimStatus === "RETURN_DONE") return "returned"; if (claimStatus === "EXCHANGE_DONE") return "exchanged"; switch (productOrderStatus) { case "PAYMENT_WAITING": return "pending"; case "PAYED": return "paid"; case "DELIVERING": return "shipped"; case "DELIVERED": return "delivered"; case "PURCHASE_DECIDED": return "delivered"; case "CANCELED": return "canceled"; case "RETURNED": return "returned"; case "EXCHANGED": return "exchanged"; default: return (productOrderStatus || "unknown").toLowerCase(); } } // 네이버 주문 entry 1개 → 내부 order 모델 function naverToInternalOrder(entry) { const c = entry.content || {}; const order = c.order || {}; const po = c.productOrder || {}; const sa = po.shippingAddress || {}; return { id: po.productOrderId || entry.productOrderId, productOrderId: po.productOrderId, orderId: order.orderId, date: (order.paymentDate || order.orderDate || "").replace("T", " ").slice(0, 16), buyer: order.ordererName || "", phone: order.ordererTel || "", address: ((sa.baseAddress || "") + " " + (sa.detailedAddress || "")).trim(), zipcode: sa.zipCode || "", items: [{ pid: po.productId || "", qty: po.quantity || 0, name: po.productName || "", price: po.unitPrice || 0, option: po.productOption || po.optionName || "", optionCode: po.optionCode || "", optionPrice: po.optionPrice || 0, productClass: po.productClass || "", }], status: naverOrderStatusToInternal(po.productOrderStatus, po.claimStatus), memo: po.shippingMemo || "", rawProductOrderStatus: po.productOrderStatus || "", rawClaimStatus: po.claimStatus || "", deliveryCompany: po.expectedDeliveryCompany || "", shippingDueDate: po.shippingDueDate || "", totalPaymentAmount: po.totalPaymentAmount || 0, paymentMeans: order.paymentMeans || "", // 보내는분(판매자 발송지/반품교환지) 정보 — 로젠 송장 출력에 사용 sender: po.takingAddress ? { name: po.takingAddress.name || "", tel: po.takingAddress.tel1 || "", zipcode: po.takingAddress.zipCode || "", address: ((po.takingAddress.baseAddress || "") + " " + (po.takingAddress.detailedAddress || "")).trim(), } : null, }; } const _sleep = (ms) => new Promise(r => setTimeout(r, ms)); // 단일 24시간 윈도우 호출 (429 시 자동 재시도 backoff) async function _fetchOrders24h(fromDate, toDate, attempt = 0) { const headers = _proxyHeaders(); const path = "/external/v1/pay-order/seller/product-orders?from=" + encodeURIComponent(_fmtKST(fromDate)) + "&to=" + encodeURIComponent(_fmtKST(toDate)); // 주의: _raw_get은 _path 안에 쿼리(from/to)를 인코딩해 담음. URL 레벨에 _ts 같은 // 추가 쿼리 붙이면 서버가 그걸 네이버로 forward해서 "?from=...&to=...?_ts=..." 망가짐. // 캐시는 서버의 Cache-Control: no-store 헤더로 차단되므로 _ts 불필요. const url = _proxyBase() + "/naver/_raw_get?_path=" + encodeURIComponent(path); const res = await fetch(url, { headers, cache: "no-store" }); if (res.status === 429 && attempt < 3) { await _sleep(1000 * Math.pow(2, attempt)); return _fetchOrders24h(fromDate, toDate, attempt + 1); } if (!res.ok) { const t = await res.text().catch(() => ""); throw new Error("주문 조회 실패 (HTTP " + res.status + ") " + t.slice(0, 200)); } const json = await res.json(); return json.data?.contents || []; } // 슬라이딩 윈도우로 N일 데이터 수집 (24h x N 호출). 호출 간 250ms sleep으로 rate limit 회피. async function naverFetchOrders({ days = 7, onProgress } = {}) { if (!naverConfigured()) throw new Error("API 자격증명이 설정되지 않았습니다"); const all = []; const now = new Date(); for (let dayOffset = 0; dayOffset < days; dayOffset++) { if (dayOffset > 0) await _sleep(250); const to = new Date(now.getTime() - dayOffset * 24 * 3600 * 1000); const from = new Date(now.getTime() - (dayOffset + 1) * 24 * 3600 * 1000); try { const entries = await _fetchOrders24h(from, to); all.push(...entries.map(naverToInternalOrder)); } catch (err) { console.warn("[fetchOrders day " + dayOffset + "]", err.message); } onProgress && onProgress({ done: dayOffset + 1, total: days }); } all.sort((a, b) => (b.date || "").localeCompare(a.date || "")); return all; } // PC 파일 직접 업로드 (File 객체) → 네이버 URL 반환 async function naverUploadImageFile(file) { if (!naverConfigured()) throw new Error("API 자격증명이 설정되지 않았습니다"); const c = loadCreds(); const form = new FormData(); form.append("image", file); const res = await fetch(_proxyBase() + "/naver/upload-image-file", { method: "POST", headers: { "X-Client-Id": c.naver_client_id, "X-Client-Secret": c.naver_client_secret, }, body: form, }); const json = await res.json(); if (!res.ok) throw new Error("파일 업로드 실패 (HTTP " + res.status + ") " + JSON.stringify(json).slice(0, 300)); const url = json.images?.[0]?.url || json.imageUrl || json.url; if (!url) throw new Error("이미지 응답에서 URL을 찾을 수 없음: " + JSON.stringify(json).slice(0, 200)); return url; } // 네이버 SmartEditor 형식 HTML 생성 (이미지 컴포넌트들) function buildSmartEditorImagesHtml(naverImageUrls) { if (!naverImageUrls || naverImageUrls.length === 0) return ""; const blocks = naverImageUrls.map(url => ({ type: "image", url })); return buildSmartEditorHtml(blocks); } // HTML escape (텍스트 블록 직렬화용) function _escHtml(s) { return String(s == null ? "" : s) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } // 블록 배열 → SmartEditor HTML. // blocks = [{ type:'image', url }, { type:'text', text }, { type:'html', html }] // 이미지 URL은 호출 전에 모두 네이버 URL로 업로드 완료된 상태여야 함. function buildSmartEditorHtml(blocks) { if (!blocks || blocks.length === 0) return ""; const ts = Date.now(); const parts = blocks.map((b, i) => { const id = "SE-" + ts + "-" + i; if (b.type === "image" && b.url) { return '
' + '
' + '
' + '
'; } if (b.type === "text") { const raw = b.text == null ? "" : String(b.text); // 줄바꿈을

단락으로 분리. 빈 줄은  로 보존. const paragraphs = raw.split(/\r?\n/).map(line => { const t = _escHtml(line); const safe = t.length === 0 ? " " : t; return '

' + safe + '

'; }).join(""); return '
' + '
' + '
' + '
' + paragraphs + '
'; } if (b.type === "html" && b.html) { // 원본 HTML 보존 (래퍼 없이 그대로 삽입) return b.html; } return ""; }).filter(Boolean); return '
' + parts.join("") + '
'; } // SmartEditor HTML → 블록 배열. // 알 수 없는 컴포넌트나 비-SE HTML은 html 블록으로 보존하여 손실 없이 round-trip. function parseSmartEditorHtml(html) { if (!html || !String(html).trim()) return []; let doc; try { doc = new DOMParser().parseFromString(html, "text/html"); } catch { return [{ type: "html", html }]; } const root = doc.querySelector(".se-main-container") || doc.body; if (!root) return [{ type: "html", html }]; // se-main-container가 없으면 일반 HTML로 간주 — html 블록 하나로 보존 if (!doc.querySelector(".se-main-container")) { const stripped = (doc.body.innerHTML || "").trim(); if (!stripped) return []; return [{ type: "html", html: stripped }]; } const out = []; let idSeed = 1; const nextId = () => "blk-" + Date.now() + "-" + (idSeed++); for (const node of Array.from(root.children)) { if (!(node instanceof Element)) continue; const cls = node.className || ""; if (cls.indexOf("se-image") !== -1) { const img = node.querySelector("img"); const url = img?.getAttribute("src") || ""; if (url) { const isNaver = /(shop-phinf\.pstatic\.net|phinf\.pstatic\.net|naver\.com)/.test(url); out.push({ id: nextId(), type: "image", url, source: isNaver ? "naver" : "external" }); } continue; } if (cls.indexOf("se-text") !== -1) { // 각

단락의 텍스트만 추출,   → 공백 const ps = node.querySelectorAll("p, div.se-text-paragraph"); const seen = ps.length > 0 ? Array.from(ps) : [node]; const lines = seen.map(p => { const raw = (p.textContent || "").replace(/ /g, " "); return raw.trim() === "" ? "" : raw.replace(/\s+$/g, ""); }); const text = lines.join("\n").replace(/\n+$/g, ""); out.push({ id: nextId(), type: "text", text }); continue; } // 알 수 없는 SE 컴포넌트(동영상, 표 등) → 원본 HTML 보존 out.push({ id: nextId(), type: "html", html: node.outerHTML }); } return out; } // 외부 이미지 URL → 네이버 자체 서버에 업로드 → 네이버 URL 반환 async function naverUploadImageFromUrl(externalUrl) { if (!naverConfigured()) throw new Error("API 자격증명이 설정되지 않았습니다"); const c = loadCreds(); const res = await fetch(_proxyBase() + "/naver/upload-image-from-url", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ clientId: c.naver_client_id, clientSecret: c.naver_client_secret, imageUrl: externalUrl }), }); const json = await res.json(); if (!res.ok) throw new Error("이미지 업로드 실패 (HTTP " + res.status + ") " + JSON.stringify(json).slice(0, 300)); // 네이버 응답 구조 추측: { images: [{ url: "..." }] } const url = json.images?.[0]?.url || json.imageUrl || json.url; if (!url) throw new Error("이미지 응답에서 URL을 찾을 수 없음: " + JSON.stringify(json).slice(0, 200)); return url; } // 신규 상품 등록 — payload는 GET origin-product와 동일 구조 async function naverRegisterProduct(payload) { if (!naverConfigured()) throw new Error("API 자격증명이 설정되지 않았습니다"); const c = loadCreds(); const res = await fetch(_proxyBase() + "/naver/products", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ clientId: c.naver_client_id, clientSecret: c.naver_client_secret, payload }), }); const json = await res.json(); if (!res.ok) throw new Error("등록 실패 (HTTP " + res.status + ") " + JSON.stringify(json).slice(0, 400)); return json; } // Template 기반 등록 — 기준상품에서는 카테고리 코드 등 최소한만 복사. // 배송/A&S/상세설명/SEO/콘텐츠 게시글 번호는 template에서 가져오지 않음 (사용자 요구). // templateOriginProductNo: 사용자 스토어의 기존 상품 번호 (카테고리 구조 참조용) // overrides: { name, salePrice, stockQuantity, imageUrl?, detailContent?, sellerCode?, leafCategoryId? } async function naverRegisterFromTemplate(templateOriginProductNo, overrides) { const tpl = await naverFetchOriginProduct(templateOriginProductNo); if (!tpl.originProduct) throw new Error("template 응답에 originProduct가 없음"); const payload = JSON.parse(JSON.stringify(tpl)); delete payload.originProductNo; delete payload.smartstoreChannelProductNo; if (overrides.name !== undefined) payload.originProduct.name = overrides.name; if (overrides.salePrice !== undefined) payload.originProduct.salePrice = overrides.salePrice; if (overrides.stockQuantity !== undefined) payload.originProduct.stockQuantity = overrides.stockQuantity; if (overrides.leafCategoryId !== undefined) payload.originProduct.leafCategoryId = overrides.leafCategoryId; // modelName 자동 등록 제거 — 항상 틀린 값이 들어가서 차라리 빈 값으로 if (payload.originProduct.detailAttribute?.naverShoppingSearchInfo) { payload.originProduct.detailAttribute.naverShoppingSearchInfo.modelName = ""; } // === 배송 정보: 정책 필드만 우리 설정값으로 덮어쓰기. === // 택배사 코드는 template의 값 유지 (사용자 매장에서 이미 검증된 코드 — 네이버 enum 추측 안 함). // 50,000원 이상 무료 + 도서/산간 추가요금만 강제. const c = loadCreds(); const baseFee = (c.naver_base_fee != null && c.naver_base_fee !== "") ? parseInt(c.naver_base_fee) : 3000; const freeAmount = (c.naver_free_amount != null && c.naver_free_amount !== "") ? parseInt(c.naver_free_amount) : 50000; const remoteFee = (c.naver_remote_fee != null && c.naver_remote_fee !== "") ? parseInt(c.naver_remote_fee) : 5000; payload.originProduct.deliveryInfo = payload.originProduct.deliveryInfo || {}; // 배송비 정책만 우리 값으로 교체 (deliveryCompany 등 코드 필드는 template 유지) payload.originProduct.deliveryInfo.deliveryFee = { ...(payload.originProduct.deliveryInfo.deliveryFee || {}), deliveryFeeType: "CONDITIONAL_FREE", // 조건부 무료 baseFee, freeConditionalAmount: freeAmount, // 이 금액 이상 무료 deliveryFeePayType: "PREPAID", deliveryAreaType: "AREA_3", area2extraFee: remoteFee, area3extraFee: remoteFee, }; // === A&S 정보: template에서 안 가져옴. 설정 기본값으로 강제 === const asPhone = c.naver_as_phone || "1588-0000"; payload.originProduct.detailAttribute = payload.originProduct.detailAttribute || {}; payload.originProduct.detailAttribute.afterServiceInfo = { afterServiceTelephoneNumber: asPhone, afterServiceGuideContent: "구매 후 7일 이내 미사용 제품 교환/환불 가능합니다. 자세한 사항은 고객센터로 문의해 주세요.", }; // === 상세설명/SEO/게시글: template 누설 차단 === // SEO 정보 전체를 minimal로 재생성 — template의 pageTitle/sellerTags 절대 안 남게. // (taxType은 보존 — 필수 필드일 가능성) const newName = overrides.name !== undefined ? overrides.name : (payload.originProduct.name || ""); const tplTaxType = payload.originProduct.seoInfo?.taxType; payload.originProduct.seoInfo = { pageTitle: newName.slice(0, 100), metaDescription: "", sellerTags: [], }; if (tplTaxType) payload.originProduct.seoInfo.taxType = tplTaxType; if (overrides.imageUrl !== undefined || overrides.optionalImageUrls !== undefined) { // 메인 이미지 처리 let repUrl = overrides.imageUrl; if (repUrl && !/(shop-phinf\.pstatic\.net|phinf\.pstatic\.net|naver\.com)/.test(repUrl)) { repUrl = await naverUploadImageFromUrl(repUrl); } // 추가 이미지들 순차 업로드 (실패한 건 건너뛰고 진행) const optionalImages = []; if (overrides.optionalImageUrls && Array.isArray(overrides.optionalImageUrls)) { // 네이버 optionalImages 한도(보통 9개) const MAX_OPTIONAL = 9; const toUpload = overrides.optionalImageUrls.slice(0, MAX_OPTIONAL); for (const ext of toUpload) { try { let u = ext; if (u && !/(shop-phinf\.pstatic\.net|phinf\.pstatic\.net|naver\.com)/.test(u)) { u = await naverUploadImageFromUrl(u); } optionalImages.push({ url: u }); } catch (err) { console.warn("[추가 이미지 업로드 실패]", ext, err.message); } } } payload.originProduct.images = { representativeImage: repUrl ? { url: repUrl } : payload.originProduct.images?.representativeImage, optionalImages, }; } // 상세설명: override 있으면 그걸로. 없거나 빈 string이어도 template 게 안 남도록 명시적 빈 SmartEditor wrapper. if (overrides.detailContent !== undefined && overrides.detailContent.trim()) { payload.originProduct.detailContent = overrides.detailContent; } else { payload.originProduct.detailContent = '

'; } if (overrides.sellerCode !== undefined) { payload.originProduct.detailAttribute = payload.originProduct.detailAttribute || {}; payload.originProduct.detailAttribute.sellerCodeInfo = payload.originProduct.detailAttribute.sellerCodeInfo || {}; payload.originProduct.detailAttribute.sellerCodeInfo.sellerManagementCode = overrides.sellerCode; } // 신규 등록 시 즉시할인 제거 (사용자가 별도로 설정해야) if (payload.originProduct.customerBenefit?.immediateDiscountPolicy) { delete payload.originProduct.customerBenefit.immediateDiscountPolicy; } // Discussion #3025 공식 답변: 공지사항 연동 안 하면 bbsSeq 키 자체를 빼야 함. // (bbsSeq: 0은 "일련번호 0인 공지사항 등록"이라는 invalid 의미) if (payload.smartstoreChannelProduct) { payload.smartstoreChannelProduct = { naverShoppingRegistration: true, channelProductDisplayStatusType: "ON", channelProductName: "", storeKeepExclusiveProduct: false, // bbsSeq 의도적으로 빠짐 — 공지사항 연동 안 함 }; } // 윈도쇼핑 안 씀 → 통째 삭제 delete payload.windowChannelProduct; // smartstoreGroupChannel(매장 그룹)이 있으면 빈 객체로 (공지 그룹 미연결) if (payload.smartstoreGroupChannel) { payload.smartstoreGroupChannel = {}; } // 방어적: payload 어디든지 contentsBBS / channelNo / bbsSeq 필드 재귀 제거. // (template에 남아있는 공지사항/채널 참조 누설 차단) const stripFields = (obj, fields) => { if (!obj || typeof obj !== "object") return; if (Array.isArray(obj)) { obj.forEach(o => stripFields(o, fields)); return; } for (const f of fields) if (f in obj) delete obj[f]; for (const k of Object.keys(obj)) stripFields(obj[k], fields); }; stripFields(payload, ["contentsBBS", "channelNo", "bbsSeq"]); return naverRegisterProduct(payload); } // === 로젠택배 Open API ============================================ // 작동 흐름: 주문 등록 → 송장번호 채번 → 출력 팝업 열기 // 자격증명: localStorage의 logen_user_id (8자), logen_cust_cd (8자), logen_env ("prod"|"dev") function logenConfigured() { const c = loadCreds(); return Boolean(c.logen_user_id && c.logen_cust_cd && c.proxy_url); } function _logenCreds() { const c = loadCreds(); return { userId: c.logen_user_id || "", custCd: c.logen_cust_cd || "", env: c.logen_env || "prod", }; } // 네이버 주문 → 로젠 주문 등록 payload row 변환. // fareTy: 010(선불)/020(착불)/030(신용)/040(기타). 기본 010 선불. function _orderToLogenRow(order, { custCd, takeDt, fareTy = "010" }) { const sender = order.sender || {}; const items = order.items || []; const firstItem = items[0] || {}; const itemName = items.map(it => it.name + (it.option ? `(${it.option})` : "") + `×${it.qty}`).join(" / ").slice(0, 100); const totalQty = items.reduce((s, it) => s + (it.qty || 1), 0); // 보내는사람 정보가 없으면 빈 값. takingAddress 없는 mock 주문 대비. return { custCd, takeDt, fareTy, sndCustNm: (sender.name || "").slice(0, 50), sndCustAddr: (sender.address || "").slice(0, 1000), sndTelNo: (sender.tel || "").slice(0, 20), sndCellNo: (sender.tel || "").slice(0, 20), rcvCustNm: (order.buyer || "").slice(0, 50), rcvCustAddr: (order.address || "").slice(0, 1000), rcvTelNo: (order.phone || "").slice(0, 20), rcvCellNo: (order.phone || "").slice(0, 20), qty: String(totalQty || 1), dlvFare: "0", // 운임 (선불일 때 0이면 무료배송 또는 기등록) goodsNm: itemName || (firstItem.name || "").slice(0, 100), remark: (order.memo || "").slice(0, 200), // 사용자 주문번호를 우리 시스템 ID로 추적 custOrderNo: (order.productOrderId || order.id || "").slice(0, 100), }; } // 주문 등록. orders는 우리 내부 모델 배열. async function logenRegisterOrders(orders, opts = {}) { if (!logenConfigured()) throw new Error("로젠 자격증명이 설정되지 않았습니다"); const { userId, custCd, env } = _logenCreds(); const today = new Date(); const takeDt = opts.takeDt || (today.getFullYear() + String(today.getMonth() + 1).padStart(2, "0") + String(today.getDate()).padStart(2, "0")); const fareTy = opts.fareTy || "010"; const data = orders.map(o => _orderToLogenRow(o, { custCd, takeDt, fareTy })); const res = await fetch(_proxyBase() + "/logen/register-orders", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ env, payload: { userId, data } }), }); const json = await res.json().catch(() => ({})); if (!res.ok) throw new Error("로젠 주문 등록 실패 (HTTP " + res.status + ") " + JSON.stringify(json).slice(0, 300)); return { takeDt, response: json, perOrder: json.data || [] }; } // 송장번호 채번. qty 개수만큼 발급. async function logenGetSlipNo(qty) { if (!logenConfigured()) throw new Error("로젠 자격증명이 설정되지 않았습니다"); const { userId, env } = _logenCreds(); const res = await fetch(_proxyBase() + "/logen/get-slip-no", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ env, payload: { userId, slipQty: qty } }), }); const json = await res.json().catch(() => ({})); if (!res.ok) throw new Error("로젠 채번 실패 (HTTP " + res.status + ") " + JSON.stringify(json).slice(0, 300)); return json; } // 출력 팝업 URL 받기. 응답 JSON에 url이 있을 가능성 (실제 응답 구조 검증 필요). async function logenGetPrintPopupUrl(takeDt) { if (!logenConfigured()) throw new Error("로젠 자격증명이 설정되지 않았습니다"); const { userId, custCd, env } = _logenCreds(); const params = new URLSearchParams({ env, userId, custCd, takeDt }); const res = await fetch(_proxyBase() + "/logen/print-popup-url?" + params.toString()); const json = await res.json().catch(() => ({})); if (!res.ok) throw new Error("로젠 출력 팝업 URL 실패 (HTTP " + res.status + ") " + JSON.stringify(json).slice(0, 300)); return json; } // 등록된 주문의 송장번호 일괄 조회. // items: [{ fixTakeNo }, ...] async function logenInquirySlipNo(items) { if (!logenConfigured()) throw new Error("로젠 자격증명이 설정되지 않았습니다"); const { userId, custCd, env } = _logenCreds(); const data = items.map(it => ({ custCd, fixTakeNo: it.fixTakeNo })); const res = await fetch(_proxyBase() + "/logen/inquiry-slip-no", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ env, payload: { userId, data } }), }); const json = await res.json().catch(() => ({})); if (!res.ok) throw new Error("로젠 송장번호 조회 실패 (HTTP " + res.status + ") " + JSON.stringify(json).slice(0, 300)); return json; } // === 문의 (Q&A) 및 리뷰 ============================================ // 서버 라우트가 path 후보 여러 개를 자동 시도함. 응답 헤더 X-Naver-Path-Used로 어느 path가 먹는지 확인 가능. async function naverFetchQA({ page = 1, size = 20, answered, from, to } = {}) { if (!naverConfigured()) throw new Error("API 자격증명이 설정되지 않았습니다"); const params = new URLSearchParams({ page: String(page), size: String(size) }); if (answered !== undefined) params.set("answered", String(answered)); if (from) params.set("from", from); if (to) params.set("to", to); params.set("_ts", String(Date.now())); const res = await fetch(_proxyBase() + "/naver/qa?" + params.toString(), { headers: _proxyHeaders(), cache: "no-store", }); if (!res.ok) { const t = await res.text().catch(() => ""); throw new Error("문의 조회 실패 (HTTP " + res.status + ") " + t.slice(0, 300)); } const json = await res.json(); // 네이버 응답 구조 추측: { contents: [...], page, size, totalElements, totalPages } return { items: json.contents || json.items || [], page: json.page || page, size: json.size || size, totalElements: json.totalElements || 0, totalPages: json.totalPages || 1, pathUsed: res.headers.get("X-Naver-Path-Used"), raw: json, }; } async function naverAnswerQA(qaId, content) { if (!naverConfigured()) throw new Error("API 자격증명이 설정되지 않았습니다"); if (!content || !content.trim()) throw new Error("답변 내용을 입력하세요"); const c = loadCreds(); const res = await fetch(_proxyBase() + "/naver/qa/" + encodeURIComponent(qaId) + "/answer", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ clientId: c.naver_client_id, clientSecret: c.naver_client_secret, content }), }); const json = await res.json().catch(() => ({})); if (!res.ok) { throw new Error("문의 답변 실패 (HTTP " + res.status + ") " + JSON.stringify(json).slice(0, 300)); } return { ...json, pathUsed: res.headers.get("X-Naver-Path-Used") }; } async function naverFetchReviews({ page = 1, size = 20, hasComment, from, to } = {}) { if (!naverConfigured()) throw new Error("API 자격증명이 설정되지 않았습니다"); const params = new URLSearchParams({ page: String(page), size: String(size) }); if (hasComment !== undefined) params.set("hasComment", String(hasComment)); if (from) params.set("from", from); if (to) params.set("to", to); params.set("_ts", String(Date.now())); const res = await fetch(_proxyBase() + "/naver/reviews?" + params.toString(), { headers: _proxyHeaders(), cache: "no-store", }); if (!res.ok) { const t = await res.text().catch(() => ""); throw new Error("리뷰 조회 실패 (HTTP " + res.status + ") " + t.slice(0, 300)); } const json = await res.json(); return { items: json.contents || json.items || [], page: json.page || page, size: json.size || size, totalElements: json.totalElements || 0, totalPages: json.totalPages || 1, pathUsed: res.headers.get("X-Naver-Path-Used"), raw: json, }; } async function naverCommentReview(reviewId, content) { if (!naverConfigured()) throw new Error("API 자격증명이 설정되지 않았습니다"); if (!content || !content.trim()) throw new Error("답글 내용을 입력하세요"); const c = loadCreds(); const res = await fetch(_proxyBase() + "/naver/reviews/" + encodeURIComponent(reviewId) + "/comment", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ clientId: c.naver_client_id, clientSecret: c.naver_client_secret, content }), }); const json = await res.json().catch(() => ({})); if (!res.ok) { throw new Error("리뷰 답글 실패 (HTTP " + res.status + ") " + JSON.stringify(json).slice(0, 300)); } return { ...json, pathUsed: res.headers.get("X-Naver-Path-Used") }; } // 발송 처리 — 송장번호 등록 // list = [{ productOrderId, trackingNumber, deliveryCompanyCode?, deliveryMethod?, dispatchDate? }] async function naverDispatchOrders(list) { if (!naverConfigured()) throw new Error("API 자격증명이 설정되지 않았습니다"); if (!list || list.length === 0) throw new Error("발송 처리할 주문이 없습니다"); const c = loadCreds(); const today = _fmtKST(new Date()); const payload = { dispatchProductOrders: list.map(d => ({ productOrderId: d.productOrderId, deliveryMethod: d.deliveryMethod || "DELIVERY", deliveryCompanyCode: d.deliveryCompanyCode || "LOGEN", trackingNumber: d.trackingNumber, dispatchDate: d.dispatchDate || today, })), }; const res = await fetch(_proxyBase() + "/naver/orders/dispatch", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ clientId: c.naver_client_id, clientSecret: c.naver_client_secret, payload }), }); const json = await res.json(); if (!res.ok) { throw new Error("발송 처리 실패 (HTTP " + res.status + ") " + JSON.stringify(json).slice(0, 300)); } return json; } window.API = { ISVM_BASE, NAVER_API_BASE, loadCreds, saveCreds, clearCreds, isvmHealth, isvmSearch, isvmAddProducts, isvmFetchProductImages, buildNaverTokenPayload, buildNaverProductPayload, naverConfigured, naverFetchProducts, naverToInternalProduct, naverFetchOriginProduct, naverUpdateOriginProduct, naverChangePrice, naverChangeProductPricing, naverUpdateProduct, naverFetchOrders, naverToInternalOrder, naverOrderStatusToInternal, naverDispatchOrders, naverRegisterProduct, naverRegisterFromTemplate, naverUploadImageFromUrl, naverUploadImageFile, buildSmartEditorImagesHtml, buildSmartEditorHtml, parseSmartEditorHtml, naverFetchQA, naverAnswerQA, naverFetchReviews, naverCommentReview, logenConfigured, logenRegisterOrders, logenGetSlipNo, logenGetPrintPopupUrl, logenInquirySlipNo, };