Preserve Daiso pickup answers when Bearer auth degrades

Keep exact stock lookup on the official Bearer-token path while restoring the public selPkupStr fallback for repeated auth blocks.

Constraint: PR #250 review required Bearer auth to remain primary without removing the resilient pickup eligibility API.

Rejected: Throwing after the retry | it collapses callers back to a brittle single upstream-auth dependency.

Confidence: high

Scope-risk: narrow

Directive: Keep pickupStock quantity semantics separate from pickupEligibility yes/no fallback.

Tested: node --test packages/daiso-product-search/test/index.test.js; npm test --workspace daiso-product-search; npm run lint --workspace daiso-product-search; npm run ci; live lookupStoreProductAvailability smoke for 강남역2호점 / VT 리들샷 100.

Not-tested: Live forced 403 from Daiso upstream; covered with injected fetch regression tests.
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-05-15 16:04:31 +09:00
commit d7263a54b9
4 changed files with 337 additions and 17 deletions

View file

@ -2,4 +2,4 @@
"daiso-product-search": minor
---
Restore actionable Daiso pickup answers when store pickup stock is blocked by adding a `selPkupStr`-backed `getStorePickupEligibility()` helper plus `pickupEligibility` field on `lookupStoreProductAvailability()`. When pickup stock returns `Unauthorized`, the package now reports whether the selected store is registered as a pickup-capable store for the product instead of only saying "unknown".
Restore Daiso store pickup stock quantities through the official non-login Bearer flow (`/api/auth/request` + AES-128-CBC token) while keeping the resilient `selPkupStr` fallback API. `getStorePickupStock()` now retries once with a fresh token on 401/403 and returns structured `retrievalStatus: "blocked"` after repeated auth blocks instead of throwing. `getStorePickupEligibility()` remains public, and `lookupStoreProductAvailability()` fills `pickupEligibility` when exact pickup stock remains unavailable.

View file

