daiso 1.0.5 → 1.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +101 -7
- package/dist/api/actionsProxy.js +111 -21
- package/dist/api/configStatus.js +23 -0
- package/dist/api/gs25Handlers.js +68 -22
- package/dist/api/handlers.js +32 -3
- package/dist/api/healthChecks.js +233 -20
- package/dist/api/lottemartHandlers.js +17 -1
- package/dist/api/placesHandlers.js +44 -0
- package/dist/api/response.js +2 -0
- package/dist/api/routes/healthRoutes.js +20 -3
- package/dist/api/routes/placesRoutes.js +9 -0
- package/dist/bin.js +0 -0
- package/dist/cli/args.js +21 -0
- package/dist/cli/commandOptions.js +96 -0
- package/dist/cli/commands/convenience.js +73 -1
- package/dist/cli/commands/daiso.js +18 -2
- package/dist/cli/http.js +58 -0
- package/dist/cli/interactiveItemSearch.js +4 -2
- package/dist/cli/interactivePrompt.js +3 -2
- package/dist/cli.js +25 -1
- package/dist/cliHelp.js +20 -6
- package/dist/cliInteractive.js +75 -6
- package/dist/cliPicker.js +1 -0
- package/dist/cliRenderer.js +93 -3
- package/dist/core/errors.js +66 -0
- package/dist/core/registry.js +246 -3
- package/dist/index.js +19 -4
- package/dist/pages/openapiFullSpec.js +3 -0
- package/dist/pages/openapiSpecActions.js +308 -42
- package/dist/pages/openapiSpecPathsCu.js +7 -0
- package/dist/pages/openapiSpecPathsLotteMart.js +14 -0
- package/dist/pages/openapiSpecPathsOliveyoung.js +35 -0
- package/dist/pages/openapiSpecPathsPlaces.js +85 -0
- package/dist/pages/promptReferenceText.js +16 -7
- package/dist/pages/promptRetailCinemaText.js +24 -2
- package/dist/services/daiso/client.js +8 -0
- package/dist/services/daiso/index.js +2 -0
- package/dist/services/daiso/tools/checkInventory.js +4 -2
- package/dist/services/daiso/tools/findInventoryByName.js +222 -0
- package/dist/services/daiso/tools/getDisplayLocation.js +2 -2
- package/dist/services/emart24/client.js +9 -0
- package/dist/services/emart24/tools/searchProducts.js +27 -17
- package/dist/services/gs25/api.js +3 -0
- package/dist/services/gs25/client.js +216 -30
- package/dist/services/gs25/index.js +11 -3
- package/dist/services/gs25/tools/checkInventory.js +39 -12
- package/dist/services/gs25/tools/findNearbyStores.js +19 -5
- package/dist/services/gs25/tools/searchProducts.js +39 -25
- package/dist/services/lottemart/client.js +10 -1
- package/dist/services/lottemart/tools/searchProducts.js +8 -1
- package/dist/services/lottemart/zetta.js +1 -1
- package/dist/services/oliveyoung/client.js +206 -86
- package/dist/services/oliveyoung/tools/checkInventory.js +7 -2
- package/dist/services/places/client.js +116 -0
- package/dist/services/places/index.js +23 -0
- package/dist/services/places/tools/searchNearby.js +56 -0
- package/dist/services/places/types.js +1 -0
- package/dist/services/seveneleven/client.js +7 -0
- package/dist/services/seveneleven/tools/searchProducts.js +29 -18
- package/dist/utils/http.js +82 -10
- package/dist/utils/zyte.js +74 -26
- package/openapi.json +22 -3
- package/openapi.yaml +16 -3
- package/package.json +28 -9
package/dist/api/healthChecks.js
CHANGED
|
@@ -2,8 +2,15 @@
|
|
|
2
2
|
* 개별 서비스 헬스 체크 실행기
|
|
3
3
|
*/
|
|
4
4
|
const HEALTH_CHECK_CACHE_TTL_MS = 60_000;
|
|
5
|
-
const DEFAULT_HEALTH_CHECK_TIMEOUT_MS =
|
|
6
|
-
const MAX_HEALTH_CHECK_TIMEOUT_MS =
|
|
5
|
+
const DEFAULT_HEALTH_CHECK_TIMEOUT_MS = 7000;
|
|
6
|
+
const MAX_HEALTH_CHECK_TIMEOUT_MS = 20_000;
|
|
7
|
+
const DEFAULT_HEALTH_CHECK_CONCURRENCY = 1;
|
|
8
|
+
const DEFAULT_HEALTH_CHECK_SLOW_THRESHOLD_MS = 0;
|
|
9
|
+
const GS25_CLOUDFRONT_403_PATTERNS = [
|
|
10
|
+
'403 Forbidden',
|
|
11
|
+
'The request could not be satisfied',
|
|
12
|
+
'403 ERROR',
|
|
13
|
+
];
|
|
7
14
|
const HEALTH_CHECKS = [
|
|
8
15
|
{
|
|
9
16
|
id: 'cli.contract',
|
|
@@ -19,6 +26,8 @@ const HEALTH_CHECKS = [
|
|
|
19
26
|
target: 'products',
|
|
20
27
|
mode: 'quick',
|
|
21
28
|
path: '/api/daiso/products?q=%ED%85%8C%EC%9D%B4%ED%94%84&pageSize=1',
|
|
29
|
+
collectionKey: 'products',
|
|
30
|
+
requiredFields: ['id', 'name', 'productName', 'itemName', 'goodsName'],
|
|
22
31
|
},
|
|
23
32
|
{
|
|
24
33
|
id: 'cu.stores',
|
|
@@ -26,6 +35,8 @@ const HEALTH_CHECKS = [
|
|
|
26
35
|
target: 'stores',
|
|
27
36
|
mode: 'quick',
|
|
28
37
|
path: '/api/cu/stores?keyword=%EA%B0%95%EB%82%A8&limit=1',
|
|
38
|
+
collectionKey: 'stores',
|
|
39
|
+
requiredFields: ['storeCode', 'storeName', 'name'],
|
|
29
40
|
},
|
|
30
41
|
{
|
|
31
42
|
id: 'emart24.products',
|
|
@@ -33,6 +44,8 @@ const HEALTH_CHECKS = [
|
|
|
33
44
|
target: 'products',
|
|
34
45
|
mode: 'quick',
|
|
35
46
|
path: '/api/emart24/products?keyword=%EC%BB%A4%ED%94%BC&pageSize=1',
|
|
47
|
+
collectionKey: 'products',
|
|
48
|
+
requiredFields: ['pluCd', 'goodsName', 'itemName', 'name'],
|
|
36
49
|
},
|
|
37
50
|
{
|
|
38
51
|
id: 'gs25.products',
|
|
@@ -40,6 +53,9 @@ const HEALTH_CHECKS = [
|
|
|
40
53
|
target: 'products',
|
|
41
54
|
mode: 'quick',
|
|
42
55
|
path: '/api/gs25/products?keyword=%EC%BD%9C%EB%9D%BC&limit=1',
|
|
56
|
+
collectionKey: 'products',
|
|
57
|
+
requiredFields: ['itemCode', 'itemName', 'name'],
|
|
58
|
+
degradedFailurePatterns: GS25_CLOUDFRONT_403_PATTERNS,
|
|
43
59
|
},
|
|
44
60
|
{
|
|
45
61
|
id: 'gs25.stores',
|
|
@@ -47,6 +63,8 @@ const HEALTH_CHECKS = [
|
|
|
47
63
|
target: 'stores',
|
|
48
64
|
mode: 'quick',
|
|
49
65
|
path: '/api/gs25/stores?keyword=%EA%B0%95%EB%82%A8&limit=1',
|
|
66
|
+
collectionKey: 'stores',
|
|
67
|
+
requiredFields: ['storeCode', 'storeName', 'name'],
|
|
50
68
|
},
|
|
51
69
|
{
|
|
52
70
|
id: 'seveneleven.products',
|
|
@@ -54,20 +72,27 @@ const HEALTH_CHECKS = [
|
|
|
54
72
|
target: 'products',
|
|
55
73
|
mode: 'quick',
|
|
56
74
|
path: '/api/seveneleven/products?query=%EC%BB%A4%ED%94%BC&size=1',
|
|
75
|
+
collectionKey: 'products',
|
|
76
|
+
requiredFields: ['itemCode', 'itemName', 'productNo', 'name'],
|
|
57
77
|
},
|
|
58
78
|
{
|
|
59
79
|
id: 'lottemart.products',
|
|
60
80
|
service: 'lottemart',
|
|
61
81
|
target: 'products',
|
|
62
82
|
mode: 'quick',
|
|
63
|
-
path: '/api/lottemart/products?keyword=%EC%BD%9C%EB%9D%BC&storeCode=2301&area=%EC%84%9C%EC%9A%B8&pageLimit=1',
|
|
83
|
+
path: '/api/lottemart/products?keyword=%EC%BD%9C%EB%9D%BC&storeCode=2301&area=%EC%84%9C%EC%9A%B8&pageLimit=1&source=zetta',
|
|
84
|
+
collectionKey: 'products',
|
|
85
|
+
requiredFields: ['productCode', 'name', 'productName'],
|
|
64
86
|
},
|
|
65
87
|
{
|
|
66
88
|
id: 'oliveyoung.products',
|
|
67
89
|
service: 'oliveyoung',
|
|
68
90
|
target: 'products',
|
|
69
91
|
mode: 'quick',
|
|
70
|
-
path: '/api/oliveyoung/products?keyword=%
|
|
92
|
+
path: '/api/oliveyoung/products?keyword=%EB%A6%BD%EB%B0%A4&size=1',
|
|
93
|
+
collectionKey: 'products',
|
|
94
|
+
requiredFields: ['goodsNumber', 'goodsName', 'productNo', 'productName', 'name'],
|
|
95
|
+
timeoutMs: 5000,
|
|
71
96
|
},
|
|
72
97
|
{
|
|
73
98
|
id: 'megabox.theaters',
|
|
@@ -75,6 +100,8 @@ const HEALTH_CHECKS = [
|
|
|
75
100
|
target: 'theaters',
|
|
76
101
|
mode: 'quick',
|
|
77
102
|
path: '/api/megabox/theaters?keyword=%EA%B0%95%EB%82%A8&limit=1',
|
|
103
|
+
collectionKey: 'theaters',
|
|
104
|
+
requiredFields: ['theaterCode', 'theaterName', 'name'],
|
|
78
105
|
},
|
|
79
106
|
{
|
|
80
107
|
id: 'lottecinema.theaters',
|
|
@@ -82,6 +109,8 @@ const HEALTH_CHECKS = [
|
|
|
82
109
|
target: 'theaters',
|
|
83
110
|
mode: 'quick',
|
|
84
111
|
path: '/api/lottecinema/theaters?keyword=%EC%9E%A0%EC%8B%A4&limit=1',
|
|
112
|
+
collectionKey: 'theaters',
|
|
113
|
+
requiredFields: ['theaterCode', 'theaterName', 'name'],
|
|
85
114
|
},
|
|
86
115
|
{
|
|
87
116
|
id: 'cgv.theaters',
|
|
@@ -89,6 +118,59 @@ const HEALTH_CHECKS = [
|
|
|
89
118
|
target: 'theaters',
|
|
90
119
|
mode: 'quick',
|
|
91
120
|
path: '/api/cgv/theaters?keyword=%EA%B0%95%EB%82%A8&limit=1',
|
|
121
|
+
collectionKey: 'theaters',
|
|
122
|
+
requiredFields: ['theaterCode', 'theaterName', 'name'],
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
id: 'cu.inventory',
|
|
126
|
+
service: 'cu',
|
|
127
|
+
target: 'inventory',
|
|
128
|
+
mode: 'deep',
|
|
129
|
+
path: '/api/cu/inventory?keyword=%EC%BB%A4%ED%94%BC&size=1&storeLimit=0&storeCheck=false',
|
|
130
|
+
collectionKey: 'inventoryItems',
|
|
131
|
+
requiredFields: ['itemCode', 'itemName', 'name'],
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
id: 'emart24.inventory',
|
|
135
|
+
service: 'emart24',
|
|
136
|
+
target: 'inventory',
|
|
137
|
+
mode: 'deep',
|
|
138
|
+
path: '/api/emart24/inventory?keyword=%EC%BB%A4%ED%94%BC&storeKeyword=%EA%B0%95%EB%82%A8&limit=1',
|
|
139
|
+
collectionKey: 'inventoryItems',
|
|
140
|
+
requiredFields: ['pluCd', 'goodsName', 'itemName', 'name'],
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
id: 'gs25.inventory',
|
|
144
|
+
service: 'gs25',
|
|
145
|
+
target: 'inventory',
|
|
146
|
+
mode: 'deep',
|
|
147
|
+
path: '/api/gs25/inventory?keyword=%EC%BD%9C%EB%9D%BC&storeKeyword=%EA%B0%95%EB%82%A8&limit=1',
|
|
148
|
+
collectionKey: 'inventoryItems',
|
|
149
|
+
requiredFields: ['itemCode', 'itemName', 'name'],
|
|
150
|
+
degradedFailurePatterns: [
|
|
151
|
+
'401 Unauthorized',
|
|
152
|
+
'인증키가 제공되지 않음',
|
|
153
|
+
...GS25_CLOUDFRONT_403_PATTERNS,
|
|
154
|
+
],
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
id: 'seveneleven.inventory',
|
|
158
|
+
service: 'seveneleven',
|
|
159
|
+
target: 'inventory',
|
|
160
|
+
mode: 'deep',
|
|
161
|
+
path: '/api/seveneleven/inventory?keyword=%EC%BB%A4%ED%94%BC&storeKeyword=%EA%B0%95%EB%82%A8&size=1',
|
|
162
|
+
collectionKey: 'inventoryItems',
|
|
163
|
+
requiredFields: ['itemCode', 'itemName', 'productNo', 'name'],
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
id: 'oliveyoung.inventory',
|
|
167
|
+
service: 'oliveyoung',
|
|
168
|
+
target: 'inventory',
|
|
169
|
+
mode: 'deep',
|
|
170
|
+
path: '/api/oliveyoung/inventory?keyword=%EC%84%A0%ED%81%AC%EB%A6%BC&storeKeyword=%EB%AA%85%EB%8F%99&size=1&storeLimit=1&stockCheckLimit=0',
|
|
171
|
+
collectionKey: 'inventoryProducts',
|
|
172
|
+
requiredFields: ['goodsNumber', 'goodsName', 'productNo', 'productName', 'name'],
|
|
173
|
+
timeoutMs: 5000,
|
|
92
174
|
},
|
|
93
175
|
];
|
|
94
176
|
const healthCheckCache = new Map();
|
|
@@ -105,13 +187,15 @@ function createCacheKey(params) {
|
|
|
105
187
|
params.check || '',
|
|
106
188
|
params.mode,
|
|
107
189
|
params.timeoutMs,
|
|
190
|
+
params.slowThresholdMs || 0,
|
|
191
|
+
params.cacheBust ? 'cache-bust' : 'cache',
|
|
108
192
|
params.includeSamples ? 'samples' : 'no-samples',
|
|
109
193
|
].join('|');
|
|
110
194
|
}
|
|
111
195
|
function selectChecks(params) {
|
|
112
196
|
const mode = params.mode || 'quick';
|
|
113
197
|
return HEALTH_CHECKS.filter((check) => {
|
|
114
|
-
if (check.mode !== mode) {
|
|
198
|
+
if (mode !== 'full' && check.mode !== mode) {
|
|
115
199
|
return false;
|
|
116
200
|
}
|
|
117
201
|
if (params.service && check.service !== params.service) {
|
|
@@ -123,6 +207,19 @@ function selectChecks(params) {
|
|
|
123
207
|
return true;
|
|
124
208
|
});
|
|
125
209
|
}
|
|
210
|
+
async function mapWithConcurrency(items, concurrency, mapper) {
|
|
211
|
+
const results = new Array(items.length);
|
|
212
|
+
let nextIndex = 0;
|
|
213
|
+
const workerCount = Math.min(Math.max(1, concurrency), items.length);
|
|
214
|
+
await Promise.all(Array.from({ length: workerCount }, async () => {
|
|
215
|
+
while (nextIndex < items.length) {
|
|
216
|
+
const currentIndex = nextIndex;
|
|
217
|
+
nextIndex += 1;
|
|
218
|
+
results[currentIndex] = await mapper(items[currentIndex]);
|
|
219
|
+
}
|
|
220
|
+
}));
|
|
221
|
+
return results;
|
|
222
|
+
}
|
|
126
223
|
function toCount(data) {
|
|
127
224
|
if (!data || typeof data !== 'object') {
|
|
128
225
|
return null;
|
|
@@ -137,6 +234,15 @@ function toCount(data) {
|
|
|
137
234
|
return value.length;
|
|
138
235
|
}
|
|
139
236
|
}
|
|
237
|
+
if (record.inventory && typeof record.inventory === 'object') {
|
|
238
|
+
const inventory = record.inventory;
|
|
239
|
+
for (const key of ['products', 'items']) {
|
|
240
|
+
const value = inventory[key];
|
|
241
|
+
if (Array.isArray(value)) {
|
|
242
|
+
return value.length;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
140
246
|
return null;
|
|
141
247
|
}
|
|
142
248
|
function toFirstName(data) {
|
|
@@ -156,8 +262,67 @@ function toFirstName(data) {
|
|
|
156
262
|
}
|
|
157
263
|
}
|
|
158
264
|
}
|
|
265
|
+
if (record.inventory && typeof record.inventory === 'object') {
|
|
266
|
+
const inventory = record.inventory;
|
|
267
|
+
for (const key of ['products', 'items']) {
|
|
268
|
+
const value = inventory[key];
|
|
269
|
+
if (!Array.isArray(value) || !value[0] || typeof value[0] !== 'object') {
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
const item = value[0];
|
|
273
|
+
for (const nameKey of ['productName', 'itemName', 'goodsName', 'name']) {
|
|
274
|
+
if (typeof item[nameKey] === 'string' && item[nameKey].trim().length > 0) {
|
|
275
|
+
return item[nameKey].trim();
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
159
280
|
return undefined;
|
|
160
281
|
}
|
|
282
|
+
function getCollectionItems(data, collectionKey) {
|
|
283
|
+
if (!data || typeof data !== 'object') {
|
|
284
|
+
return [];
|
|
285
|
+
}
|
|
286
|
+
const record = data;
|
|
287
|
+
if (collectionKey === 'inventoryProducts' || collectionKey === 'inventoryItems') {
|
|
288
|
+
const inventory = record.inventory;
|
|
289
|
+
if (!inventory || typeof inventory !== 'object') {
|
|
290
|
+
return [];
|
|
291
|
+
}
|
|
292
|
+
const key = collectionKey === 'inventoryProducts' ? 'products' : 'items';
|
|
293
|
+
const value = inventory[key];
|
|
294
|
+
return Array.isArray(value) ? value : [];
|
|
295
|
+
}
|
|
296
|
+
if (collectionKey && Array.isArray(record[collectionKey])) {
|
|
297
|
+
return record[collectionKey];
|
|
298
|
+
}
|
|
299
|
+
for (const key of ['products', 'stores', 'theaters', 'movies', 'showtimes']) {
|
|
300
|
+
/* c8 ignore next -- 현재 정의된 체크는 collectionKey를 명시한다. */
|
|
301
|
+
if (Array.isArray(record[key])) {
|
|
302
|
+
return record[key];
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return [];
|
|
306
|
+
}
|
|
307
|
+
function hasRequiredRepresentativeFields(data, collectionKey, requiredFields = []) {
|
|
308
|
+
/* c8 ignore next -- 현재 정의된 API 체크는 requiredFields를 명시한다. */
|
|
309
|
+
if (requiredFields.length === 0) {
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
const items = getCollectionItems(data, collectionKey);
|
|
313
|
+
if (items.length === 0) {
|
|
314
|
+
return true;
|
|
315
|
+
}
|
|
316
|
+
const first = items[0];
|
|
317
|
+
if (!first || typeof first !== 'object' || Array.isArray(first)) {
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
const record = first;
|
|
321
|
+
return requiredFields.some((field) => {
|
|
322
|
+
const value = record[field];
|
|
323
|
+
return typeof value === 'string' ? value.trim().length > 0 : value !== undefined && value !== null;
|
|
324
|
+
});
|
|
325
|
+
}
|
|
161
326
|
function aggregateStatus(checks) {
|
|
162
327
|
if (checks.length === 0) {
|
|
163
328
|
return 'skipped';
|
|
@@ -170,9 +335,30 @@ function aggregateStatus(checks) {
|
|
|
170
335
|
}
|
|
171
336
|
return 'ok';
|
|
172
337
|
}
|
|
173
|
-
function
|
|
338
|
+
function shouldDegradeFailedResponse(check, message) {
|
|
339
|
+
return check.degradedFailurePatterns?.some((pattern) => message.includes(pattern)) ?? false;
|
|
340
|
+
}
|
|
341
|
+
function shouldDegradeCliContractPath(path, message) {
|
|
342
|
+
return path.startsWith('/api/gs25/') && GS25_CLOUDFRONT_403_PATTERNS.some((pattern) => message.includes(pattern));
|
|
343
|
+
}
|
|
344
|
+
function resolveCheckTimeoutMs(check, timeoutMs) {
|
|
345
|
+
if (check.timeoutMs === undefined) {
|
|
346
|
+
return timeoutMs;
|
|
347
|
+
}
|
|
348
|
+
return Math.min(timeoutMs, Math.trunc(check.timeoutMs));
|
|
349
|
+
}
|
|
350
|
+
function resolveCliContractTimeoutMs(path, timeoutMs) {
|
|
351
|
+
if (path.startsWith('/api/oliveyoung/')) {
|
|
352
|
+
return Math.min(timeoutMs, 5000);
|
|
353
|
+
}
|
|
354
|
+
return timeoutMs;
|
|
355
|
+
}
|
|
356
|
+
function buildCheckUrl(baseUrl, check, timeoutMs, cacheBustValue) {
|
|
174
357
|
const url = new URL(check.path, baseUrl);
|
|
175
358
|
url.searchParams.set('timeoutMs', String(timeoutMs));
|
|
359
|
+
if (typeof cacheBustValue === 'number') {
|
|
360
|
+
url.searchParams.set('_healthCheck', String(cacheBustValue));
|
|
361
|
+
}
|
|
176
362
|
return url.toString();
|
|
177
363
|
}
|
|
178
364
|
const CLI_CONTRACT_PATHS = [
|
|
@@ -201,14 +387,22 @@ function isCliCompatibleEnvelope(path, body) {
|
|
|
201
387
|
}
|
|
202
388
|
async function runCliContractCheck(check, params) {
|
|
203
389
|
const startedAt = params.now();
|
|
390
|
+
const cacheBustValue = params.cacheBust ? startedAt : undefined;
|
|
391
|
+
const degradedMessages = [];
|
|
204
392
|
for (const path of CLI_CONTRACT_PATHS) {
|
|
393
|
+
const checkTimeoutMs = resolveCliContractTimeoutMs(path, params.timeoutMs);
|
|
205
394
|
const syntheticCheck = { ...check, path };
|
|
206
395
|
try {
|
|
207
|
-
const response = await params.fetchImpl(buildCheckUrl(params.baseUrl, syntheticCheck,
|
|
208
|
-
signal: AbortSignal.timeout(
|
|
396
|
+
const response = await params.fetchImpl(buildCheckUrl(params.baseUrl, syntheticCheck, checkTimeoutMs, cacheBustValue), {
|
|
397
|
+
signal: AbortSignal.timeout(checkTimeoutMs),
|
|
209
398
|
});
|
|
210
399
|
const body = (await response.json().catch(() => ({})));
|
|
211
400
|
if (!response.ok || !isCliCompatibleEnvelope(path, body)) {
|
|
401
|
+
const message = body.error?.message || `${path} CLI 계약 응답이 올바르지 않습니다.`;
|
|
402
|
+
if (shouldDegradeCliContractPath(path, message)) {
|
|
403
|
+
degradedMessages.push(`${path}: ${message}`);
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
212
406
|
return {
|
|
213
407
|
id: check.id,
|
|
214
408
|
service: check.service,
|
|
@@ -216,7 +410,7 @@ async function runCliContractCheck(check, params) {
|
|
|
216
410
|
status: 'fail',
|
|
217
411
|
durationMs: params.now() - startedAt,
|
|
218
412
|
httpStatus: response.status,
|
|
219
|
-
message
|
|
413
|
+
message,
|
|
220
414
|
};
|
|
221
415
|
}
|
|
222
416
|
}
|
|
@@ -235,37 +429,51 @@ async function runCliContractCheck(check, params) {
|
|
|
235
429
|
id: check.id,
|
|
236
430
|
service: check.service,
|
|
237
431
|
target: check.target,
|
|
238
|
-
status: 'ok',
|
|
432
|
+
status: degradedMessages.length > 0 ? 'degraded' : 'ok',
|
|
239
433
|
durationMs: params.now() - startedAt,
|
|
240
|
-
message:
|
|
434
|
+
message: degradedMessages.length > 0
|
|
435
|
+
? `known upstream issue: ${degradedMessages[0]}`
|
|
436
|
+
: `${CLI_CONTRACT_PATHS.length} CLI contract endpoint(s) passed`,
|
|
241
437
|
};
|
|
242
438
|
}
|
|
243
439
|
async function runSingleCheck(check, params) {
|
|
244
440
|
if (check.kind === 'cli-contract') {
|
|
245
441
|
return runCliContractCheck(check, params);
|
|
246
442
|
}
|
|
247
|
-
const timeoutMs = params.timeoutMs;
|
|
443
|
+
const timeoutMs = resolveCheckTimeoutMs(check, params.timeoutMs);
|
|
248
444
|
const startedAt = params.now();
|
|
445
|
+
const cacheBustValue = params.cacheBust ? startedAt : undefined;
|
|
249
446
|
try {
|
|
250
|
-
const response = await params.fetchImpl(buildCheckUrl(params.baseUrl, check, timeoutMs), {
|
|
447
|
+
const response = await params.fetchImpl(buildCheckUrl(params.baseUrl, check, timeoutMs, cacheBustValue), {
|
|
251
448
|
signal: AbortSignal.timeout(timeoutMs),
|
|
252
449
|
});
|
|
253
450
|
const body = (await response.json().catch(() => ({})));
|
|
254
451
|
const durationMs = params.now() - startedAt;
|
|
255
452
|
if (!response.ok || body.success === false) {
|
|
453
|
+
const message = body.error?.message || `HTTP ${response.status}`;
|
|
256
454
|
return {
|
|
257
455
|
id: check.id,
|
|
258
456
|
service: check.service,
|
|
259
457
|
target: check.target,
|
|
260
|
-
status: 'fail',
|
|
458
|
+
status: shouldDegradeFailedResponse(check, message) ? 'degraded' : 'fail',
|
|
261
459
|
durationMs,
|
|
262
460
|
httpStatus: response.status,
|
|
263
|
-
message
|
|
461
|
+
message,
|
|
264
462
|
};
|
|
265
463
|
}
|
|
266
464
|
const count = typeof body.meta?.total === 'number' ? body.meta.total : toCount(body.data);
|
|
267
|
-
const
|
|
465
|
+
const shapeOk = hasRequiredRepresentativeFields(body.data, check.collectionKey, check.requiredFields);
|
|
466
|
+
const slowThresholdMs = params.slowThresholdMs || DEFAULT_HEALTH_CHECK_SLOW_THRESHOLD_MS;
|
|
467
|
+
const slow = slowThresholdMs > 0 && durationMs > slowThresholdMs;
|
|
468
|
+
const status = count === 0 || !shapeOk || slow ? 'degraded' : 'ok';
|
|
268
469
|
const first = params.includeSamples ? toFirstName(body.data) : undefined;
|
|
470
|
+
const message = slow
|
|
471
|
+
? `slow response: ${durationMs}ms > ${slowThresholdMs}ms`
|
|
472
|
+
: !shapeOk && count !== 0
|
|
473
|
+
? `response missing required fields: ${check.requiredFields.join(', ')}`
|
|
474
|
+
: count === null
|
|
475
|
+
? 'response ok'
|
|
476
|
+
: `${count} item(s) returned`;
|
|
269
477
|
return {
|
|
270
478
|
id: check.id,
|
|
271
479
|
service: check.service,
|
|
@@ -273,7 +481,7 @@ async function runSingleCheck(check, params) {
|
|
|
273
481
|
status,
|
|
274
482
|
durationMs,
|
|
275
483
|
httpStatus: response.status,
|
|
276
|
-
message
|
|
484
|
+
message,
|
|
277
485
|
...(first ? { sample: { first } } : {}),
|
|
278
486
|
};
|
|
279
487
|
}
|
|
@@ -293,7 +501,10 @@ export async function runHealthChecks(params) {
|
|
|
293
501
|
const fetchImpl = params.fetchImpl || ((input, init) => globalThis.fetch(input, init));
|
|
294
502
|
const mode = params.mode || 'quick';
|
|
295
503
|
const timeoutMs = clampTimeout(params.timeoutMs ?? DEFAULT_HEALTH_CHECK_TIMEOUT_MS);
|
|
296
|
-
const
|
|
504
|
+
const slowThresholdMs = Number.isFinite(params.slowThresholdMs) && params.slowThresholdMs && params.slowThresholdMs > 0
|
|
505
|
+
? Math.trunc(params.slowThresholdMs)
|
|
506
|
+
: DEFAULT_HEALTH_CHECK_SLOW_THRESHOLD_MS;
|
|
507
|
+
const cacheKey = createCacheKey({ ...params, mode, timeoutMs, slowThresholdMs, baseUrl: params.baseUrl });
|
|
297
508
|
const cached = healthCheckCache.get(cacheKey);
|
|
298
509
|
const startedAt = now();
|
|
299
510
|
if (!params.fresh && cached && cached.expiresAt > startedAt) {
|
|
@@ -302,13 +513,15 @@ export async function runHealthChecks(params) {
|
|
|
302
513
|
cached: true,
|
|
303
514
|
};
|
|
304
515
|
}
|
|
305
|
-
const checks = await
|
|
516
|
+
const checks = await mapWithConcurrency(selectChecks(params), DEFAULT_HEALTH_CHECK_CONCURRENCY, (check) => runSingleCheck(check, {
|
|
306
517
|
baseUrl: params.baseUrl,
|
|
307
518
|
fetchImpl,
|
|
308
519
|
now,
|
|
309
520
|
timeoutMs,
|
|
521
|
+
slowThresholdMs,
|
|
310
522
|
includeSamples: params.includeSamples,
|
|
311
|
-
|
|
523
|
+
cacheBust: params.cacheBust,
|
|
524
|
+
}));
|
|
312
525
|
const summary = {
|
|
313
526
|
status: aggregateStatus(checks),
|
|
314
527
|
checkedAt: new Date(startedAt).toISOString(),
|
|
@@ -5,6 +5,15 @@ import { errorResponse, successResponse } from './response.js';
|
|
|
5
5
|
import { fetchLotteMartStores, searchLotteMartProducts } from '../services/lottemart/client.js';
|
|
6
6
|
import { DEFAULT_LOTTEMART_TIMEOUT_MS } from '../services/lottemart/config.js';
|
|
7
7
|
import { probeLotteMartUpstream } from '../services/lottemart/debug.js';
|
|
8
|
+
function parseLotteMartProductSource(value) {
|
|
9
|
+
if (!value || value === 'auto') {
|
|
10
|
+
return 'auto';
|
|
11
|
+
}
|
|
12
|
+
if (value === 'legacy' || value === 'zetta') {
|
|
13
|
+
return value;
|
|
14
|
+
}
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
8
17
|
/**
|
|
9
18
|
* 롯데마트 매장 검색 API 핸들러
|
|
10
19
|
* GET /api/lottemart/stores?keyword={키워드}&lat={위도}&lng={경도}
|
|
@@ -62,9 +71,15 @@ export async function handleLotteMartSearchProducts(c) {
|
|
|
62
71
|
const storeCode = c.req.query('storeCode') || '';
|
|
63
72
|
const storeName = c.req.query('storeName') || '';
|
|
64
73
|
const pageLimit = parseInt(c.req.query('pageLimit') || '3', 10);
|
|
74
|
+
const parsedTimeoutMs = parseInt(c.req.query('timeoutMs') || String(DEFAULT_LOTTEMART_TIMEOUT_MS), 10);
|
|
75
|
+
const timeoutMs = Number.isFinite(parsedTimeoutMs) && parsedTimeoutMs > 0 ? parsedTimeoutMs : DEFAULT_LOTTEMART_TIMEOUT_MS;
|
|
76
|
+
const source = parseLotteMartProductSource(c.req.query('source'));
|
|
65
77
|
if (keyword.trim().length === 0) {
|
|
66
78
|
return errorResponse(c, 'MISSING_QUERY', '검색어(keyword)를 입력해주세요.');
|
|
67
79
|
}
|
|
80
|
+
if (!source) {
|
|
81
|
+
return errorResponse(c, 'INVALID_LOTTEMART_SOURCE', 'source는 auto, legacy, zetta 중 하나여야 합니다.');
|
|
82
|
+
}
|
|
68
83
|
if (storeCode.trim().length === 0 && storeName.trim().length === 0) {
|
|
69
84
|
return errorResponse(c, 'MISSING_STORE', 'storeCode 또는 storeName 중 하나를 입력해주세요.');
|
|
70
85
|
}
|
|
@@ -75,8 +90,9 @@ export async function handleLotteMartSearchProducts(c) {
|
|
|
75
90
|
storeName,
|
|
76
91
|
keyword,
|
|
77
92
|
pageLimit,
|
|
93
|
+
source,
|
|
78
94
|
}, {
|
|
79
|
-
timeout:
|
|
95
|
+
timeout: timeoutMs,
|
|
80
96
|
zyteApiKey: c.env?.ZYTE_API_KEY,
|
|
81
97
|
});
|
|
82
98
|
return successResponse(c, {
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 장소 검색 GET API 핸들러
|
|
3
|
+
*/
|
|
4
|
+
import { errorResponse, successResponse } from './response.js';
|
|
5
|
+
import { searchNaverLocalPlaces } from '../services/places/client.js';
|
|
6
|
+
import { getErrorMessage } from '../core/errors.js';
|
|
7
|
+
const PLACE_CATEGORIES = new Set(['restaurant', 'cafe', 'food', 'dessert', 'all']);
|
|
8
|
+
function parseCategory(value) {
|
|
9
|
+
if (value && PLACE_CATEGORIES.has(value)) {
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
return 'all';
|
|
13
|
+
}
|
|
14
|
+
export async function handlePlacesSearch(c) {
|
|
15
|
+
const location = c.req.query('location') || '';
|
|
16
|
+
const keyword = c.req.query('keyword') || '';
|
|
17
|
+
const limit = Number.parseInt(c.req.query('limit') || '5', 10);
|
|
18
|
+
const start = Number.parseInt(c.req.query('start') || '1', 10);
|
|
19
|
+
const sort = c.req.query('sort') === 'comment' ? 'comment' : 'random';
|
|
20
|
+
const category = parseCategory(c.req.query('category'));
|
|
21
|
+
if (location.trim().length === 0 && keyword.trim().length === 0) {
|
|
22
|
+
return errorResponse(c, 'MISSING_QUERY', '위치(location) 또는 검색어(keyword)를 입력해주세요.');
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
const result = await searchNaverLocalPlaces({
|
|
26
|
+
naverClientId: c.env?.NAVER_CLIENT_ID,
|
|
27
|
+
naverClientSecret: c.env?.NAVER_CLIENT_SECRET,
|
|
28
|
+
location,
|
|
29
|
+
keyword,
|
|
30
|
+
category,
|
|
31
|
+
limit,
|
|
32
|
+
start,
|
|
33
|
+
sort,
|
|
34
|
+
});
|
|
35
|
+
return successResponse(c, result, {
|
|
36
|
+
total: result.totalCount,
|
|
37
|
+
page: start,
|
|
38
|
+
pageSize: result.count,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
return errorResponse(c, 'PLACES_SEARCH_FAILED', getErrorMessage(error), 500);
|
|
43
|
+
}
|
|
44
|
+
}
|
package/dist/api/response.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* API 공통 응답 유틸리티
|
|
3
3
|
*/
|
|
4
|
+
import { toStandardErrorDiagnostics } from '../core/errors.js';
|
|
4
5
|
/**
|
|
5
6
|
* 성공 응답 생성
|
|
6
7
|
*/
|
|
@@ -18,5 +19,6 @@ export function errorResponse(c, code, message, status = 400) {
|
|
|
18
19
|
return c.json({
|
|
19
20
|
success: false,
|
|
20
21
|
error: { code, message },
|
|
22
|
+
diagnostics: toStandardErrorDiagnostics(code, message, { status }),
|
|
21
23
|
}, status);
|
|
22
24
|
}
|
|
@@ -14,6 +14,15 @@ function readHealthCheckToken(headers) {
|
|
|
14
14
|
}
|
|
15
15
|
return (headers.get('x-health-check-key') || '').trim();
|
|
16
16
|
}
|
|
17
|
+
function parseMode(value) {
|
|
18
|
+
if (!value || value === 'quick') {
|
|
19
|
+
return 'quick';
|
|
20
|
+
}
|
|
21
|
+
if (value === 'deep' || value === 'full') {
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
17
26
|
export function registerHealthRoutes(app) {
|
|
18
27
|
app.get('/api/health/checks', async (c) => {
|
|
19
28
|
const secret = c.env?.HEALTH_CHECK_SECRET?.trim();
|
|
@@ -23,10 +32,16 @@ export function registerHealthRoutes(app) {
|
|
|
23
32
|
if (readHealthCheckToken(c.req.raw.headers) !== secret) {
|
|
24
33
|
return errorResponse(c, 'UNAUTHORIZED_HEALTH_CHECK', '유효한 헬스 체크 시크릿 키가 필요합니다.', 401);
|
|
25
34
|
}
|
|
26
|
-
const mode = c.req.query('mode')
|
|
35
|
+
const mode = parseMode(c.req.query('mode'));
|
|
36
|
+
if (!mode) {
|
|
37
|
+
return errorResponse(c, 'INVALID_HEALTH_CHECK_MODE', 'mode는 quick, deep, full 중 하나여야 합니다.', 400);
|
|
38
|
+
}
|
|
27
39
|
const timeoutMs = Number.parseInt(c.req.query('timeoutMs') || '3000', 10);
|
|
28
|
-
const
|
|
29
|
-
const
|
|
40
|
+
const slowThresholdMs = Number.parseInt(c.req.query('slowThresholdMs') || c.env?.HEALTH_CHECK_SLOW_THRESHOLD_MS || '0', 10);
|
|
41
|
+
const baseUrl = c.req.query('baseUrl')?.trim() || c.env?.HEALTH_CHECK_BASE_URL?.trim() || new URL(c.req.url).origin;
|
|
42
|
+
const transport = c.req.query('transport') || c.env?.HEALTH_CHECK_TRANSPORT || 'internal';
|
|
43
|
+
const cacheBust = parseBoolean(c.req.query('cacheBust')) || transport === 'network';
|
|
44
|
+
const fetchImpl = transport !== 'network'
|
|
30
45
|
? async (input, init) => app.fetch(new Request(input, init), c.env)
|
|
31
46
|
: undefined;
|
|
32
47
|
const result = await runHealthChecks({
|
|
@@ -35,8 +50,10 @@ export function registerHealthRoutes(app) {
|
|
|
35
50
|
check: c.req.query('check') || undefined,
|
|
36
51
|
mode,
|
|
37
52
|
timeoutMs,
|
|
53
|
+
slowThresholdMs,
|
|
38
54
|
includeSamples: parseBoolean(c.req.query('includeSamples')),
|
|
39
55
|
fresh: parseBoolean(c.req.query('fresh')),
|
|
56
|
+
cacheBust,
|
|
40
57
|
fetchImpl,
|
|
41
58
|
});
|
|
42
59
|
return c.json(result);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { withEdgeCache } from '../../utils/cache.js';
|
|
2
|
+
import { handlePlacesSearch } from '../placesHandlers.js';
|
|
3
|
+
export function registerPlacesRoutes(app) {
|
|
4
|
+
app.get('/api/places/search', async (c) => withEdgeCache(c.req.url, {
|
|
5
|
+
ttlSeconds: 60 * 60,
|
|
6
|
+
staleWhileRevalidateSeconds: 60 * 10,
|
|
7
|
+
keyPrefix: 'places-search-v1',
|
|
8
|
+
}, () => handlePlacesSearch(c)));
|
|
9
|
+
}
|
package/dist/bin.js
CHANGED
|
File without changes
|
package/dist/cli/args.js
CHANGED
|
@@ -52,3 +52,24 @@ export function toQueryOptions(options) {
|
|
|
52
52
|
}
|
|
53
53
|
return queryOptions;
|
|
54
54
|
}
|
|
55
|
+
export function findUnknownOption(options, allowedOptions) {
|
|
56
|
+
const allowed = new Set(allowedOptions);
|
|
57
|
+
for (const key of Object.keys(options)) {
|
|
58
|
+
if (!allowed.has(key)) {
|
|
59
|
+
return key;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
export function writeUnknownOptionError(options, allowedOptions, writeErr, hint) {
|
|
65
|
+
const unknownOption = findUnknownOption(options, allowedOptions);
|
|
66
|
+
if (!unknownOption) {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
writeErr(`알 수 없는 옵션: --${unknownOption}`);
|
|
70
|
+
if (hint) {
|
|
71
|
+
writeErr(hint);
|
|
72
|
+
}
|
|
73
|
+
writeErr('도움말: daiso help');
|
|
74
|
+
return true;
|
|
75
|
+
}
|