// === 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 || []; } // 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: product.name.slice(0, 30), 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(",")); const url = `${_proxyBase()}/naver/products?${params.toString()}`; const res = await fetch(url, { headers: _proxyHeaders() }); 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}`; const res = await fetch(url, { headers: _proxyHeaders() }); 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?: number, immediateDiscount?: number (0이면 할인 없음) } async function naverChangeProductPricing(originProductNo, changes) { const current = await naverFetchOriginProduct(originProductNo); if (!current.originProduct) throw new Error("응답에 originProduct가 없음"); const updated = JSON.parse(JSON.stringify(current)); // deep copy if (changes.salePrice !== undefined) { updated.originProduct.salePrice = changes.salePrice; } 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 { // 0원: 정책 제거 (할인 안 함) delete updated.originProduct.customerBenefit.immediateDiscountPolicy; } } const result = await naverUpdateOriginProduct(originProductNo, updated); return { changes, result }; } window.API = { ISVM_BASE, NAVER_API_BASE, loadCreds, saveCreds, clearCreds, isvmHealth, isvmSearch, isvmAddProducts, buildNaverTokenPayload, buildNaverProductPayload, naverConfigured, naverFetchProducts, naverToInternalProduct, naverFetchOriginProduct, naverUpdateOriginProduct, naverChangePrice, naverChangeProductPricing, };