daiso 1.0.10 → 1.2.0
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 +77 -5
- package/dist/api/actionsProxy.js +3 -0
- package/dist/api/cgvHandlers.js +15 -3
- package/dist/api/configStatus.js +2 -1
- package/dist/api/dtryxHandlers.js +113 -0
- package/dist/api/emart24Handlers.js +6 -2
- package/dist/api/gs25Handlers.js +22 -19
- package/dist/api/handlers.js +13 -5
- package/dist/api/healthCheckDefinitions.js +46 -16
- package/dist/api/healthCheckShape.js +24 -8
- package/dist/api/healthChecks.js +39 -20
- package/dist/api/operationalAuth.js +32 -0
- package/dist/api/routes/dtryxRoutes.js +22 -0
- package/dist/api/routes/healthRoutes.js +19 -11
- package/dist/api/routes/rateLimitStatsRoutes.js +274 -0
- package/dist/api/routes/registerServiceRoutes.js +37 -0
- package/dist/api/sevenelevenHandlers.js +7 -3
- package/dist/cli/commandOptions.js +24 -0
- package/dist/cli/commands/cinema.js +27 -0
- package/dist/cli.js +7 -1
- package/dist/cliHelp.js +3 -337
- package/dist/cliHelpDefinitions.js +369 -0
- package/dist/cliInteractive.js +8 -5
- package/dist/core/errors.js +29 -2
- package/dist/core/registry.js +13 -15
- package/dist/durableObjects/dailyRateLimiter.js +185 -0
- package/dist/durableObjects/rateLimitMetricsStore.js +144 -0
- package/dist/index.js +18 -31
- package/dist/middleware/dailyRateLimit.js +176 -0
- package/dist/pages/openapiFullSpec.js +2 -0
- package/dist/pages/openapiSpecActionParameters.js +429 -0
- package/dist/pages/openapiSpecActions.js +1 -391
- package/dist/pages/openapiSpecComponentsLottecinema.js +2 -2
- package/dist/pages/openapiSpecPathsDtryx.js +148 -0
- package/dist/pages/openapiSpecPathsLotteMart.js +10 -2
- package/dist/pages/promptRetailCinemaText.js +9 -1
- package/dist/services/cgv/errors.js +13 -0
- package/dist/services/cgv/transport.js +5 -22
- package/dist/services/cu/client.js +44 -30
- package/dist/services/cu/index.js +13 -3
- package/dist/services/cu/tools/checkInventory.js +65 -45
- package/dist/services/cu/tools/findNearbyStores.js +4 -3
- package/dist/services/cu/upstreamError.js +11 -0
- package/dist/services/dtryx/api.js +25 -0
- package/dist/services/dtryx/client.js +133 -0
- package/dist/services/dtryx/index.js +24 -0
- package/dist/services/dtryx/location.js +216 -0
- package/dist/services/dtryx/tools/getRemainingSeats.js +117 -0
- package/dist/services/dtryx/tools/listCinemas.js +46 -0
- package/dist/services/dtryx/tools/listNowShowing.js +77 -0
- package/dist/services/dtryx/types.js +4 -0
- package/dist/services/dtryx/validation.js +21 -0
- package/dist/services/feedback/tools/submitDeveloperRequest.js +15 -1
- package/dist/services/gs25/api.js +2 -2
- package/dist/services/gs25/client.js +16 -123
- package/dist/services/gs25/errors.js +12 -0
- package/dist/services/gs25/index.js +2 -2
- package/dist/services/gs25/productSearch.js +2 -14
- package/dist/services/gs25/storeStockTransport.js +30 -0
- package/dist/services/gs25/tools/checkInventory.js +7 -2
- package/dist/services/gs25/tools/findNearbyStores.js +20 -5
- package/dist/services/lottecinema/client.js +4 -2
- package/dist/services/lottemart/config.js +1 -1
- package/dist/services/lottemart/session.js +33 -201
- package/dist/services/lottemart/socketTransport.js +149 -0
- package/dist/services/oliveyoung/client.js +7 -39
- package/dist/services/oliveyoung/index.js +4 -4
- package/dist/services/oliveyoung/tools/checkInventory.js +6 -3
- package/dist/services/oliveyoung/tools/findNearbyStores.js +4 -3
- package/dist/services/oliveyoung/tools/searchProducts.js +4 -3
- package/dist/services/oliveyoung/transport.js +46 -0
- package/dist/services/seveneleven/client.js +20 -9
- package/dist/services/seveneleven/index.js +11 -7
- package/dist/services/seveneleven/inventory.js +5 -2
- package/dist/services/seveneleven/productKeyword.js +3 -2
- package/dist/services/seveneleven/readCache.js +49 -0
- package/dist/services/seveneleven/tools/checkInventory.js +4 -3
- package/dist/services/seveneleven/tools/getCatalogSnapshot.js +4 -3
- package/dist/services/seveneleven/tools/getSearchPopwords.js +4 -3
- package/dist/services/seveneleven/tools/searchProducts.js +4 -3
- package/dist/services/seveneleven/tools/searchStores.js +4 -3
- package/dist/utils/format.js +10 -3
- package/dist/utils/http.js +40 -12
- package/dist/utils/zyte.js +4 -101
- package/dist/utils/zyteJsonFallback.js +28 -0
- package/openapi.json +51 -3
- package/openapi.yaml +36 -3
- package/package.json +28 -27
|
@@ -17,7 +17,7 @@ export function toCount(data) {
|
|
|
17
17
|
}
|
|
18
18
|
if (record.inventory && typeof record.inventory === 'object') {
|
|
19
19
|
const inventory = record.inventory;
|
|
20
|
-
for (const key of ['products', 'items']) {
|
|
20
|
+
for (const key of ['products', 'items', 'stores']) {
|
|
21
21
|
const value = inventory[key];
|
|
22
22
|
if (Array.isArray(value)) {
|
|
23
23
|
return value.length;
|
|
@@ -37,7 +37,15 @@ export function toFirstName(data) {
|
|
|
37
37
|
continue;
|
|
38
38
|
}
|
|
39
39
|
const item = value[0];
|
|
40
|
-
for (const nameKey of [
|
|
40
|
+
for (const nameKey of [
|
|
41
|
+
'productName',
|
|
42
|
+
'itemName',
|
|
43
|
+
'goodsName',
|
|
44
|
+
'name',
|
|
45
|
+
'storeName',
|
|
46
|
+
'theaterName',
|
|
47
|
+
'movieName',
|
|
48
|
+
]) {
|
|
41
49
|
if (typeof item[nameKey] === 'string' && item[nameKey].trim().length > 0) {
|
|
42
50
|
return item[nameKey].trim();
|
|
43
51
|
}
|
|
@@ -45,13 +53,13 @@ export function toFirstName(data) {
|
|
|
45
53
|
}
|
|
46
54
|
if (record.inventory && typeof record.inventory === 'object') {
|
|
47
55
|
const inventory = record.inventory;
|
|
48
|
-
for (const key of ['products', 'items']) {
|
|
56
|
+
for (const key of ['products', 'items', 'stores']) {
|
|
49
57
|
const value = inventory[key];
|
|
50
58
|
if (!Array.isArray(value) || !value[0] || typeof value[0] !== 'object') {
|
|
51
59
|
continue;
|
|
52
60
|
}
|
|
53
61
|
const item = value[0];
|
|
54
|
-
for (const nameKey of ['productName', 'itemName', 'goodsName', 'name']) {
|
|
62
|
+
for (const nameKey of ['productName', 'itemName', 'goodsName', 'name', 'storeName']) {
|
|
55
63
|
if (typeof item[nameKey] === 'string' && item[nameKey].trim().length > 0) {
|
|
56
64
|
return item[nameKey].trim();
|
|
57
65
|
}
|
|
@@ -65,12 +73,18 @@ function getCollectionItems(data, collectionKey) {
|
|
|
65
73
|
return [];
|
|
66
74
|
}
|
|
67
75
|
const record = data;
|
|
68
|
-
if (collectionKey === 'inventoryProducts' ||
|
|
76
|
+
if (collectionKey === 'inventoryProducts' ||
|
|
77
|
+
collectionKey === 'inventoryItems' ||
|
|
78
|
+
collectionKey === 'inventoryStores') {
|
|
69
79
|
const inventory = record.inventory;
|
|
70
80
|
if (!inventory || typeof inventory !== 'object') {
|
|
71
81
|
return [];
|
|
72
82
|
}
|
|
73
|
-
const key = collectionKey === 'inventoryProducts'
|
|
83
|
+
const key = collectionKey === 'inventoryProducts'
|
|
84
|
+
? 'products'
|
|
85
|
+
: collectionKey === 'inventoryStores'
|
|
86
|
+
? 'stores'
|
|
87
|
+
: 'items';
|
|
74
88
|
const value = inventory[key];
|
|
75
89
|
return Array.isArray(value) ? value : [];
|
|
76
90
|
}
|
|
@@ -92,7 +106,7 @@ export function hasRequiredRepresentativeFields(data, collectionKey, requiredFie
|
|
|
92
106
|
}
|
|
93
107
|
const items = getCollectionItems(data, collectionKey);
|
|
94
108
|
if (items.length === 0) {
|
|
95
|
-
return
|
|
109
|
+
return false;
|
|
96
110
|
}
|
|
97
111
|
const first = items[0];
|
|
98
112
|
if (!first || typeof first !== 'object' || Array.isArray(first)) {
|
|
@@ -101,6 +115,8 @@ export function hasRequiredRepresentativeFields(data, collectionKey, requiredFie
|
|
|
101
115
|
const record = first;
|
|
102
116
|
return requiredFields.some((field) => {
|
|
103
117
|
const value = record[field];
|
|
104
|
-
return typeof value === 'string'
|
|
118
|
+
return typeof value === 'string'
|
|
119
|
+
? value.trim().length > 0
|
|
120
|
+
: value !== undefined && value !== null;
|
|
105
121
|
});
|
|
106
122
|
}
|
package/dist/api/healthChecks.js
CHANGED
|
@@ -65,6 +65,9 @@ function aggregateStatus(checks) {
|
|
|
65
65
|
if (checks.some((check) => check.status === 'degraded')) {
|
|
66
66
|
return 'degraded';
|
|
67
67
|
}
|
|
68
|
+
if (checks.every((check) => check.status === 'skipped')) {
|
|
69
|
+
return 'skipped';
|
|
70
|
+
}
|
|
68
71
|
return 'ok';
|
|
69
72
|
}
|
|
70
73
|
function shouldDegradeFailedResponse(check, message) {
|
|
@@ -82,18 +85,6 @@ function shouldDegradeCliContractPath(path, message) {
|
|
|
82
85
|
}
|
|
83
86
|
return false;
|
|
84
87
|
}
|
|
85
|
-
function resolveCheckTimeoutMs(check, timeoutMs) {
|
|
86
|
-
if (check.timeoutMs === undefined) {
|
|
87
|
-
return timeoutMs;
|
|
88
|
-
}
|
|
89
|
-
return Math.min(timeoutMs, Math.trunc(check.timeoutMs));
|
|
90
|
-
}
|
|
91
|
-
function resolveCliContractTimeoutMs(path, timeoutMs) {
|
|
92
|
-
if (path.startsWith('/api/oliveyoung/')) {
|
|
93
|
-
return Math.min(timeoutMs, 5000);
|
|
94
|
-
}
|
|
95
|
-
return timeoutMs;
|
|
96
|
-
}
|
|
97
88
|
function buildCheckUrl(baseUrl, check, timeoutMs, cacheBustValue) {
|
|
98
89
|
const url = new URL(check.path, baseUrl);
|
|
99
90
|
url.searchParams.set('timeoutMs', String(timeoutMs));
|
|
@@ -131,7 +122,7 @@ async function runCliContractCheck(check, params) {
|
|
|
131
122
|
const cacheBustValue = params.cacheBust ? startedAt : undefined;
|
|
132
123
|
const degradedMessages = [];
|
|
133
124
|
for (const path of CLI_CONTRACT_PATHS) {
|
|
134
|
-
const checkTimeoutMs =
|
|
125
|
+
const checkTimeoutMs = params.timeoutMs;
|
|
135
126
|
const syntheticCheck = { ...check, path };
|
|
136
127
|
try {
|
|
137
128
|
const response = await params.fetchImpl(buildCheckUrl(params.baseUrl, syntheticCheck, checkTimeoutMs, cacheBustValue), {
|
|
@@ -181,7 +172,7 @@ async function runSingleCheck(check, params) {
|
|
|
181
172
|
if (check.kind === 'cli-contract') {
|
|
182
173
|
return runCliContractCheck(check, params);
|
|
183
174
|
}
|
|
184
|
-
const timeoutMs =
|
|
175
|
+
const timeoutMs = params.timeoutMs;
|
|
185
176
|
const startedAt = params.now();
|
|
186
177
|
const cacheBustValue = params.cacheBust ? startedAt : undefined;
|
|
187
178
|
try {
|
|
@@ -202,19 +193,41 @@ async function runSingleCheck(check, params) {
|
|
|
202
193
|
message,
|
|
203
194
|
};
|
|
204
195
|
}
|
|
196
|
+
const inventory = body.data?.inventory;
|
|
197
|
+
if (check.id === 'cu.inventory' && inventory?.available === false) {
|
|
198
|
+
const reason = inventory.unavailableReason;
|
|
199
|
+
return {
|
|
200
|
+
id: check.id,
|
|
201
|
+
service: check.service,
|
|
202
|
+
target: check.target,
|
|
203
|
+
status: 'degraded',
|
|
204
|
+
durationMs,
|
|
205
|
+
httpStatus: response.status,
|
|
206
|
+
message: typeof reason === 'string' && reason.trim() ? reason.trim() : 'inventory unavailable',
|
|
207
|
+
};
|
|
208
|
+
}
|
|
205
209
|
const count = typeof body.meta?.total === 'number' ? body.meta.total : toCount(body.data);
|
|
206
210
|
const shapeOk = hasRequiredRepresentativeFields(body.data, check.collectionKey, check.requiredFields);
|
|
207
211
|
const slowThresholdMs = params.slowThresholdMs || DEFAULT_HEALTH_CHECK_SLOW_THRESHOLD_MS;
|
|
208
212
|
const slow = slowThresholdMs > 0 && durationMs > slowThresholdMs;
|
|
209
|
-
const
|
|
213
|
+
const optionalEmpty = check.allowEmpty === true && count === 0;
|
|
214
|
+
const status = slow
|
|
215
|
+
? 'degraded'
|
|
216
|
+
: optionalEmpty
|
|
217
|
+
? 'skipped'
|
|
218
|
+
: count === null || count === 0 || !shapeOk
|
|
219
|
+
? 'degraded'
|
|
220
|
+
: 'ok';
|
|
210
221
|
const first = params.includeSamples ? toFirstName(body.data) : undefined;
|
|
211
222
|
const message = slow
|
|
212
223
|
? `slow response: ${durationMs}ms > ${slowThresholdMs}ms`
|
|
213
|
-
:
|
|
214
|
-
?
|
|
224
|
+
: optionalEmpty
|
|
225
|
+
? 'optional data unavailable'
|
|
215
226
|
: count === null
|
|
216
|
-
? 'response
|
|
217
|
-
:
|
|
227
|
+
? 'response count unavailable'
|
|
228
|
+
: !shapeOk && count !== 0
|
|
229
|
+
? `response missing required fields: ${check.requiredFields.join(', ')}`
|
|
230
|
+
: `${count} item(s) returned`;
|
|
218
231
|
return {
|
|
219
232
|
id: check.id,
|
|
220
233
|
service: check.service,
|
|
@@ -245,7 +258,13 @@ export async function runHealthChecks(params) {
|
|
|
245
258
|
const slowThresholdMs = Number.isFinite(params.slowThresholdMs) && params.slowThresholdMs && params.slowThresholdMs > 0
|
|
246
259
|
? Math.trunc(params.slowThresholdMs)
|
|
247
260
|
: DEFAULT_HEALTH_CHECK_SLOW_THRESHOLD_MS;
|
|
248
|
-
const cacheKey = createCacheKey({
|
|
261
|
+
const cacheKey = createCacheKey({
|
|
262
|
+
...params,
|
|
263
|
+
mode,
|
|
264
|
+
timeoutMs,
|
|
265
|
+
slowThresholdMs,
|
|
266
|
+
baseUrl: params.baseUrl,
|
|
267
|
+
});
|
|
249
268
|
const cached = healthCheckCache.get(cacheKey);
|
|
250
269
|
const startedAt = now();
|
|
251
270
|
if (!params.fresh && cached && cached.expiresAt > startedAt) {
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const textEncoder = new TextEncoder();
|
|
2
|
+
export function readOperationalToken(headers) {
|
|
3
|
+
const authorization = headers.get('Authorization') || '';
|
|
4
|
+
if (authorization.toLowerCase().startsWith('bearer ')) {
|
|
5
|
+
return authorization.slice('bearer '.length).trim();
|
|
6
|
+
}
|
|
7
|
+
return (headers.get('x-health-check-key') || '').trim();
|
|
8
|
+
}
|
|
9
|
+
async function digestToken(token) {
|
|
10
|
+
return crypto.subtle.digest('SHA-256', textEncoder.encode(token));
|
|
11
|
+
}
|
|
12
|
+
async function compareDigests(left, right) {
|
|
13
|
+
const timingSafeEqual = crypto.subtle.timingSafeEqual;
|
|
14
|
+
if (typeof timingSafeEqual === 'function') {
|
|
15
|
+
return timingSafeEqual.call(crypto.subtle, left, right);
|
|
16
|
+
}
|
|
17
|
+
// Node Web Crypto에는 Workers 확장 메서드가 없으므로 네이티브 HMAC 검증으로 비교한다.
|
|
18
|
+
const key = await crypto.subtle.importKey('raw', right, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']);
|
|
19
|
+
const signature = await crypto.subtle.sign('HMAC', key, right);
|
|
20
|
+
return crypto.subtle.verify('HMAC', key, signature, left);
|
|
21
|
+
}
|
|
22
|
+
export async function authorizeOperationalRequest(headers, configuredSecret) {
|
|
23
|
+
const secret = configuredSecret?.trim();
|
|
24
|
+
if (!secret) {
|
|
25
|
+
return 'not-configured';
|
|
26
|
+
}
|
|
27
|
+
const [requestDigest, secretDigest] = await Promise.all([
|
|
28
|
+
digestToken(readOperationalToken(headers)),
|
|
29
|
+
digestToken(secret),
|
|
30
|
+
]);
|
|
31
|
+
return (await compareDigests(requestDigest, secretDigest)) ? 'authorized' : 'unauthorized';
|
|
32
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 디트릭스 GET API 라우트 등록
|
|
3
|
+
*/
|
|
4
|
+
import { withEdgeCache } from '../../utils/cache.js';
|
|
5
|
+
import { handleDtryxGetRemainingSeats, handleDtryxListCinemas, handleDtryxListNowShowing, } from '../dtryxHandlers.js';
|
|
6
|
+
export function registerDtryxRoutes(app) {
|
|
7
|
+
app.get('/api/dtryx/cinemas', async (c) => withEdgeCache(c.req.url, {
|
|
8
|
+
ttlSeconds: 60 * 60 * 24,
|
|
9
|
+
staleWhileRevalidateSeconds: 60 * 5,
|
|
10
|
+
keyPrefix: 'dtryx-cinemas-v1',
|
|
11
|
+
}, () => handleDtryxListCinemas(c)));
|
|
12
|
+
app.get('/api/dtryx/movies', async (c) => withEdgeCache(c.req.url, {
|
|
13
|
+
ttlSeconds: 60 * 10,
|
|
14
|
+
staleWhileRevalidateSeconds: 60,
|
|
15
|
+
keyPrefix: 'dtryx-movies-v1',
|
|
16
|
+
}, () => handleDtryxListNowShowing(c)));
|
|
17
|
+
app.get('/api/dtryx/seats', async (c) => withEdgeCache(c.req.url, {
|
|
18
|
+
ttlSeconds: 60 * 3,
|
|
19
|
+
staleWhileRevalidateSeconds: 30,
|
|
20
|
+
keyPrefix: 'dtryx-seats-v1',
|
|
21
|
+
}, () => handleDtryxGetRemainingSeats(c)));
|
|
22
|
+
}
|
|
@@ -3,17 +3,11 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { errorResponse } from '../response.js';
|
|
5
5
|
import { runHealthChecks } from '../healthChecks.js';
|
|
6
|
+
import { authorizeOperationalRequest } from '../operationalAuth.js';
|
|
6
7
|
function parseBoolean(value) {
|
|
7
8
|
const normalized = (value || '').trim().toLowerCase();
|
|
8
9
|
return normalized === 'true' || normalized === '1' || normalized === 'y';
|
|
9
10
|
}
|
|
10
|
-
function readHealthCheckToken(headers) {
|
|
11
|
-
const authorization = headers.get('Authorization') || '';
|
|
12
|
-
if (authorization.toLowerCase().startsWith('bearer ')) {
|
|
13
|
-
return authorization.slice('bearer '.length).trim();
|
|
14
|
-
}
|
|
15
|
-
return (headers.get('x-health-check-key') || '').trim();
|
|
16
|
-
}
|
|
17
11
|
function isBetterStackHealthProbe(userAgent) {
|
|
18
12
|
const normalized = (userAgent || '').toLowerCase();
|
|
19
13
|
return normalized.includes('better stack better uptime bot') || normalized === 'nginx-ssl early hints';
|
|
@@ -24,6 +18,13 @@ function isEarlyHintsProbe(userAgent) {
|
|
|
24
18
|
function canForceFresh(headers) {
|
|
25
19
|
return parseBoolean(headers.get('x-health-check-force-fresh') || undefined);
|
|
26
20
|
}
|
|
21
|
+
function isTrustedHealthTarget(target, requestUrl, configuredBaseUrl) {
|
|
22
|
+
const allowedOrigins = new Set([new URL(requestUrl).origin]);
|
|
23
|
+
if (configuredBaseUrl) {
|
|
24
|
+
allowedOrigins.add(new URL(configuredBaseUrl).origin);
|
|
25
|
+
}
|
|
26
|
+
return allowedOrigins.has(new URL(String(target)).origin);
|
|
27
|
+
}
|
|
27
28
|
function parseMode(value) {
|
|
28
29
|
if (!value || value === 'quick') {
|
|
29
30
|
return 'quick';
|
|
@@ -43,7 +44,7 @@ export function registerHealthRoutes(app) {
|
|
|
43
44
|
if (!secret) {
|
|
44
45
|
return errorResponse(c, 'HEALTH_CHECK_SECRET_NOT_CONFIGURED', 'HEALTH_CHECK_SECRET이 설정되어 있지 않습니다.', 503);
|
|
45
46
|
}
|
|
46
|
-
if (
|
|
47
|
+
if ((await authorizeOperationalRequest(c.req.raw.headers, secret)) !== 'authorized') {
|
|
47
48
|
return errorResponse(c, 'UNAUTHORIZED_HEALTH_CHECK', '유효한 헬스 체크 시크릿 키가 필요합니다.', 401);
|
|
48
49
|
}
|
|
49
50
|
const mode = parseMode(c.req.query('mode'));
|
|
@@ -55,9 +56,16 @@ export function registerHealthRoutes(app) {
|
|
|
55
56
|
const baseUrl = c.req.query('baseUrl')?.trim() || c.env?.HEALTH_CHECK_BASE_URL?.trim() || new URL(c.req.url).origin;
|
|
56
57
|
const transport = c.req.query('transport') || c.env?.HEALTH_CHECK_TRANSPORT || 'internal';
|
|
57
58
|
const cacheBust = parseBoolean(c.req.query('cacheBust')) || transport === 'network';
|
|
58
|
-
const fetchImpl =
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
const fetchImpl = async (input, init) => {
|
|
60
|
+
const headers = new Headers(init?.headers);
|
|
61
|
+
if (isTrustedHealthTarget(input, c.req.url, c.env?.HEALTH_CHECK_BASE_URL)) {
|
|
62
|
+
headers.set('x-health-check-key', secret);
|
|
63
|
+
}
|
|
64
|
+
const requestInit = { ...init, headers };
|
|
65
|
+
return transport !== 'network'
|
|
66
|
+
? app.fetch(new Request(input, requestInit), c.env)
|
|
67
|
+
: globalThis.fetch(input, requestInit);
|
|
68
|
+
};
|
|
61
69
|
const result = await runHealthChecks({
|
|
62
70
|
baseUrl,
|
|
63
71
|
service: c.req.query('service') || undefined,
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 인증된 일일 호출 제한 집계 조회 라우트
|
|
3
|
+
*/
|
|
4
|
+
import { RATE_LIMIT_METRICS_LEDGER_NAME, RATE_LIMIT_METRICS_RETENTION_DAYS, RATE_LIMIT_SERVICES, } from '../../durableObjects/rateLimitMetricsStore.js';
|
|
5
|
+
import { authorizeOperationalRequest } from '../operationalAuth.js';
|
|
6
|
+
import { errorResponse, successResponse } from '../response.js';
|
|
7
|
+
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
|
8
|
+
const DEFAULT_RANGE_DAYS = 7;
|
|
9
|
+
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|
10
|
+
const ALLOWED_QUERY_KEYS = new Set(['from', 'to', 'service']);
|
|
11
|
+
function isRecord(value) {
|
|
12
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
13
|
+
}
|
|
14
|
+
function isExactDate(value) {
|
|
15
|
+
if (typeof value !== 'string' || !DATE_PATTERN.test(value)) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
const timestamp = Date.parse(`${value}T00:00:00.000Z`);
|
|
19
|
+
return Number.isFinite(timestamp) && new Date(timestamp).toISOString().slice(0, 10) === value;
|
|
20
|
+
}
|
|
21
|
+
function isRateLimitService(value) {
|
|
22
|
+
return typeof value === 'string' && RATE_LIMIT_SERVICES.includes(value);
|
|
23
|
+
}
|
|
24
|
+
function toKstDay(nowMs) {
|
|
25
|
+
return new Date(nowMs + KST_OFFSET_MS).toISOString().slice(0, 10);
|
|
26
|
+
}
|
|
27
|
+
function addCalendarDays(day, days) {
|
|
28
|
+
const date = new Date(`${day}T00:00:00.000Z`);
|
|
29
|
+
date.setUTCDate(date.getUTCDate() + days);
|
|
30
|
+
return date.toISOString().slice(0, 10);
|
|
31
|
+
}
|
|
32
|
+
function inclusiveDayCount(from, to) {
|
|
33
|
+
const fromMs = Date.parse(`${from}T00:00:00.000Z`);
|
|
34
|
+
const toMs = Date.parse(`${to}T00:00:00.000Z`);
|
|
35
|
+
return (toMs - fromMs) / (24 * 60 * 60 * 1000) + 1;
|
|
36
|
+
}
|
|
37
|
+
function hasStrictQueryShape(searchParams) {
|
|
38
|
+
const counts = new Map();
|
|
39
|
+
for (const [key] of searchParams) {
|
|
40
|
+
if (!ALLOWED_QUERY_KEYS.has(key)) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
const count = (counts.get(key) ?? 0) + 1;
|
|
44
|
+
if (count > 1) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
counts.set(key, count);
|
|
48
|
+
}
|
|
49
|
+
return counts.has('from') === counts.has('to');
|
|
50
|
+
}
|
|
51
|
+
function parseStatsInput(url, nowMs) {
|
|
52
|
+
if (!hasStrictQueryShape(url.searchParams)) {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
const serviceValue = url.searchParams.get('service');
|
|
56
|
+
if (serviceValue !== null && !isRateLimitService(serviceValue)) {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
const currentDay = toKstDay(nowMs);
|
|
60
|
+
const earliestDay = addCalendarDays(currentDay, -(RATE_LIMIT_METRICS_RETENTION_DAYS - 1));
|
|
61
|
+
const fromValue = url.searchParams.get('from');
|
|
62
|
+
const toValue = url.searchParams.get('to');
|
|
63
|
+
const from = fromValue ?? addCalendarDays(currentDay, -(DEFAULT_RANGE_DAYS - 1));
|
|
64
|
+
const to = toValue ?? currentDay;
|
|
65
|
+
if (!isExactDate(from) ||
|
|
66
|
+
!isExactDate(to) ||
|
|
67
|
+
from > to ||
|
|
68
|
+
from < earliestDay ||
|
|
69
|
+
to > currentDay ||
|
|
70
|
+
inclusiveDayCount(from, to) > RATE_LIMIT_METRICS_RETENTION_DAYS) {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
return serviceValue === null
|
|
74
|
+
? { from, to, asOfDay: currentDay }
|
|
75
|
+
: { from, to, asOfDay: currentDay, service: serviceValue };
|
|
76
|
+
}
|
|
77
|
+
function sanitizeAggregate(value) {
|
|
78
|
+
if (!isRecord(value)) {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
const { blockedRequests, uniqueIdentities } = value;
|
|
82
|
+
if (typeof blockedRequests !== 'number' ||
|
|
83
|
+
!Number.isSafeInteger(blockedRequests) ||
|
|
84
|
+
blockedRequests < 0 ||
|
|
85
|
+
typeof uniqueIdentities !== 'number' ||
|
|
86
|
+
!Number.isSafeInteger(uniqueIdentities) ||
|
|
87
|
+
uniqueIdentities < 0 ||
|
|
88
|
+
uniqueIdentities > blockedRequests) {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
return { blockedRequests, uniqueIdentities };
|
|
92
|
+
}
|
|
93
|
+
function sanitizeDailyRows(value, input) {
|
|
94
|
+
if (!Array.isArray(value)) {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
const rows = [];
|
|
98
|
+
let previousDay;
|
|
99
|
+
for (const valueRow of value) {
|
|
100
|
+
if (!isRecord(valueRow)) {
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
const aggregate = sanitizeAggregate(valueRow);
|
|
104
|
+
const { day } = valueRow;
|
|
105
|
+
if (!aggregate ||
|
|
106
|
+
!isExactDate(day) ||
|
|
107
|
+
day < input.from ||
|
|
108
|
+
day > input.to ||
|
|
109
|
+
(previousDay !== undefined && day <= previousDay)) {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
rows.push({ day, ...aggregate });
|
|
113
|
+
previousDay = day;
|
|
114
|
+
}
|
|
115
|
+
return rows;
|
|
116
|
+
}
|
|
117
|
+
function sanitizeServiceRows(value, input) {
|
|
118
|
+
if (!Array.isArray(value)) {
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
const rows = [];
|
|
122
|
+
let previousKey;
|
|
123
|
+
for (const valueRow of value) {
|
|
124
|
+
if (!isRecord(valueRow)) {
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
const aggregate = sanitizeAggregate(valueRow);
|
|
128
|
+
const { day, service } = valueRow;
|
|
129
|
+
const key = `${String(day)}\u0000${String(service)}`;
|
|
130
|
+
if (!aggregate ||
|
|
131
|
+
!isExactDate(day) ||
|
|
132
|
+
day < input.from ||
|
|
133
|
+
day > input.to ||
|
|
134
|
+
!isRateLimitService(service) ||
|
|
135
|
+
(input.service !== undefined && service !== input.service) ||
|
|
136
|
+
(previousKey !== undefined && key <= previousKey)) {
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
rows.push({ day, service, ...aggregate });
|
|
140
|
+
previousKey = key;
|
|
141
|
+
}
|
|
142
|
+
return rows;
|
|
143
|
+
}
|
|
144
|
+
function sumBlockedRequests(rows) {
|
|
145
|
+
let total = 0;
|
|
146
|
+
for (const row of rows) {
|
|
147
|
+
total += row.blockedRequests;
|
|
148
|
+
if (!Number.isSafeInteger(total)) {
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return total;
|
|
153
|
+
}
|
|
154
|
+
function hasValidGroupedRelations(stats, input) {
|
|
155
|
+
const { totals, daily, services } = stats;
|
|
156
|
+
if (totals.blockedRequests === 0) {
|
|
157
|
+
return totals.uniqueIdentities === 0 && daily.length === 0 && services.length === 0;
|
|
158
|
+
}
|
|
159
|
+
const dailyUniqueSum = daily.reduce((sum, row) => sum + row.uniqueIdentities, 0);
|
|
160
|
+
if (totals.uniqueIdentities > dailyUniqueSum ||
|
|
161
|
+
daily.some((row) => row.blockedRequests === 0 ||
|
|
162
|
+
row.uniqueIdentities === 0 ||
|
|
163
|
+
row.uniqueIdentities > totals.uniqueIdentities) ||
|
|
164
|
+
services.some((row) => row.blockedRequests === 0 ||
|
|
165
|
+
row.uniqueIdentities === 0 ||
|
|
166
|
+
row.uniqueIdentities > totals.uniqueIdentities)) {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
const dailyByDay = new Map(daily.map((row) => [row.day, row]));
|
|
170
|
+
const servicesByDay = new Map(daily.map((row) => [row.day, []]));
|
|
171
|
+
for (const row of services) {
|
|
172
|
+
const dailyRow = dailyByDay.get(row.day);
|
|
173
|
+
if (!dailyRow || row.uniqueIdentities > dailyRow.uniqueIdentities) {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
servicesByDay.get(row.day).push(row);
|
|
177
|
+
}
|
|
178
|
+
return daily.every((dailyRow) => {
|
|
179
|
+
const dayServices = servicesByDay.get(dailyRow.day);
|
|
180
|
+
if (input.service !== undefined) {
|
|
181
|
+
const serviceRow = dayServices[0];
|
|
182
|
+
return (dayServices.length === 1 &&
|
|
183
|
+
serviceRow?.service === input.service &&
|
|
184
|
+
serviceRow.blockedRequests === dailyRow.blockedRequests &&
|
|
185
|
+
serviceRow.uniqueIdentities === dailyRow.uniqueIdentities);
|
|
186
|
+
}
|
|
187
|
+
const blockedRequests = dayServices.reduce((sum, row) => sum + row.blockedRequests, 0);
|
|
188
|
+
const uniqueIdentities = dayServices.map((row) => row.uniqueIdentities);
|
|
189
|
+
const uniqueSum = uniqueIdentities.reduce((sum, count) => sum + count, 0);
|
|
190
|
+
const uniqueMaximum = Math.max(0, ...uniqueIdentities);
|
|
191
|
+
return (blockedRequests === dailyRow.blockedRequests &&
|
|
192
|
+
dailyRow.uniqueIdentities >= uniqueMaximum &&
|
|
193
|
+
dailyRow.uniqueIdentities <= uniqueSum);
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
function sanitizeStats(value, input) {
|
|
197
|
+
if (!isRecord(value)) {
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
200
|
+
const totals = sanitizeAggregate(value.totals);
|
|
201
|
+
const daily = sanitizeDailyRows(value.daily, input);
|
|
202
|
+
const services = sanitizeServiceRows(value.services, input);
|
|
203
|
+
if (!totals || !daily || !services) {
|
|
204
|
+
return undefined;
|
|
205
|
+
}
|
|
206
|
+
if (sumBlockedRequests(daily) !== totals.blockedRequests ||
|
|
207
|
+
sumBlockedRequests(services) !== totals.blockedRequests) {
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
210
|
+
const stats = { totals, daily, services };
|
|
211
|
+
return hasValidGroupedRelations(stats, input) ? stats : undefined;
|
|
212
|
+
}
|
|
213
|
+
function unavailableResponse(c) {
|
|
214
|
+
return errorResponse(c, 'RATE_LIMIT_STATS_UNAVAILABLE', '호출 제한 통계를 조회할 수 없습니다.', 503);
|
|
215
|
+
}
|
|
216
|
+
export function registerRateLimitStatsRoutes(app) {
|
|
217
|
+
app.get('/api/rate-limit/stats', async (c) => {
|
|
218
|
+
const authorization = await authorizeOperationalRequest(c.req.raw.headers, c.env?.HEALTH_CHECK_SECRET);
|
|
219
|
+
if (authorization === 'not-configured') {
|
|
220
|
+
return errorResponse(c, 'HEALTH_CHECK_SECRET_NOT_CONFIGURED', 'HEALTH_CHECK_SECRET이 설정되어 있지 않습니다.', 503);
|
|
221
|
+
}
|
|
222
|
+
if (authorization === 'unauthorized') {
|
|
223
|
+
return errorResponse(c, 'UNAUTHORIZED_RATE_LIMIT_STATS', '유효한 운영 시크릿 키가 필요합니다.', 401);
|
|
224
|
+
}
|
|
225
|
+
const input = parseStatsInput(new URL(c.req.url), Date.now());
|
|
226
|
+
if (!input) {
|
|
227
|
+
return errorResponse(c, 'INVALID_RATE_LIMIT_STATS_QUERY', '통계 조회 조건이 올바르지 않습니다.', 400);
|
|
228
|
+
}
|
|
229
|
+
const namespace = c.env?.DAILY_RATE_LIMITER;
|
|
230
|
+
if (!namespace) {
|
|
231
|
+
console.error('호출 제한 통계 binding 누락');
|
|
232
|
+
return unavailableResponse(c);
|
|
233
|
+
}
|
|
234
|
+
let response;
|
|
235
|
+
try {
|
|
236
|
+
const id = namespace.idFromName(RATE_LIMIT_METRICS_LEDGER_NAME);
|
|
237
|
+
const stub = namespace.get(id);
|
|
238
|
+
const backendUrl = new URL('https://daily-rate-limit/stats');
|
|
239
|
+
backendUrl.searchParams.set('from', input.from);
|
|
240
|
+
backendUrl.searchParams.set('to', input.to);
|
|
241
|
+
backendUrl.searchParams.set('asOf', input.asOfDay);
|
|
242
|
+
if (input.service !== undefined) {
|
|
243
|
+
backendUrl.searchParams.set('service', input.service);
|
|
244
|
+
}
|
|
245
|
+
response = await stub.fetch(backendUrl.toString(), { method: 'GET' });
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
console.error('호출 제한 통계 Durable Object 호출 실패');
|
|
249
|
+
return unavailableResponse(c);
|
|
250
|
+
}
|
|
251
|
+
if (response.status !== 200) {
|
|
252
|
+
console.error('호출 제한 통계 Durable Object 응답 실패', response.status);
|
|
253
|
+
return unavailableResponse(c);
|
|
254
|
+
}
|
|
255
|
+
let value;
|
|
256
|
+
try {
|
|
257
|
+
value = await response.json();
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
console.error('호출 제한 통계 응답 검증 실패');
|
|
261
|
+
return unavailableResponse(c);
|
|
262
|
+
}
|
|
263
|
+
const stats = sanitizeStats(value, input);
|
|
264
|
+
if (!stats) {
|
|
265
|
+
console.error('호출 제한 통계 응답 검증 실패');
|
|
266
|
+
return unavailableResponse(c);
|
|
267
|
+
}
|
|
268
|
+
if (toKstDay(Date.now()) !== input.asOfDay) {
|
|
269
|
+
console.error('호출 제한 통계 날짜 전환 감지');
|
|
270
|
+
return unavailableResponse(c);
|
|
271
|
+
}
|
|
272
|
+
return successResponse(c, stats);
|
|
273
|
+
});
|
|
274
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 도메인별 GET API 라우트 일괄 등록
|
|
3
|
+
*
|
|
4
|
+
* `src/index.ts` 의 길이 제한(450줄)을 유지하기 위해 분리했습니다.
|
|
5
|
+
*/
|
|
6
|
+
import { registerCgvRoutes } from './cgvRoutes.js';
|
|
7
|
+
import { registerCompareRoutes } from './compareRoutes.js';
|
|
8
|
+
import { registerCuRoutes } from './cuRoutes.js';
|
|
9
|
+
import { registerDaisoRoutes } from './daisoRoutes.js';
|
|
10
|
+
import { registerDtryxRoutes } from './dtryxRoutes.js';
|
|
11
|
+
import { registerEmart24Routes } from './emart24Routes.js';
|
|
12
|
+
import { registerFeedbackRoutes } from './feedbackRoutes.js';
|
|
13
|
+
import { registerGs25Routes } from './gs25Routes.js';
|
|
14
|
+
import { registerLotteCinemaRoutes } from './lottecinemaRoutes.js';
|
|
15
|
+
import { registerLotteMartRoutes } from './lottemartRoutes.js';
|
|
16
|
+
import { registerMegaboxRoutes } from './megaboxRoutes.js';
|
|
17
|
+
import { registerOliveyoungRoutes } from './oliveyoungRoutes.js';
|
|
18
|
+
import { registerOpinetRoutes } from './opinetRoutes.js';
|
|
19
|
+
import { registerPlacesRoutes } from './placesRoutes.js';
|
|
20
|
+
import { registerSevenElevenRoutes } from './sevenelevenRoutes.js';
|
|
21
|
+
export function registerServiceRoutes(app) {
|
|
22
|
+
registerDaisoRoutes(app);
|
|
23
|
+
registerGs25Routes(app);
|
|
24
|
+
registerSevenElevenRoutes(app);
|
|
25
|
+
registerCompareRoutes(app);
|
|
26
|
+
registerFeedbackRoutes(app);
|
|
27
|
+
registerPlacesRoutes(app);
|
|
28
|
+
registerOpinetRoutes(app);
|
|
29
|
+
registerCuRoutes(app);
|
|
30
|
+
registerEmart24Routes(app);
|
|
31
|
+
registerLotteMartRoutes(app);
|
|
32
|
+
registerOliveyoungRoutes(app);
|
|
33
|
+
registerDtryxRoutes(app);
|
|
34
|
+
registerMegaboxRoutes(app);
|
|
35
|
+
registerLotteCinemaRoutes(app);
|
|
36
|
+
registerCgvRoutes(app);
|
|
37
|
+
}
|
|
@@ -36,7 +36,7 @@ export async function handleSevenElevenSearchProducts(c) {
|
|
|
36
36
|
page,
|
|
37
37
|
size,
|
|
38
38
|
sort,
|
|
39
|
-
});
|
|
39
|
+
}, { zyteApiKey: c.env?.ZYTE_API_KEY });
|
|
40
40
|
return successResponse(c, {
|
|
41
41
|
query: result.query,
|
|
42
42
|
count: result.products.length,
|
|
@@ -69,7 +69,7 @@ export async function handleSevenElevenSearchStores(c) {
|
|
|
69
69
|
const result = await fetchSevenElevenStoresByKeyword({
|
|
70
70
|
keyword,
|
|
71
71
|
limit: safeLimit,
|
|
72
|
-
});
|
|
72
|
+
}, { zyteApiKey: c.env?.ZYTE_API_KEY });
|
|
73
73
|
return successResponse(c, {
|
|
74
74
|
keyword: result.query,
|
|
75
75
|
count: result.stores.length,
|
|
@@ -107,6 +107,7 @@ export async function handleSevenElevenCheckInventory(c) {
|
|
|
107
107
|
storeLimit: safeStoreLimit,
|
|
108
108
|
}, {
|
|
109
109
|
timeout: safeTimeoutMs,
|
|
110
|
+
zyteApiKey: c.env?.ZYTE_API_KEY,
|
|
110
111
|
});
|
|
111
112
|
const note = result.stockAvailable
|
|
112
113
|
? '실시간 재고 데이터가 포함되어 있습니다.'
|
|
@@ -143,7 +144,9 @@ export async function handleSevenElevenCheckInventory(c) {
|
|
|
143
144
|
export async function handleSevenElevenGetSearchPopwords(c) {
|
|
144
145
|
const label = c.req.query('label') || 'home';
|
|
145
146
|
try {
|
|
146
|
-
const keywords = await fetchSevenElevenSearchPopwords(label
|
|
147
|
+
const keywords = await fetchSevenElevenSearchPopwords(label, {
|
|
148
|
+
zyteApiKey: c.env?.ZYTE_API_KEY,
|
|
149
|
+
});
|
|
147
150
|
return successResponse(c, {
|
|
148
151
|
label,
|
|
149
152
|
available: keywords.length > 0,
|
|
@@ -171,6 +174,7 @@ export async function handleSevenElevenGetCatalogSnapshot(c) {
|
|
|
171
174
|
const result = await fetchSevenElevenCatalogSnapshot({
|
|
172
175
|
includeIssues,
|
|
173
176
|
includeExhibition,
|
|
177
|
+
zyteApiKey: c.env?.ZYTE_API_KEY,
|
|
174
178
|
});
|
|
175
179
|
return successResponse(c, {
|
|
176
180
|
options: {
|