@ -20,7 +20,7 @@ npm install
- 매장명과 상품명 둘 다 필요합니다.
- 공식 다이소몰 표면을 우선 사용합니다.
- `selStrPkupStck` 는 Bearer 토큰 인증이 필요합니다. `/api/auth/request` 로 비로그인 JWT를 받아 AES-128-CBC / 키 `"PRE_AUTH_ENC_KEY"` 로 암호화한 뒤 Bearer 헤더로 전달합니다. 403 응답 시 토큰을 재발급해 1회 재시도합니다.
- `selStrPkupStck` 는 Bearer 토큰 인증이 필요합니다. `/api/auth/request` 로 비로그인 JWT를 받아 AES-128-CBC / 키 `"PRE_AUTH_ENC_KEY"` 로 암호화한 뒤 Bearer 헤더로 전달합니다. 401/403 응답 시 토큰을 재발급해 1회 재시도합니다. 그래도 인증이 막히면 수량 조회는 `retrievalStatus: "blocked"` 로 반환하고 `selPkupStr` 픽업 가능 여부 폴백을 사용할 수 있습니다.
- 매장 픽업 재고의 `status` 는 조회 결과 범주입니다. 실제 재고 여부는 `inStock` 또는 `inventoryStatus` (`"in_stock"`, `"out_of_stock"`, `"unknown"`) 를 기준으로 판단합니다.
- 공식 표면이 매장 내 진열 위치를 주지 않으면 재고 중심으로 응답해야 합니다.
@ -39,6 +39,7 @@ async function main() {
console.log(result.selectedStore)
console.log(result.selectedProduct)
console.log(result.pickupStock)
console.log(result.pickupEligibility)
console.log(result.onlineStock)
}
@ -82,10 +83,15 @@ main().catch((error) => {
- `searchProducts(query, options?)`
- 반환되는 각 상품 후보는 `pdNo` 와 함께 `onldPdNo` 를 포함할 수 있습니다. 다이소몰 온라인 재고 표면이 별도 마스터 상품 번호를 요구하는 경우 이 값을 그대로 `getOnlineStock()` 에 넘기면 됩니다.
- `getStorePickupStock({ pdNo, strCd }, options?)`
- 호출 전 `/api/auth/request` 로 Bearer 토큰을 자동 빌드합니다. 403 응답 시 토큰을 재발급해 1회 재시도합니다.
- 호출 전 `/api/auth/request` 로 Bearer 토큰을 자동 빌드합니다. 401/403 응답 시 토큰을 재발급해 1회 재시도합니다.
- 성공한 조회는 `status: "available"`, `retrievalStatus: "resolved"` 를 포함합니다. 여기서 `status` 는 조회 성공 범주이며 상품 재고 여부가 아닙니다.
- 실제 재고 여부는 `inStock` 또는 `inventoryStatus` 로 확인합니다. 수량이 0이면 `status: "available"` 이면서 `inventoryStatus: "out_of_stock"` 일 수 있습니다.
- 인증이 계속 막히면 예외 대신 `status: "unavailable"`, `retrievalStatus: "blocked"`, `inventoryStatus: "unknown"` 를 반환합니다.
- `getStorePickupEligibility({ pdNo, strCd, storeName?, keyword?, pageSize? }, options?)`
- `selPkupStr` 로 특정 상품의 픽업 가능 매장 목록을 조회해 선택 매장이 픽업 가능 매장인지 확인합니다.
- 수량은 제공하지 않으며 `pickupEligible` (`true`/`false`/`null`) 과 `retrievalStatus` (`"resolved"`, `"blocked"`, `"insufficient_coverage"`) 로 폴백 판단을 전달합니다.
- `getOnlineStock({ pdNo, onldPdNo? }, options?)`
- 반환값은 `referenceOnly: true` 를 포함합니다. 온라인 재고는 다이소몰 온라인몰 재고 참고값이며 특정 매장의 픽업/진열 재고가 아닙니다.
- `lookupStoreProductAvailability({ storeQuery, productQuery, ...options })`
- 매장·상품 검색 → Bearer 인증 → 픽업 재고 조회를 한 번에 처리합니다.
- 픽업 재고 인증이 계속 막혀 `pickupStock.retrievalStatus === "blocked"` 이면 `pickupEligibility``selPkupStr` 기반 픽업 가능 여부를 채웁니다. 필요 없으면 `includePickupEligibility: false` 를 전달합니다.

View file

@ -4,6 +4,7 @@ const {
BASE_SEARCH_URL,
buildSearchGoodsParams,
normalizeOnlineStockResponse,
normalizePickupEligibilityResponse,
normalizeProductIdentifier,
normalizeSearchGoodsResponse,
normalizeStorePickupStockResponse,
@ -104,6 +105,22 @@ async function buildBearerToken(options = {}) {
return { bearer, uid }
}
function isAuthBlockedError(error) {
return error instanceof DaisoRequestError && (error.status === 401 || error.status === 403)
}
function normalizeAuthBlockedStock(request, error) {
return normalizeStorePickupStockResponse(
{
success: false,
message: "Unauthorized",
status: error && error.status,
upstreamPayload: error && error.payload ? error.payload : null
},
request
)
}
async function searchStores(query, options = {}) {
const body = {
keyword: String(query || "").trim(),
@ -144,28 +161,33 @@ async function searchProducts(query, options = {}) {
}
async function getStorePickupStock(request, options = {}) {
const body = [{ pdNo: String(request.pdNo), strCd: String(request.strCd) }]
async function requestStockWithFreshToken() {
const { bearer, uid } = await buildBearerToken(options)
const authHeaders = { Authorization: `Bearer ${bearer}`, "X-DM-UID": uid }
const payload = await requestJson(`${BASE_API_URL}/pd/pdh/selStrPkupStck`, {
...options,
method: "POST",
headers: { Authorization: `Bearer ${bearer}`, "X-DM-UID": uid },
body
})
return normalizeStorePickupStockResponse(payload, request)
}
try {
const payload = await requestJson(`${BASE_API_URL}/pd/pdh/selStrPkupStck`, {
...options,
method: "POST",
headers: authHeaders,
body: [{ pdNo: String(request.pdNo), strCd: String(request.strCd) }]
})
return normalizeStorePickupStockResponse(payload, request)
return await requestStockWithFreshToken()
} catch (error) {
if (error instanceof DaisoRequestError && error.status === 403) {
const { bearer: newBearer, uid: newUid } = await buildBearerToken(options)
const payload = await requestJson(`${BASE_API_URL}/pd/pdh/selStrPkupStck`, {
...options,
method: "POST",
headers: { Authorization: `Bearer ${newBearer}`, "X-DM-UID": newUid },
body: [{ pdNo: String(request.pdNo), strCd: String(request.strCd) }]
})
return normalizeStorePickupStockResponse(payload, request)
if (!isAuthBlockedError(error)) {
throw error
}
}
try {
return await requestStockWithFreshToken()
} catch (error) {
if (isAuthBlockedError(error)) {
return normalizeAuthBlockedStock(request, error)
}
throw error
@ -186,6 +208,73 @@ async function getOnlineStock(request, options = {}) {
return normalizeOnlineStockResponse(payload, normalizedRequest)
}
function buildPickupEligibilityKeyword(value) {
return String(value || "")
.replace(/\d+\s*호점\s*$/u, "")
.replace(/[(].*?[)]/gu, " ")
.replace(/\s+/gu, " ")
.trim()
}
async function getStorePickupEligibility(request, options = {}) {
const pdNo = String(request.pdNo || "").trim()
const strCd = String(request.strCd || "").trim()
const explicitKeyword =
typeof request.keyword === "string" && request.keyword.trim() ? request.keyword.trim() : null
const derivedKeyword = explicitKeyword || buildPickupEligibilityKeyword(request.storeName)
const pageSize = Number(request.pageSize || 50)
if (!pdNo) {
throw new Error("pdNo is required.")
}
if (strCd && !derivedKeyword) {
return {
pdNo,
strCd,
pickupEligible: null,
eligibleStoreCount: null,
eligibleStores: [],
matchedStore: null,
searchedKeyword: "",
pageSize,
totalCount: null,
retrievalStatus: "insufficient_coverage",
reason: "missing_search_keyword",
raw: null
}
}
try {
const payload = await requestJson(`${BASE_API_URL}/ms/msg/selPkupStr`, {
...options,
method: "POST",
body: {
pdNo,
keyword: derivedKeyword || "",
currentPage: 1,
pageSize
}
})
return normalizePickupEligibilityResponse(payload, {
pdNo,
strCd,
keyword: derivedKeyword || "",
pageSize
})
} catch (error) {
if (error instanceof DaisoRequestError) {
return normalizePickupEligibilityResponse(
error.payload || { success: false, message: `HTTP ${error.status}` },
{ pdNo, strCd, keyword: derivedKeyword || "", pageSize }
)
}
throw error
}
}
async function lookupStoreProductAvailability(options = {}) {
const storeQuery = String(options.storeQuery || "").trim()
const productQuery = String(options.productQuery || "").trim()
@ -228,6 +317,23 @@ async function lookupStoreProductAvailability(options = {}) {
getStorePickupStock({ pdNo: selectedProduct.pdNo, strCd: selectedStore.strCd }, options)
])
let pickupEligibility = null
if (
options.includePickupEligibility !== false &&
pickupStock &&
pickupStock.retrievalStatus === "blocked"
) {
pickupEligibility = await getStorePickupEligibility(
{
pdNo: selectedProduct.pdNo,
strCd: selectedStore.strCd,
storeName: selectedStore.name
},
options
)
}
const onlineStock = await onlineStockPromise
return {
@ -239,6 +345,7 @@ async function lookupStoreProductAvailability(options = {}) {
storeDetail: storeDetailPayload.data || null,
selectedProduct,
pickupStock,
pickupEligibility,
onlineStock
}
}
@ -246,6 +353,7 @@ async function lookupStoreProductAvailability(options = {}) {
module.exports = {
getOnlineStock,
getStoreDetail,
getStorePickupEligibility,
getStorePickupStock,
lookupStoreProductAvailability,
searchProducts,

View file

@ -6,6 +6,7 @@ const path = require("node:path")
const {
getOnlineStock,
getStoreDetail,
getStorePickupEligibility,
getStorePickupStock,
lookupStoreProductAvailability,
searchProducts,
@ -13,6 +14,7 @@ const {
} = require("../src/index")
const {
buildSearchGoodsParams,
normalizePickupEligibilityResponse,
normalizeSearchGoodsResponse,
normalizeStorePickupStockResponse,
normalizeStoreSearchResponse
@ -24,6 +26,27 @@ const searchGoodsPayload = JSON.parse(fs.readFileSync(path.join(fixturesDir, "se
const storeDetailPayload = JSON.parse(fs.readFileSync(path.join(fixturesDir, "store-detail.json"), "utf8"))
const storePickupStockPayload = JSON.parse(fs.readFileSync(path.join(fixturesDir, "store-pickup-stock.json"), "utf8"))
const onlineStockPayload = JSON.parse(fs.readFileSync(path.join(fixturesDir, "online-stock.json"), "utf8"))
const storePickupEligibilityPayload = {
data: [
{
strCd: "10224",
strNm: "강남역2호점",
strAddr: "서울특별시 강남구 강남대로",
strDtlAddr: "지하 1층",
strTno: "02-1234-5678",
pkupYn: "Y",
opngTime: "1000",
clsngTime: "2200",
km: "0.2",
strLttd: "37.498095",
strLitd: "127.02761",
totalCnt: 1,
currentPageCnt: 1
}
],
success: true
}
const liveSearchGoodsPayload = {
resultSet: {
result: [
@ -562,3 +585,186 @@ test("lookupStoreProductAvailability reuses a product candidate's online stock i
global.fetch = originalFetch
}
})
test("getStorePickupStock sends Bearer auth headers and returns blocked after repeated auth failures", async () => {
const originalFetch = global.fetch
const stockRequests = []
let authCallCount = 0
global.fetch = async (url, init = {}) => {
if (String(url).includes("/api/auth/request")) {
authCallCount++
return makeAuthResponse()
}
if (String(url).includes("/api/pd/pdh/selStrPkupStck")) {
stockRequests.push({ headers: init.headers, body: JSON.parse(init.body) })
return makeResponse({ success: false, message: "Unauthorized" }, { status: 403 })
}
return new Response("not found", { status: 404 })
}
try {
const pickupStock = await getStorePickupStock({ pdNo: "1049275", strCd: "10224" })
assert.equal(authCallCount, 2)
assert.equal(stockRequests.length, 2)
for (const request of stockRequests) {
assert.match(request.headers.Authorization, /^Bearer /)
assert.equal(request.headers["X-DM-UID"], "test-uid-123")
assert.deepEqual(request.body, [{ pdNo: "1049275", strCd: "10224" }])
}
assert.equal(pickupStock.status, "unavailable")
assert.equal(pickupStock.retrievalStatus, "blocked")
assert.equal(pickupStock.reason, "unauthorized")
} finally {
global.fetch = originalFetch
}
})
test("getStorePickupEligibility posts pdNo and a derived store keyword to selPkupStr", async () => {
const originalFetch = global.fetch
let capturedBody = null
let capturedUrl = null
global.fetch = async (url, init = {}) => {
capturedUrl = String(url)
capturedBody = JSON.parse(init.body)
return makeResponse(storePickupEligibilityPayload)
}
try {
const eligibility = await getStorePickupEligibility({
pdNo: "1049275",
strCd: "10224",
storeName: "강남역2호점"
})
assert.match(capturedUrl, /\/api\/ms\/msg\/selPkupStr$/)
assert.equal(capturedBody.pdNo, "1049275")
assert.equal(capturedBody.keyword, "강남역")
assert.equal(capturedBody.currentPage, 1)
assert.equal(typeof capturedBody.pageSize, "number")
assert.equal(eligibility.pickupEligible, true)
assert.equal(eligibility.matchedStore.strCd, "10224")
} finally {
global.fetch = originalFetch
}
})
test("lookupStoreProductAvailability falls back to pickup eligibility when Bearer stock remains forbidden", async () => {
const originalFetch = global.fetch
let eligibilityCalled = false
global.fetch = async (url) => {
if (String(url).includes("/api/auth/request")) {
return makeAuthResponse()
}
if (String(url).includes("/api/ms/msg/selStr") && !String(url).includes("selStrInfo") && !String(url).includes("selPkupStr")) {
return makeResponse(storeSearchPayload)
}
if (String(url).includes("/ssn/search/SearchGoods")) {
return makeResponse(searchGoodsPayload)
}
if (String(url).includes("/api/dl/dla-api/selStrInfo")) {
return makeResponse(storeDetailPayload)
}
if (String(url).includes("/api/pd/pdh/selStrPkupStck")) {
return makeResponse({ success: false, message: "Unauthorized" }, { status: 403 })
}
if (String(url).includes("/api/ms/msg/selPkupStr")) {
eligibilityCalled = true
return makeResponse(storePickupEligibilityPayload)
}
if (String(url).includes("/api/pdo/selOnlStck")) {
return makeResponse(onlineStockPayload)
}
return new Response("not found", { status: 404 })
}
try {
const availability = await lookupStoreProductAvailability({
storeQuery: "강남역2호점",
productQuery: "VT 리들샷 100"
})
assert.equal(availability.pickupStock.retrievalStatus, "blocked")
assert.equal(eligibilityCalled, true)
assert.equal(availability.pickupEligibility.pickupEligible, true)
assert.equal(availability.pickupEligibility.matchedStore.strCd, "10224")
assert.equal(availability.onlineStock.quantity, 13047)
} finally {
global.fetch = originalFetch
}
})
test("lookupStoreProductAvailability falls back to pickup eligibility when token issuance is forbidden", async () => {
const originalFetch = global.fetch
let eligibilityCalled = false
global.fetch = async (url) => {
if (String(url).includes("/api/auth/request")) {
return new Response("forbidden", { status: 403, headers: { "content-type": "text/plain" } })
}
if (String(url).includes("/api/ms/msg/selStr") && !String(url).includes("selStrInfo") && !String(url).includes("selPkupStr")) {
return makeResponse(storeSearchPayload)
}
if (String(url).includes("/ssn/search/SearchGoods")) {
return makeResponse(searchGoodsPayload)
}
if (String(url).includes("/api/dl/dla-api/selStrInfo")) {
return makeResponse(storeDetailPayload)
}
if (String(url).includes("/api/ms/msg/selPkupStr")) {
eligibilityCalled = true
return makeResponse(storePickupEligibilityPayload)
}
if (String(url).includes("/api/pdo/selOnlStck")) {
return makeResponse(onlineStockPayload)
}
return new Response("not found", { status: 404 })
}
try {
const availability = await lookupStoreProductAvailability({
storeQuery: "강남역2호점",
productQuery: "VT 리들샷 100"
})
assert.equal(availability.pickupStock.retrievalStatus, "blocked")
assert.equal(availability.pickupStock.inventoryStatus, "unknown")
assert.equal(eligibilityCalled, true)
assert.equal(availability.pickupEligibility.pickupEligible, true)
} finally {
global.fetch = originalFetch
}
})
test("normalizePickupEligibilityResponse keeps blocked fallback shape stable", () => {
const eligibility = normalizePickupEligibilityResponse(
{ success: false, message: "Upstream error" },
{ pdNo: "1049275", strCd: "10224" }
)
assert.equal(eligibility.pickupEligible, null)
assert.equal(eligibility.eligibleStoreCount, null)
assert.deepEqual(eligibility.eligibleStores, [])
assert.equal(eligibility.matchedStore, null)
assert.equal(eligibility.retrievalStatus, "blocked")
assert.equal(eligibility.reason, "upstream_error")
})