@sayren/store-sdk 0.1.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/dist/index.d.mts +5338 -0
- package/dist/index.mjs +2300 -0
- package/dist/server.d.mts +59 -0
- package/dist/server.mjs +125 -0
- package/dist/webhook-headers-D6QmPdEJ.d.mts +22 -0
- package/dist/webhook-headers-ap_exsb9.mjs +22 -0
- package/package.json +48 -0
- package/src/base64.ts +28 -0
- package/src/client.ts +877 -0
- package/src/http.ts +194 -0
- package/src/index.ts +35 -0
- package/src/oauth.ts +132 -0
- package/src/schemas/api-client.ts +121 -0
- package/src/schemas/api-log.ts +104 -0
- package/src/schemas/auth.ts +81 -0
- package/src/schemas/category.ts +98 -0
- package/src/schemas/claim.ts +89 -0
- package/src/schemas/common.ts +61 -0
- package/src/schemas/misc.ts +298 -0
- package/src/schemas/order.ts +233 -0
- package/src/schemas/payment.ts +582 -0
- package/src/schemas/product.ts +272 -0
- package/src/schemas/scope.ts +75 -0
- package/src/schemas/store.ts +143 -0
- package/src/schemas/upload.ts +23 -0
- package/src/schemas/webhook-event.ts +145 -0
- package/src/server.ts +24 -0
- package/src/session.ts +249 -0
- package/src/webhook-headers.ts +21 -0
- package/src/webhook-signature.ts +177 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,2300 @@
|
|
|
1
|
+
import { a as WEBHOOK_HEADERS, i as WEBHOOK_EVENT_TYPE_HEADER, n as WEBHOOK_DELIVERY_ID_HEADER, o as WEBHOOK_SIGNATURE_HEADER, r as WEBHOOK_EVENT_ID_HEADER, s as WEBHOOK_USER_AGENT, t as WEBHOOK_DEFAULT_TOLERANCE_SEC } from "./webhook-headers-ap_exsb9.mjs";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
//#region src/base64.ts
|
|
4
|
+
/**
|
|
5
|
+
* base64 인코딩 헬퍼 — 웹 표준(`btoa` + `TextEncoder`)만 사용한다.
|
|
6
|
+
*
|
|
7
|
+
* 이 패키지는 브라우저/서버 공용 entrypoint 하나로 배포되므로 Node 전용 `Buffer`에
|
|
8
|
+
* 의존하지 않는다. `btoa`는 Latin-1 코드유닛만 받기 때문에 문자열은 반드시
|
|
9
|
+
* UTF-8 바이트로 먼저 인코딩한 뒤 넘긴다 — 그래야 non-ASCII 입력에서도
|
|
10
|
+
* `Buffer.from(text, "utf8").toString("base64")`와 동일한 결과가 나온다.
|
|
11
|
+
*/
|
|
12
|
+
/** 바이트 배열 → 표준 base64 (패딩 포함) */
|
|
13
|
+
function base64FromBytes(bytes) {
|
|
14
|
+
let binary = "";
|
|
15
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
16
|
+
return btoa(binary);
|
|
17
|
+
}
|
|
18
|
+
/** 바이트 배열 → base64url (RFC 4648 §5, 패딩 없음) */
|
|
19
|
+
function base64UrlFromBytes(bytes) {
|
|
20
|
+
return base64FromBytes(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* 문자열 → UTF-8 base64. `btoa(text)`를 직접 쓰면 Latin-1로 해석돼
|
|
24
|
+
* non-ASCII에서 잘못된 바이트가 나오거나(예: "café") 예외가 난다(예: "🔑").
|
|
25
|
+
*/
|
|
26
|
+
function base64Utf8(text) {
|
|
27
|
+
return base64FromBytes(new TextEncoder().encode(text));
|
|
28
|
+
}
|
|
29
|
+
//#endregion
|
|
30
|
+
//#region src/http.ts
|
|
31
|
+
/** 표준 엔벨로프의 $.error 상세 */
|
|
32
|
+
const errorDetailSchema = z.object({
|
|
33
|
+
code: z.string(),
|
|
34
|
+
message: z.string(),
|
|
35
|
+
traceId: z.string().optional(),
|
|
36
|
+
/**
|
|
37
|
+
* 에러 코드별 보조 데이터(선택). 예: 관리 API `LIVE_GATE_NOT_MET`·`LIVE_PAYMENTS_DISABLED`의
|
|
38
|
+
* `{ conditions: LiveGateCondition[] }`. 형태는 에러 코드마다 다르므로 소비측이 코드별 스키마로 다시 파싱한다
|
|
39
|
+
*/
|
|
40
|
+
details: z.record(z.string(), z.unknown()).optional().catch(void 0)
|
|
41
|
+
});
|
|
42
|
+
/** 표준 응답 엔벨로프 — $.meta / $.data / $.error */
|
|
43
|
+
const envelopeSchema = z.object({
|
|
44
|
+
meta: z.object({
|
|
45
|
+
status: z.number(),
|
|
46
|
+
code: z.string(),
|
|
47
|
+
message: z.string(),
|
|
48
|
+
isSuccess: z.boolean()
|
|
49
|
+
}),
|
|
50
|
+
data: z.unknown().optional(),
|
|
51
|
+
/** 구형 필드 — data로 개명되기 전 응답과의 전환기 호환 */
|
|
52
|
+
content: z.unknown().optional(),
|
|
53
|
+
error: errorDetailSchema.nullable()
|
|
54
|
+
});
|
|
55
|
+
/** OAuth 프로토콜 표면의 평면 에러 (RFC 6749 §5.2, RFC 7591 §3.2.2) */
|
|
56
|
+
const oauthErrorSchema = z.object({
|
|
57
|
+
error: z.string(),
|
|
58
|
+
error_description: z.string().optional()
|
|
59
|
+
});
|
|
60
|
+
/** 표준 엔벨로프면 $.data(구형 $.content)를, 아니면(평면 응답) 본문 전체를 반환 */
|
|
61
|
+
function unwrapData(json) {
|
|
62
|
+
const envelope = envelopeSchema.safeParse(json);
|
|
63
|
+
if (!envelope.success) return json;
|
|
64
|
+
return envelope.data.data ?? envelope.data.content ?? null;
|
|
65
|
+
}
|
|
66
|
+
var ApiError = class extends Error {
|
|
67
|
+
status;
|
|
68
|
+
code;
|
|
69
|
+
traceId;
|
|
70
|
+
body;
|
|
71
|
+
details;
|
|
72
|
+
constructor(status, code, message, traceId, body, details) {
|
|
73
|
+
super(message);
|
|
74
|
+
this.status = status;
|
|
75
|
+
this.code = code;
|
|
76
|
+
this.traceId = traceId;
|
|
77
|
+
this.body = body;
|
|
78
|
+
this.details = details;
|
|
79
|
+
this.name = "ApiError";
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* 에러 응답 본문 → ApiError 구성 (에러 형태의 단일 원천).
|
|
84
|
+
* 표준 엔벨로프($.error) 우선, 구형 평면 에러({ code, message }), OAuth 평면 에러
|
|
85
|
+
* ({ error, error_description } → code=error, message=error_description) 순으로 폴백하고,
|
|
86
|
+
* 모두 아니면 UNKNOWN. createHttp의 send와 세션(userinfo 등 raw fetch 경로)이 동일한 에러 모양을 내도록 공유한다.
|
|
87
|
+
*/
|
|
88
|
+
function toApiError(status, raw) {
|
|
89
|
+
const envelope = envelopeSchema.safeParse(raw);
|
|
90
|
+
const detail = envelope.success ? envelope.data.error : errorDetailSchema.safeParse(raw).data ?? null;
|
|
91
|
+
if (detail) return new ApiError(status, detail.code, detail.message, detail.traceId, raw, detail.details);
|
|
92
|
+
const oauth = envelope.success ? null : oauthErrorSchema.safeParse(raw).data;
|
|
93
|
+
if (oauth) return new ApiError(status, oauth.error, oauth.error_description ?? oauth.error, void 0, raw);
|
|
94
|
+
return new ApiError(status, "UNKNOWN", `HTTP ${status}`, void 0, raw);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* 경로 템플릿 태그 — 보간 값(경로 파라미터)마다 encodeURIComponent를 적용한다.
|
|
98
|
+
* `/`·`?`·`#` 등이 섞인 id가 다른 경로·쿼리로 해석되지 않게 한다. 예: path`/products/${id}`
|
|
99
|
+
*/
|
|
100
|
+
function path(strings, ...params) {
|
|
101
|
+
return strings.reduce((acc, part, i) => acc + part + (i < params.length ? encodeURIComponent(String(params[i])) : ""), "");
|
|
102
|
+
}
|
|
103
|
+
function resolveToken(source) {
|
|
104
|
+
return (typeof source === "function" ? source() : source) ?? null;
|
|
105
|
+
}
|
|
106
|
+
function buildQuery(query) {
|
|
107
|
+
if (!query) return "";
|
|
108
|
+
const params = new URLSearchParams();
|
|
109
|
+
for (const [key, value] of Object.entries(query)) if (value !== void 0) params.set(key, String(value));
|
|
110
|
+
const qs = params.toString();
|
|
111
|
+
return qs ? `?${qs}` : "";
|
|
112
|
+
}
|
|
113
|
+
function createHttp(options) {
|
|
114
|
+
const fetchFn = options.fetch ?? globalThis.fetch;
|
|
115
|
+
async function send(method, path, request, retried = false) {
|
|
116
|
+
const url = `${options.baseUrl}${path}${buildQuery(request?.query)}`;
|
|
117
|
+
const headers = {
|
|
118
|
+
...request?.body !== void 0 ? { "content-type": "application/json" } : {},
|
|
119
|
+
...options.headers?.(),
|
|
120
|
+
...request?.headers
|
|
121
|
+
};
|
|
122
|
+
const response = await fetchFn(url, {
|
|
123
|
+
method,
|
|
124
|
+
headers,
|
|
125
|
+
body: request?.body !== void 0 ? JSON.stringify(request.body) : void 0
|
|
126
|
+
});
|
|
127
|
+
options.onResponse?.(response);
|
|
128
|
+
if (response.status === 401 && !retried && options.refresh) {
|
|
129
|
+
if (await options.refresh()) return send(method, path, request, true);
|
|
130
|
+
}
|
|
131
|
+
if (!response.ok) {
|
|
132
|
+
if (response.status === 401) options.onUnauthorized?.();
|
|
133
|
+
const raw = await response.json().catch(() => null);
|
|
134
|
+
throw toApiError(response.status, raw);
|
|
135
|
+
}
|
|
136
|
+
return response;
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
async request(method, path, schema, request) {
|
|
140
|
+
const json = await (await send(method, path, request)).json();
|
|
141
|
+
return schema.parse(unwrapData(json));
|
|
142
|
+
},
|
|
143
|
+
async requestVoid(method, path, request) {
|
|
144
|
+
await send(method, path, request);
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
//#endregion
|
|
149
|
+
//#region src/schemas/api-client.ts
|
|
150
|
+
/**
|
|
151
|
+
* 앱(OAuth 클라이언트) 관리 — `/v1/store/api-clients` (auth-spec 부록 B-13).
|
|
152
|
+
* 등록은 DCR(`POST /v1/oauth2/register`, RFC 7591)이 담당하고, 이 스키마는 등록 이후의
|
|
153
|
+
* 목록·상세·시크릿 재발급·비활성화 응답 계약이다. 시크릿 해시는 어떤 응답에도 포함되지 않는다.
|
|
154
|
+
*/
|
|
155
|
+
const apiClientTypeSchema = z.enum(["public", "confidential"]).describe("클라이언트 유형. `public`=시크릿 없음(PKCE 로그인용), `confidential`=시크릿 보유(서버 간 연동용)");
|
|
156
|
+
const apiClientStatusSchema = z.enum(["ACTIVE", "REVOKED"]).describe("상태. `REVOKED`는 비활성화된 클라이언트이며 다시 활성화할 수 없다");
|
|
157
|
+
const apiClientSchema = z.object({
|
|
158
|
+
clientId: z.string().describe("클라이언트 ID (`client_id`)"),
|
|
159
|
+
name: z.string().describe("클라이언트 이름 (`client_name`)"),
|
|
160
|
+
type: apiClientTypeSchema,
|
|
161
|
+
status: apiClientStatusSchema,
|
|
162
|
+
redirectUris: z.array(z.string()).describe("authorization_code 리다이렉트 URI 허용 목록 (정확 일치)"),
|
|
163
|
+
scopes: z.array(z.string()).describe("클라이언트가 요청할 수 있는 최대 스코프"),
|
|
164
|
+
grantTypes: z.array(z.string()).describe("허용된 grant_type 목록"),
|
|
165
|
+
secretPrefix: z.string().nullable().describe("시크릿 앞부분(식별용, 인증에 사용할 수 없음). public 클라이언트와 시크릿 앞부분이 기록되기 전에 등록된 클라이언트는 null"),
|
|
166
|
+
createdBy: z.string().nullable().describe("등록자 userId. 등록자 기록이 도입되기 전에 등록된 클라이언트는 null"),
|
|
167
|
+
createdByName: z.string().nullable().describe("등록자 이름 (표시용). 계정이 없으면 null"),
|
|
168
|
+
createdByEmail: z.string().nullable().describe("등록자 이메일 (표시용). 계정이 없으면 null"),
|
|
169
|
+
createdAt: z.string().describe("등록 일시 (ISO 8601)"),
|
|
170
|
+
revokedAt: z.string().nullable().describe("비활성화 일시 (ISO 8601). 활성 상태면 null")
|
|
171
|
+
});
|
|
172
|
+
/** 시크릿 재발급 응답 — 평문 시크릿은 이 응답에서 단 한 번만 노출된다 */
|
|
173
|
+
const apiClientSecretRotatedSchema = apiClientSchema.extend({ clientSecret: z.string().describe("새 클라이언트 시크릿 평문. 이 응답에서만 노출되며 다시 조회할 수 없다") });
|
|
174
|
+
const apiClientListQuerySchema = z.object({ status: apiClientStatusSchema.optional().describe("상태 필터. 생략하면 전체") });
|
|
175
|
+
/**
|
|
176
|
+
* Dynamic Client Registration 요청 (RFC 7591 §2, auth-spec §12) — `POST /v1/oauth2/register`.
|
|
177
|
+
* OAuth 프로토콜 표면이라 필드명이 snake_case이고 응답·등록 오류는 평면 JSON이다.
|
|
178
|
+
* 생략한 필드는 서버 기본값을 따른다(SDK가 기본값을 채워 보내지 않는다).
|
|
179
|
+
*/
|
|
180
|
+
const oauthClientRegistrationRequestSchema = z.object({
|
|
181
|
+
client_name: z.string().min(1).max(80).describe("클라이언트 이름 (1~80자)"),
|
|
182
|
+
token_endpoint_auth_method: z.enum(["none", "client_secret_basic"]).optional().describe("토큰 엔드포인트 인증 방식. `none`=public(시크릿 없음), `client_secret_basic`=confidential. 생략하면 `client_secret_basic`"),
|
|
183
|
+
redirect_uris: z.array(z.string()).max(10).optional().describe("리다이렉트 URI 목록(최대 10개). authorization_code에는 필수이며 https(개발용 루프백 http 제외)만 허용"),
|
|
184
|
+
grant_types: z.array(z.enum([
|
|
185
|
+
"authorization_code",
|
|
186
|
+
"refresh_token",
|
|
187
|
+
"client_credentials",
|
|
188
|
+
"urn:ietf:params:oauth:grant-type:token-exchange"
|
|
189
|
+
])).min(1).optional().describe("허용할 grant_type 목록. 생략하면 `client_credentials`"),
|
|
190
|
+
scope: z.string().optional().describe("공백 구분 스코프. 등록자 보유 스코프 이내여야 한다. 생략하면 confidential은 등록자 스코프, public은 `stores:read`")
|
|
191
|
+
});
|
|
192
|
+
/** Dynamic Client Registration 응답 (RFC 7591 §3.2.1) — client_secret은 이 응답에서만 노출된다 */
|
|
193
|
+
const oauthClientRegistrationResponseSchema = z.object({
|
|
194
|
+
client_id: z.string().describe("발급된 클라이언트 ID"),
|
|
195
|
+
client_secret: z.string().optional().describe("클라이언트 시크릿 평문. confidential만 있으며 이 응답에서만 노출된다"),
|
|
196
|
+
client_secret_expires_at: z.number().int().optional().describe("시크릿 만료 시각(epoch 초). 0은 만료 없음. confidential만 있다"),
|
|
197
|
+
client_id_issued_at: z.number().int().describe("발급 시각 (epoch 초)"),
|
|
198
|
+
client_name: z.string(),
|
|
199
|
+
token_endpoint_auth_method: z.enum(["none", "client_secret_basic"]),
|
|
200
|
+
redirect_uris: z.array(z.string()),
|
|
201
|
+
grant_types: z.array(z.string()),
|
|
202
|
+
scope: z.string().describe("부여된 스코프 (공백 구분)")
|
|
203
|
+
});
|
|
204
|
+
//#endregion
|
|
205
|
+
//#region src/schemas/api-log.ts
|
|
206
|
+
/**
|
|
207
|
+
* API 로그 — `/v1/api-logs`. 스토어가 해석된 관리 API(`/v1/**`) 요청을 7일간 보관한다.
|
|
208
|
+
* 인증에 실패해 스토어를 알 수 없는 요청(401 등)은 기록되지 않는다.
|
|
209
|
+
* Authorization·쿠키 헤더, 요청 본문, 개인정보는 저장하지 않는다.
|
|
210
|
+
*/
|
|
211
|
+
const apiLogPrincipalTypeSchema = z.enum([
|
|
212
|
+
"USER",
|
|
213
|
+
"PAT",
|
|
214
|
+
"CLIENT"
|
|
215
|
+
]).describe("호출 주체 유형. `USER`=콘솔 사용자, `PAT`=API 토큰, `CLIENT`=앱(OAuth 클라이언트)");
|
|
216
|
+
const apiLogPrincipalSchema = z.object({
|
|
217
|
+
type: apiLogPrincipalTypeSchema,
|
|
218
|
+
id: z.string().describe("호출 주체 ID. `USER`=userId, `PAT`=API 토큰 ID, `CLIENT`=clientId"),
|
|
219
|
+
name: z.string().nullable().describe("기록 시점의 표시 이름 (사용자 이름·API 토큰 이름·앱 이름). 알 수 없으면 null")
|
|
220
|
+
});
|
|
221
|
+
const apiLogErrorSchema = z.object({
|
|
222
|
+
code: z.string().describe("에러 코드. 에러 응답 `error.code`와 같다"),
|
|
223
|
+
message: z.string().describe("에러 메시지. 에러 응답 `error.message`와 같다")
|
|
224
|
+
});
|
|
225
|
+
const apiLogSchema = z.object({
|
|
226
|
+
logId: z.string().describe("로그 ID"),
|
|
227
|
+
occurredAt: z.string().describe("요청 일시 (ISO 8601)"),
|
|
228
|
+
method: z.string().describe("HTTP 메서드"),
|
|
229
|
+
path: z.string().describe("요청 경로와 쿼리. token·secret·code·password·email·phone 등 민감한 쿼리 값은 `***`로 가린다"),
|
|
230
|
+
statusCode: z.number().int().describe("응답 HTTP 상태 코드"),
|
|
231
|
+
latencyMs: z.number().int().min(0).describe("처리 시간(ms)"),
|
|
232
|
+
principal: apiLogPrincipalSchema,
|
|
233
|
+
traceId: z.string().nullable().describe("추적 ID. 에러 응답의 `error.traceId`와 같다"),
|
|
234
|
+
error: apiLogErrorSchema.nullable().describe("에러 응답 요약. 4xx·5xx일 때만 값이 있다")
|
|
235
|
+
});
|
|
236
|
+
const apiLogStatusFilterSchema = z.enum([
|
|
237
|
+
"2xx",
|
|
238
|
+
"3xx",
|
|
239
|
+
"4xx",
|
|
240
|
+
"5xx",
|
|
241
|
+
"error"
|
|
242
|
+
]).describe("응답 상태 필터. `error`는 4xx·5xx 전체");
|
|
243
|
+
/** API 로그 조회 쿼리 — `GET /api-logs` */
|
|
244
|
+
const apiLogListQuerySchema = z.object({
|
|
245
|
+
from: z.string().optional().describe("조회 시작 일시 (ISO 8601, 포함). 보관 기간은 7일이다"),
|
|
246
|
+
to: z.string().optional().describe("조회 종료 일시 (ISO 8601, 미포함)"),
|
|
247
|
+
principalType: z.union([z.string(), z.array(z.string())]).transform((value) => (Array.isArray(value) ? value : [value]).flatMap((part) => part.split(",")).map((part) => part.trim()).filter((part) => part.length > 0)).pipe(z.array(apiLogPrincipalTypeSchema).min(1)).optional().describe("호출 주체 유형 필터. 여러 개는 쉼표로 구분한다 (예: `PAT,CLIENT`). 파싱 결과는 배열로 정규화된다"),
|
|
248
|
+
principalId: z.string().optional().describe("호출 주체 ID 필터"),
|
|
249
|
+
status: apiLogStatusFilterSchema.optional(),
|
|
250
|
+
q: z.string().optional().describe("경로 부분 일치 검색어"),
|
|
251
|
+
cursor: z.string().optional().describe("이전 응답의 `nextCursor`. 생략하면 가장 최신부터"),
|
|
252
|
+
limit: z.coerce.number().int().min(1).max(100).optional().describe("최대 건수 (1~100, 기본 50)")
|
|
253
|
+
});
|
|
254
|
+
/** API 로그 목록 응답 (최신순, 커서 페이지네이션) */
|
|
255
|
+
const apiLogPageSchema = z.object({
|
|
256
|
+
items: z.array(apiLogSchema),
|
|
257
|
+
nextCursor: z.string().nullable().describe("다음 페이지 커서. 마지막 페이지면 null")
|
|
258
|
+
});
|
|
259
|
+
/**
|
|
260
|
+
* API 로그 요약 — `GET /api-logs/summary`. 연동 트래픽(호출 주체 `PAT`·`CLIENT`)만 집계하며
|
|
261
|
+
* 콘솔 사용자(`USER`) 요청은 제외한다
|
|
262
|
+
*/
|
|
263
|
+
const apiLogSummarySchema = z.object({
|
|
264
|
+
windowHours: z.number().int().describe("집계 구간(시간). 최근 24시간"),
|
|
265
|
+
total: z.number().int().min(0).describe("구간 내 연동 요청 수 (호출 주체 `PAT`·`CLIENT`, 콘솔 사용자 제외)"),
|
|
266
|
+
errors: z.number().int().min(0).describe("구간 내 연동 요청 중 4xx·5xx 응답 수"),
|
|
267
|
+
serverErrors: z.number().int().min(0).describe("구간 내 연동 요청 중 5xx 응답 수")
|
|
268
|
+
});
|
|
269
|
+
//#endregion
|
|
270
|
+
//#region src/schemas/auth.ts
|
|
271
|
+
const storeRoleSchema = z.enum([
|
|
272
|
+
"OWNER",
|
|
273
|
+
"ADMIN",
|
|
274
|
+
"STAFF"
|
|
275
|
+
]);
|
|
276
|
+
const userTokenResponseSchema = z.object({
|
|
277
|
+
access_token: z.string(),
|
|
278
|
+
token_type: z.literal("Bearer"),
|
|
279
|
+
expires_in: z.number().int(),
|
|
280
|
+
refresh_token: z.string().optional()
|
|
281
|
+
});
|
|
282
|
+
/** client_credentials 응답 (RFC 6749 §4.4.3) — storeToken 직발급, refresh 없음 */
|
|
283
|
+
const clientCredentialsTokenResponseSchema = z.object({
|
|
284
|
+
access_token: z.string(),
|
|
285
|
+
token_type: z.literal("Bearer"),
|
|
286
|
+
expires_in: z.number().int(),
|
|
287
|
+
scope: z.string()
|
|
288
|
+
});
|
|
289
|
+
const storeTokenResponseSchema = z.object({
|
|
290
|
+
access_token: z.string(),
|
|
291
|
+
token_type: z.literal("Bearer"),
|
|
292
|
+
expires_in: z.number().int(),
|
|
293
|
+
storeId: z.string(),
|
|
294
|
+
role: storeRoleSchema,
|
|
295
|
+
scopes: z.array(z.string())
|
|
296
|
+
});
|
|
297
|
+
const myStoreSchema = z.object({
|
|
298
|
+
storeId: z.string(),
|
|
299
|
+
storeName: z.string(),
|
|
300
|
+
storeCode: z.string(),
|
|
301
|
+
role: storeRoleSchema,
|
|
302
|
+
status: z.enum([
|
|
303
|
+
"ACTIVE",
|
|
304
|
+
"SUSPENDED",
|
|
305
|
+
"CLOSED"
|
|
306
|
+
])
|
|
307
|
+
});
|
|
308
|
+
const myAccountSchema = z.object({
|
|
309
|
+
userId: z.string(),
|
|
310
|
+
email: z.email(),
|
|
311
|
+
name: z.string(),
|
|
312
|
+
storeCount: z.number().int(),
|
|
313
|
+
createdAt: z.string()
|
|
314
|
+
});
|
|
315
|
+
/** API 토큰(PAT) — 목록/조회 뷰 (평문·해시 미포함) */
|
|
316
|
+
const apiTokenSchema = z.object({
|
|
317
|
+
tokenId: z.string(),
|
|
318
|
+
name: z.string(),
|
|
319
|
+
prefix: z.string(),
|
|
320
|
+
scopes: z.array(z.string()),
|
|
321
|
+
createdBy: z.string().describe("발급자 userId"),
|
|
322
|
+
createdByName: z.string().nullable().describe("발급자 이름 (표시용). 계정이 없으면 null"),
|
|
323
|
+
createdByEmail: z.string().nullable().describe("발급자 이메일 (표시용). 계정이 없으면 null"),
|
|
324
|
+
createdAt: z.string(),
|
|
325
|
+
expiresAt: z.string().nullable(),
|
|
326
|
+
lastUsedAt: z.string().nullable()
|
|
327
|
+
});
|
|
328
|
+
/** 생성 응답 — 평문 토큰은 이 응답에서 단 한 번만 노출된다 */
|
|
329
|
+
const apiTokenCreatedSchema = apiTokenSchema.extend({ token: z.string() });
|
|
330
|
+
const apiTokenCreateRequestSchema = z.object({
|
|
331
|
+
name: z.string().min(1).max(50),
|
|
332
|
+
scopes: z.array(z.string()).min(1),
|
|
333
|
+
/** 일 단위 만료. null = 무기한, 생략 = 90일 */
|
|
334
|
+
expiresInDays: z.number().int().nullable().optional()
|
|
335
|
+
});
|
|
336
|
+
//#endregion
|
|
337
|
+
//#region src/schemas/category.ts
|
|
338
|
+
const categoryProductSortSchema = z.enum([
|
|
339
|
+
"RECENT",
|
|
340
|
+
"POPULAR",
|
|
341
|
+
"PRICE_LOW",
|
|
342
|
+
"PRICE_HIGH",
|
|
343
|
+
"NAME"
|
|
344
|
+
]);
|
|
345
|
+
const categorySchema = z.object({
|
|
346
|
+
categoryId: z.string(),
|
|
347
|
+
storeId: z.string(),
|
|
348
|
+
name: z.string(),
|
|
349
|
+
parentId: z.string().nullable(),
|
|
350
|
+
path: z.string(),
|
|
351
|
+
depth: z.number().int().min(1).max(4),
|
|
352
|
+
leaf: z.boolean(),
|
|
353
|
+
sortOrder: z.number().int(),
|
|
354
|
+
visible: z.boolean(),
|
|
355
|
+
showPc: z.boolean(),
|
|
356
|
+
showMobile: z.boolean(),
|
|
357
|
+
productSort: categoryProductSortSchema,
|
|
358
|
+
metaTitle: z.string().nullable(),
|
|
359
|
+
metaDescription: z.string().nullable(),
|
|
360
|
+
metaKeywords: z.string().nullable(),
|
|
361
|
+
createdAt: z.string(),
|
|
362
|
+
updatedAt: z.string()
|
|
363
|
+
});
|
|
364
|
+
const categoryTreeNodeSchema = categorySchema.extend({ children: z.lazy(() => z.array(categoryTreeNodeSchema)) });
|
|
365
|
+
const categoryWithPathSchema = categorySchema.extend({
|
|
366
|
+
fullPath: z.string(),
|
|
367
|
+
ancestors: z.array(categorySchema),
|
|
368
|
+
childCount: z.number().int()
|
|
369
|
+
});
|
|
370
|
+
const categorySearchResultSchema = categorySchema.extend({
|
|
371
|
+
fullPath: z.string(),
|
|
372
|
+
ancestors: z.array(categorySchema)
|
|
373
|
+
});
|
|
374
|
+
const categoryAttributeSchema = z.object({
|
|
375
|
+
attributeId: z.string(),
|
|
376
|
+
name: z.string(),
|
|
377
|
+
required: z.boolean(),
|
|
378
|
+
inputType: z.enum([
|
|
379
|
+
"SELECT",
|
|
380
|
+
"MULTI_SELECT",
|
|
381
|
+
"TEXT",
|
|
382
|
+
"NUMBER"
|
|
383
|
+
]),
|
|
384
|
+
unit: z.string().nullable(),
|
|
385
|
+
allowedValues: z.array(z.string()).optional()
|
|
386
|
+
});
|
|
387
|
+
const categoryCreateRequestSchema = z.object({
|
|
388
|
+
name: z.string().min(1).max(100),
|
|
389
|
+
parentId: z.string().nullable().optional(),
|
|
390
|
+
sortOrder: z.number().int().optional(),
|
|
391
|
+
visible: z.boolean().optional(),
|
|
392
|
+
showPc: z.boolean().optional(),
|
|
393
|
+
showMobile: z.boolean().optional(),
|
|
394
|
+
productSort: categoryProductSortSchema.optional(),
|
|
395
|
+
metaTitle: z.string().max(100).nullable().optional(),
|
|
396
|
+
metaDescription: z.string().max(300).nullable().optional(),
|
|
397
|
+
metaKeywords: z.string().max(200).nullable().optional()
|
|
398
|
+
});
|
|
399
|
+
const categoryPatchRequestSchema = z.object({
|
|
400
|
+
name: z.string().min(1).max(100).optional(),
|
|
401
|
+
parentId: z.string().nullable().optional(),
|
|
402
|
+
sortOrder: z.number().int().optional(),
|
|
403
|
+
visible: z.boolean().optional(),
|
|
404
|
+
showPc: z.boolean().optional(),
|
|
405
|
+
showMobile: z.boolean().optional(),
|
|
406
|
+
productSort: categoryProductSortSchema.optional(),
|
|
407
|
+
metaTitle: z.string().max(100).nullable().optional(),
|
|
408
|
+
metaDescription: z.string().max(300).nullable().optional(),
|
|
409
|
+
metaKeywords: z.string().max(200).nullable().optional()
|
|
410
|
+
});
|
|
411
|
+
const categoryDeleteStrategySchema = z.enum(["reparent", "cascade"]);
|
|
412
|
+
//#endregion
|
|
413
|
+
//#region src/schemas/claim.ts
|
|
414
|
+
const claimTypeSchema = z.enum([
|
|
415
|
+
"CANCEL",
|
|
416
|
+
"RETURN",
|
|
417
|
+
"EXCHANGE"
|
|
418
|
+
]);
|
|
419
|
+
const claimStatusSchema = z.enum([
|
|
420
|
+
"REQUESTED",
|
|
421
|
+
"HELD",
|
|
422
|
+
"COLLECTING",
|
|
423
|
+
"COLLECTED",
|
|
424
|
+
"REDELIVERING",
|
|
425
|
+
"APPROVED",
|
|
426
|
+
"REJECTED",
|
|
427
|
+
"COMPLETED",
|
|
428
|
+
"WITHDRAWN"
|
|
429
|
+
]);
|
|
430
|
+
const claimReasonSchema = z.enum([
|
|
431
|
+
"CHANGE_OF_MIND",
|
|
432
|
+
"WRONG_ORDER",
|
|
433
|
+
"DEFECTIVE",
|
|
434
|
+
"WRONG_DELIVERY",
|
|
435
|
+
"DIFFERENT_FROM_PAGE",
|
|
436
|
+
"DELAY"
|
|
437
|
+
]);
|
|
438
|
+
const claimSchema = z.object({
|
|
439
|
+
claimId: z.string(),
|
|
440
|
+
type: claimTypeSchema,
|
|
441
|
+
status: claimStatusSchema,
|
|
442
|
+
orderId: z.string(),
|
|
443
|
+
orderItemId: z.string(),
|
|
444
|
+
productName: z.string(),
|
|
445
|
+
quantity: z.number().int(),
|
|
446
|
+
reason: claimReasonSchema,
|
|
447
|
+
reasonDetail: z.string().nullable(),
|
|
448
|
+
responsibility: z.enum(["BUYER", "SELLER"]),
|
|
449
|
+
claimDeliveryFee: z.number().int(),
|
|
450
|
+
expectedRefundAmount: z.number().int().nullable(),
|
|
451
|
+
exchangeOption: z.object({
|
|
452
|
+
optionId: z.string(),
|
|
453
|
+
optionName: z.string()
|
|
454
|
+
}).nullable(),
|
|
455
|
+
collect: z.object({
|
|
456
|
+
method: z.enum(["SELLER_PICKUP", "BUYER_SEND"]),
|
|
457
|
+
carrierCode: z.string().nullable(),
|
|
458
|
+
trackingNumber: z.string().nullable()
|
|
459
|
+
}).nullable(),
|
|
460
|
+
requestedAt: z.string(),
|
|
461
|
+
completedAt: z.string().nullable()
|
|
462
|
+
});
|
|
463
|
+
const rejectClaimRequestSchema = z.object({
|
|
464
|
+
reason: z.enum([
|
|
465
|
+
"USED_PRODUCT",
|
|
466
|
+
"MISSING_COMPONENT",
|
|
467
|
+
"PERIOD_EXPIRED",
|
|
468
|
+
"BUYER_FAULT_DAMAGE",
|
|
469
|
+
"ETC"
|
|
470
|
+
]),
|
|
471
|
+
detail: z.string().min(10).max(1e3),
|
|
472
|
+
evidenceImages: z.array(z.url()).max(5).optional()
|
|
473
|
+
});
|
|
474
|
+
const approveClaimResultSchema = z.object({
|
|
475
|
+
claimId: z.string(),
|
|
476
|
+
refundAmount: z.number().int(),
|
|
477
|
+
deductAmount: z.number().int(),
|
|
478
|
+
refundedAt: z.string()
|
|
479
|
+
});
|
|
480
|
+
//#endregion
|
|
481
|
+
//#region src/schemas/common.ts
|
|
482
|
+
const pageInfoSchema = z.object({
|
|
483
|
+
page: z.number().int(),
|
|
484
|
+
size: z.number().int(),
|
|
485
|
+
totalElements: z.number().int(),
|
|
486
|
+
totalPages: z.number().int()
|
|
487
|
+
});
|
|
488
|
+
function pageSchema(item) {
|
|
489
|
+
return pageInfoSchema.extend({ contents: z.array(item) });
|
|
490
|
+
}
|
|
491
|
+
const bulkResultSchema = z.object({
|
|
492
|
+
succeeded: z.array(z.string()),
|
|
493
|
+
failed: z.array(z.object({
|
|
494
|
+
id: z.string().optional(),
|
|
495
|
+
productId: z.string().optional(),
|
|
496
|
+
orderItemId: z.string().optional(),
|
|
497
|
+
code: z.string(),
|
|
498
|
+
message: z.string()
|
|
499
|
+
}))
|
|
500
|
+
});
|
|
501
|
+
const orderItemStatusSchema = z.enum([
|
|
502
|
+
"PAYMENT_WAITING",
|
|
503
|
+
"PAID",
|
|
504
|
+
"CONFIRMED",
|
|
505
|
+
"DISPATCHED",
|
|
506
|
+
"DELIVERING",
|
|
507
|
+
"DELIVERED",
|
|
508
|
+
"PURCHASE_DECIDED",
|
|
509
|
+
"CANCELED"
|
|
510
|
+
]);
|
|
511
|
+
const changeTypeSchema = z.enum([
|
|
512
|
+
"ORDER.PAID",
|
|
513
|
+
"ORDER.CONFIRMED",
|
|
514
|
+
"ORDER.DISPATCHED",
|
|
515
|
+
"ORDER.DELIVERED",
|
|
516
|
+
"ORDER.PURCHASE_DECIDED",
|
|
517
|
+
"CLAIM.CANCEL_REQUESTED",
|
|
518
|
+
"CLAIM.RETURN_REQUESTED",
|
|
519
|
+
"CLAIM.EXCHANGE_REQUESTED",
|
|
520
|
+
"ORDER.ADDRESS_CHANGE_REQUESTED"
|
|
521
|
+
]);
|
|
522
|
+
//#endregion
|
|
523
|
+
//#region src/schemas/misc.ts
|
|
524
|
+
const inquirySchema = z.object({
|
|
525
|
+
inquiryId: z.string(),
|
|
526
|
+
productId: z.string(),
|
|
527
|
+
productName: z.string(),
|
|
528
|
+
orderItemId: z.string().nullable(),
|
|
529
|
+
category: z.enum([
|
|
530
|
+
"PRODUCT",
|
|
531
|
+
"DELIVERY",
|
|
532
|
+
"RETURN",
|
|
533
|
+
"EXCHANGE",
|
|
534
|
+
"ETC"
|
|
535
|
+
]),
|
|
536
|
+
content: z.string(),
|
|
537
|
+
secret: z.boolean(),
|
|
538
|
+
writerMaskedName: z.string(),
|
|
539
|
+
answered: z.boolean(),
|
|
540
|
+
answer: z.object({
|
|
541
|
+
content: z.string(),
|
|
542
|
+
answeredAt: z.string(),
|
|
543
|
+
modifiedCount: z.number().int().max(1)
|
|
544
|
+
}).nullable(),
|
|
545
|
+
createdAt: z.string()
|
|
546
|
+
});
|
|
547
|
+
const customerInquiryCategorySchema = z.enum([
|
|
548
|
+
"DELIVERY",
|
|
549
|
+
"RETURN",
|
|
550
|
+
"EXCHANGE",
|
|
551
|
+
"PAYMENT",
|
|
552
|
+
"ETC"
|
|
553
|
+
]);
|
|
554
|
+
/** 셀러 뷰 1:1 문의 — 작성자 마스킹명 + 답변 수정횟수 포함 */
|
|
555
|
+
const sellerCustomerInquirySchema = z.object({
|
|
556
|
+
inquiryId: z.string(),
|
|
557
|
+
memberMaskedName: z.string(),
|
|
558
|
+
category: customerInquiryCategorySchema,
|
|
559
|
+
title: z.string(),
|
|
560
|
+
content: z.string(),
|
|
561
|
+
orderItemId: z.string().nullable(),
|
|
562
|
+
answered: z.boolean(),
|
|
563
|
+
answer: z.object({
|
|
564
|
+
content: z.string(),
|
|
565
|
+
answeredAt: z.string(),
|
|
566
|
+
modifiedCount: z.number().int().max(1)
|
|
567
|
+
}).nullable(),
|
|
568
|
+
createdAt: z.string()
|
|
569
|
+
});
|
|
570
|
+
const settlementStatusSchema = z.enum([
|
|
571
|
+
"SCHEDULED",
|
|
572
|
+
"PAID",
|
|
573
|
+
"HELD"
|
|
574
|
+
]);
|
|
575
|
+
const settlementSummarySchema = z.object({
|
|
576
|
+
settlementId: z.string(),
|
|
577
|
+
settlementDate: z.string(),
|
|
578
|
+
payoutDate: z.string().nullable(),
|
|
579
|
+
status: settlementStatusSchema,
|
|
580
|
+
totalPaymentAmount: z.number().int(),
|
|
581
|
+
totalCommission: z.number().int(),
|
|
582
|
+
totalDeliveryFee: z.number().int(),
|
|
583
|
+
adjustmentAmount: z.number().int(),
|
|
584
|
+
settlementAmount: z.number().int(),
|
|
585
|
+
itemCount: z.number().int()
|
|
586
|
+
});
|
|
587
|
+
const settlementDetailSchema = z.object({
|
|
588
|
+
orderItemId: z.string(),
|
|
589
|
+
productName: z.string(),
|
|
590
|
+
settlementType: z.enum([
|
|
591
|
+
"SALE",
|
|
592
|
+
"REFUND_DEDUCTION",
|
|
593
|
+
"ADJUSTMENT"
|
|
594
|
+
]),
|
|
595
|
+
paymentAmount: z.number().int(),
|
|
596
|
+
salesCommission: z.number().int(),
|
|
597
|
+
paymentCommission: z.number().int(),
|
|
598
|
+
commissionRate: z.number(),
|
|
599
|
+
deliveryFee: z.number().int(),
|
|
600
|
+
settlementAmount: z.number().int(),
|
|
601
|
+
purchaseDecidedAt: z.string().nullable()
|
|
602
|
+
});
|
|
603
|
+
const eventTypeSchema = z.enum([
|
|
604
|
+
"ORDER.PAID",
|
|
605
|
+
"ORDER.CONFIRMED",
|
|
606
|
+
"ORDER.DISPATCHED",
|
|
607
|
+
"ORDER.DELIVERED",
|
|
608
|
+
"ORDER.PURCHASE_DECIDED",
|
|
609
|
+
"ORDER.ADDRESS_CHANGE_REQUESTED",
|
|
610
|
+
"CLAIM.CANCEL_REQUESTED",
|
|
611
|
+
"CLAIM.RETURN_REQUESTED",
|
|
612
|
+
"CLAIM.EXCHANGE_REQUESTED",
|
|
613
|
+
"CLAIM.COMPLETED",
|
|
614
|
+
"INQUIRY.CREATED",
|
|
615
|
+
"REVIEW.CREATED",
|
|
616
|
+
"PRODUCT.OUT_OF_STOCK"
|
|
617
|
+
]);
|
|
618
|
+
const webhookStatusSchema = z.enum(["ACTIVE", "SUSPENDED"]).describe("구독 상태. `ACTIVE`=발송 대상, `SUSPENDED`=24시간 이상 연속 전송 실패로 발송이 정지된 상태. 정지 중 발생한 이벤트는 적재하지 않는다. 재개(`POST /webhooks/{id}/resume`)하거나 구독을 수정하면 ACTIVE로 돌아간다");
|
|
619
|
+
const webhookSchema = z.object({
|
|
620
|
+
webhookId: z.string(),
|
|
621
|
+
url: z.url(),
|
|
622
|
+
eventTypes: z.array(eventTypeSchema),
|
|
623
|
+
status: webhookStatusSchema,
|
|
624
|
+
secretPrefix: z.string().describe("서명 시크릿 앞부분(식별용, 서명 검증에 사용할 수 없음). 예: `whsec_1a2b`"),
|
|
625
|
+
suspendedAt: z.string().nullable().describe("발송이 정지된 일시 (ISO 8601). `ACTIVE`면 null"),
|
|
626
|
+
createdAt: z.string()
|
|
627
|
+
});
|
|
628
|
+
/** 등록 응답 — 서명 시크릿 평문은 이 응답에서만 노출된다 */
|
|
629
|
+
const webhookCreatedSchema = webhookSchema.extend({ secret: z.string().describe("서명 시크릿 평문. 등록 응답에서만 노출되며 다시 조회할 수 없다") });
|
|
630
|
+
/** 시크릿 교체 응답 */
|
|
631
|
+
const webhookSecretRotatedSchema = z.object({
|
|
632
|
+
secret: z.string().describe("새 서명 시크릿 평문. 이 응답에서만 노출된다"),
|
|
633
|
+
oldSecretExpiresAt: z.string().describe("기존 시크릿의 병행 유효 종료 일시 (ISO 8601). 이때까지는 기존 시크릿 서명도 함께 붙는다")
|
|
634
|
+
});
|
|
635
|
+
const webhookDeliveryStatusSchema = z.enum([
|
|
636
|
+
"PENDING",
|
|
637
|
+
"PENDING_RETRY",
|
|
638
|
+
"SUCCESS",
|
|
639
|
+
"FAILED"
|
|
640
|
+
]).describe("전송 상태. `PENDING`=첫 시도 전, `PENDING_RETRY`=실패 후 재시도 대기, `SUCCESS`=2xx 응답 수신, `FAILED`=재시도를 모두 소진했거나 구독 정지로 종료");
|
|
641
|
+
const webhookDeliveryErrorCodeSchema = z.enum([
|
|
642
|
+
"TIMEOUT",
|
|
643
|
+
"CONNECTION_FAILED",
|
|
644
|
+
"BLOCKED_ADDRESS",
|
|
645
|
+
"NON_2XX",
|
|
646
|
+
"WEBHOOK_SUSPENDED"
|
|
647
|
+
]).describe("전송 실패 사유. `TIMEOUT`=10초 안에 응답 없음, `CONNECTION_FAILED`=연결 실패(DNS·TLS 포함), `BLOCKED_ADDRESS`=사설·루프백 등 전송이 허용되지 않는 주소, `NON_2XX`=2xx가 아닌 응답(리다이렉트 포함), `WEBHOOK_SUSPENDED`=구독 정지로 대기 중인 전송이 종료됨");
|
|
648
|
+
/** 웹훅 전송 기록 한 건 (전송 기록 조회 항목) */
|
|
649
|
+
const webhookDeliverySchema = z.object({
|
|
650
|
+
deliveryId: z.string().describe("전송 기록 ID. 수신 요청의 `Sayren-Delivery-Id` 헤더 값"),
|
|
651
|
+
eventId: z.string().describe("이벤트 ID. 재시도·재전송해도 같은 이벤트면 같은 값이다. 수신 측 중복 제거 키로 쓴다"),
|
|
652
|
+
eventType: eventTypeSchema,
|
|
653
|
+
url: z.string().describe("전송 시점의 대상 URL"),
|
|
654
|
+
status: webhookDeliveryStatusSchema,
|
|
655
|
+
responseStatusCode: z.number().int().nullable().describe("마지막 시도의 HTTP 응답 코드. 미시도·연결 실패·타임아웃·차단이면 null"),
|
|
656
|
+
responseTimeMs: z.number().int().nullable().describe("마지막 시도의 응답 시간(ms). 미시도·연결 실패면 null"),
|
|
657
|
+
responseBodyExcerpt: z.string().nullable().describe("마지막 시도의 응답 본문 앞부분(최대 1,024자). 응답이 없으면 null"),
|
|
658
|
+
errorCode: webhookDeliveryErrorCodeSchema.nullable().describe("마지막 시도의 실패 사유. 성공했거나 미시도면 null"),
|
|
659
|
+
attemptCount: z.number().int().min(0).describe("지금까지의 시도 횟수. 최초 시도 1회 + 재시도 최대 4회"),
|
|
660
|
+
createdAt: z.string().describe("전송 기록이 적재된 일시 (ISO 8601)"),
|
|
661
|
+
attemptedAt: z.string().nullable().describe("마지막 시도 일시 (ISO 8601). 미시도면 null"),
|
|
662
|
+
nextAttemptAt: z.string().nullable().describe("다음 시도 예정 일시 (ISO 8601). `PENDING`·`PENDING_RETRY`일 때만 값이 있다"),
|
|
663
|
+
redeliveryOf: z.string().nullable().describe("수동 재전송으로 생긴 기록이면 원본 전송 기록 ID. 아니면 null"),
|
|
664
|
+
redeliveredAs: z.string().nullable().describe("이 기록을 원본으로 수동 재전송해 생긴 기록의 전송 기록 ID. 아직 재전송하지 않았으면 null. 재전송은 원본당 1회다")
|
|
665
|
+
});
|
|
666
|
+
/** 전송 기록 조회 쿼리 — `GET /webhooks/{id}/deliveries` */
|
|
667
|
+
const webhookDeliveryListQuerySchema = z.object({
|
|
668
|
+
status: webhookDeliveryStatusSchema.optional().describe("전송 상태 필터. 생략하면 전체"),
|
|
669
|
+
limit: z.coerce.number().int().min(1).max(100).optional().describe("최대 건수 (1~100, 기본 50). 최신 적재순으로 반환하며 보관 기간은 30일이다")
|
|
670
|
+
});
|
|
671
|
+
/** 실패 건 일괄 재전송 응답 — `POST /webhooks/{id}/deliveries/redeliver-failed` */
|
|
672
|
+
const webhookRedeliverFailedResultSchema = z.object({ queued: z.number().int().min(0).describe("재전송 대기열에 올린 건수. 최근 72시간의 `FAILED` 중 아직 재전송되지 않은 건이 대상이며 최대 500건이다") });
|
|
673
|
+
const webhookTestRequestSchema = z.object({ eventType: eventTypeSchema.describe("샘플 페이로드로 보낼 이벤트 타입") });
|
|
674
|
+
/** 테스트 이벤트 발송 결과 (동기 반환). 재시도·정지 판정·전송 기록이 없다 */
|
|
675
|
+
const webhookTestResultSchema = z.object({
|
|
676
|
+
responseStatusCode: z.number().int().nullable().describe("수신 서버의 HTTP 응답 코드. 연결 실패·타임아웃·차단이면 null"),
|
|
677
|
+
responseTimeMs: z.number().int().nullable().describe("응답 시간(ms). 연결 실패면 null"),
|
|
678
|
+
errorCode: webhookDeliveryErrorCodeSchema.nullable().describe("실패 사유. 2xx 응답이면 null")
|
|
679
|
+
});
|
|
680
|
+
const webhookRequestSchema = z.object({
|
|
681
|
+
url: z.url().startsWith("https://", "웹훅 URL은 HTTPS만 허용됩니다"),
|
|
682
|
+
eventTypes: z.array(eventTypeSchema).min(1)
|
|
683
|
+
});
|
|
684
|
+
const reviewSchema = z.object({
|
|
685
|
+
reviewId: z.string(),
|
|
686
|
+
productId: z.string(),
|
|
687
|
+
productName: z.string(),
|
|
688
|
+
orderItemId: z.string(),
|
|
689
|
+
optionName: z.string().nullable(),
|
|
690
|
+
rating: z.number().int().min(1).max(5),
|
|
691
|
+
content: z.string(),
|
|
692
|
+
images: z.array(z.url()),
|
|
693
|
+
writerMaskedName: z.string(),
|
|
694
|
+
blinded: z.boolean(),
|
|
695
|
+
reply: z.object({
|
|
696
|
+
content: z.string(),
|
|
697
|
+
repliedAt: z.string(),
|
|
698
|
+
updatedAt: z.string().nullable()
|
|
699
|
+
}).nullable(),
|
|
700
|
+
createdAt: z.string()
|
|
701
|
+
});
|
|
702
|
+
//#endregion
|
|
703
|
+
//#region src/schemas/order.ts
|
|
704
|
+
const orderSummarySchema = z.object({
|
|
705
|
+
orderId: z.string(),
|
|
706
|
+
ordererName: z.string(),
|
|
707
|
+
itemCount: z.number().int(),
|
|
708
|
+
totalAmount: z.number().int(),
|
|
709
|
+
representativeStatus: orderItemStatusSchema,
|
|
710
|
+
orderedAt: z.string(),
|
|
711
|
+
testPayment: z.boolean().describe("테스트 결제(샌드박스 결제) 주문인가. true면 실제로 돈이 오가지 않았어요. 상품을 발송하지 않도록 주의하세요")
|
|
712
|
+
});
|
|
713
|
+
const shippingAddressSchema = z.object({
|
|
714
|
+
receiverName: z.string(),
|
|
715
|
+
phone: z.string(),
|
|
716
|
+
zipCode: z.string(),
|
|
717
|
+
address1: z.string(),
|
|
718
|
+
address2: z.string().nullish(),
|
|
719
|
+
deliveryMemo: z.string().nullish(),
|
|
720
|
+
entranceCode: z.string().nullish()
|
|
721
|
+
});
|
|
722
|
+
const orderItemSchema = z.object({
|
|
723
|
+
orderItemId: z.string(),
|
|
724
|
+
orderId: z.string(),
|
|
725
|
+
status: orderItemStatusSchema,
|
|
726
|
+
claimStatus: z.enum([
|
|
727
|
+
"CANCEL_REQUESTED",
|
|
728
|
+
"RETURN_REQUESTED",
|
|
729
|
+
"EXCHANGE_REQUESTED"
|
|
730
|
+
]).nullable(),
|
|
731
|
+
productId: z.string(),
|
|
732
|
+
productName: z.string(),
|
|
733
|
+
optionId: z.string().nullable(),
|
|
734
|
+
optionName: z.string().nullable(),
|
|
735
|
+
sellerCode: z.string().nullable(),
|
|
736
|
+
quantity: z.number().int(),
|
|
737
|
+
unitPrice: z.number().int(),
|
|
738
|
+
discountAmount: z.number().int(),
|
|
739
|
+
totalPrice: z.number().int(),
|
|
740
|
+
hold: z.object({
|
|
741
|
+
reason: z.string(),
|
|
742
|
+
expectedDispatchDate: z.string()
|
|
743
|
+
}).nullable(),
|
|
744
|
+
autoDecisionDate: z.string().nullable(),
|
|
745
|
+
sellerMemo: z.string().nullable(),
|
|
746
|
+
orderedAt: z.string(),
|
|
747
|
+
lastChangedAt: z.string(),
|
|
748
|
+
lastChangedType: changeTypeSchema,
|
|
749
|
+
testPayment: z.boolean().describe("소속 주문이 테스트 결제(샌드박스 결제) 주문인가. true면 실제로 돈이 오가지 않았어요")
|
|
750
|
+
});
|
|
751
|
+
const orderSchema = z.object({
|
|
752
|
+
orderId: z.string(),
|
|
753
|
+
orderedAt: z.string(),
|
|
754
|
+
orderer: z.object({
|
|
755
|
+
name: z.string(),
|
|
756
|
+
phone: z.string(),
|
|
757
|
+
email: z.email().optional()
|
|
758
|
+
}),
|
|
759
|
+
shippingAddress: shippingAddressSchema,
|
|
760
|
+
payment: z.object({
|
|
761
|
+
method: z.enum([
|
|
762
|
+
"CARD",
|
|
763
|
+
"BANK_TRANSFER",
|
|
764
|
+
"VIRTUAL_ACCOUNT",
|
|
765
|
+
"MOBILE",
|
|
766
|
+
"POINT",
|
|
767
|
+
"MIXED"
|
|
768
|
+
]).describe("method는 호환용이며 간편결제를 CARD로 표시한다. 실제 수단은 approvedMethod를 본다"),
|
|
769
|
+
approvedMethod: z.string().nullable().optional().default(null).describe("실제 결제수단. method는 호환용이며 간편결제를 CARD로 표시한다. 실제 수단은 approvedMethod를 본다. 현재 값은 `pgPaymentMethodSchema`(`PgPaymentMethod`: CARD·BANK_TRANSFER·VIRTUAL_ACCOUNT·MOBILE·EASY_PAY)이고 매출 리포트·스토어프론트·환불 원장과 같은 값이다. 값이 추가될 수 있어 문자열로 받는다. PG가 알려 주지 않았으면 요청 수단, 시도 기록이 없는 오래된 주문은 null. 서버는 항상 싣는다"),
|
|
770
|
+
productAmount: z.number().int(),
|
|
771
|
+
deliveryFee: z.number().int(),
|
|
772
|
+
discountAmount: z.number().int(),
|
|
773
|
+
totalAmount: z.number().int(),
|
|
774
|
+
paidAt: z.string().nullable()
|
|
775
|
+
}),
|
|
776
|
+
testPayment: z.boolean().describe("테스트 결제(샌드박스 결제) 주문인가. true면 실제로 돈이 오가지 않았어요. 상품을 발송하지 않도록 주의하세요"),
|
|
777
|
+
items: z.array(orderItemSchema)
|
|
778
|
+
});
|
|
779
|
+
/** 주문 목록 응답 — `GET /v1/orders` */
|
|
780
|
+
const orderSummaryPageSchema = pageSchema(orderSummarySchema);
|
|
781
|
+
/** 주문상품 변경 목록 응답 — `GET /v1/order-items` */
|
|
782
|
+
const orderItemPageSchema = pageSchema(orderItemSchema);
|
|
783
|
+
const testPaymentQuery = z.enum(["true", "false"]).optional().describe("테스트 결제(샌드박스 결제) 필터. `true`면 테스트 결제만, `false`면 실결제만, 생략하면 전부예요");
|
|
784
|
+
const pageQuery = { page: z.string().optional().describe("페이지 번호 (1부터, 기본 1)") };
|
|
785
|
+
/**
|
|
786
|
+
* `GET /v1/orders` 쿼리 — OpenAPI 문서용 형태(값은 쿼리 문자열). 검증 규칙은 서버가 적용한다:
|
|
787
|
+
* `orderedFrom` 누락 400 `MISSING_PARAMETER`, 날짜 형식 오류 400 `INVALID_PARAMETER`, 31일 초과 400 `RANGE_TOO_WIDE`,
|
|
788
|
+
* `testPayment`가 `true`·`false`가 아니면 400 `INVALID_PARAMETER`
|
|
789
|
+
*/
|
|
790
|
+
const orderListQuerySchema = z.object({
|
|
791
|
+
orderedFrom: z.string().describe("조회 시작 시각 (ISO 8601 날짜 또는 일시, 포함). 필수"),
|
|
792
|
+
orderedTo: z.string().optional().describe("조회 끝 시각 (ISO 8601, 포함). 생략하면 orderedFrom부터 31일. 기간은 최대 31일"),
|
|
793
|
+
status: orderItemStatusSchema.optional().describe("주문상품 상태 필터 — 이 상태의 주문상품이 하나라도 있는 주문"),
|
|
794
|
+
keyword: z.string().optional().describe("주문번호(일치)·주문자명·수령인명(포함) 검색"),
|
|
795
|
+
testPayment: testPaymentQuery,
|
|
796
|
+
...pageQuery,
|
|
797
|
+
size: z.string().optional().describe("페이지 크기 (기본 20, 최대 100)")
|
|
798
|
+
});
|
|
799
|
+
/**
|
|
800
|
+
* `GET /v1/order-items` 쿼리 — OpenAPI 문서용 형태. `lastChangedFrom` 누락 400 `MISSING_PARAMETER`, 형식 오류 400
|
|
801
|
+
* `INVALID_PARAMETER`, 24시간 초과 400 `RANGE_TOO_WIDE`, `testPayment` 값 오류 400 `INVALID_PARAMETER`
|
|
802
|
+
*/
|
|
803
|
+
const orderItemListQuerySchema = z.object({
|
|
804
|
+
lastChangedFrom: z.string().describe("마지막 변경 시각 조회 시작 (ISO 8601, 포함). 필수"),
|
|
805
|
+
lastChangedTo: z.string().optional().describe("마지막 변경 시각 조회 끝 (ISO 8601, 포함). 생략하면 시작부터 24시간. 기간은 최대 24시간"),
|
|
806
|
+
lastChangedType: changeTypeSchema.optional().describe("마지막 변경 유형 필터"),
|
|
807
|
+
testPayment: testPaymentQuery,
|
|
808
|
+
...pageQuery,
|
|
809
|
+
size: z.string().optional().describe("페이지 크기 (기본 100, 최대 300)")
|
|
810
|
+
});
|
|
811
|
+
const deliveryMethodSchema = z.enum([
|
|
812
|
+
"COURIER",
|
|
813
|
+
"DIRECT",
|
|
814
|
+
"QUICK",
|
|
815
|
+
"NOTHING"
|
|
816
|
+
]);
|
|
817
|
+
const dispatchRequestSchema = z.object({
|
|
818
|
+
deliveryMethod: deliveryMethodSchema,
|
|
819
|
+
carrierCode: z.string().optional(),
|
|
820
|
+
trackingNumber: z.string().optional(),
|
|
821
|
+
dispatchedAt: z.string().optional()
|
|
822
|
+
}).refine((value) => value.deliveryMethod === "DIRECT" || value.deliveryMethod === "NOTHING" ? true : !!value.carrierCode && !!value.trackingNumber, { message: "COURIER/QUICK 발송은 carrierCode와 trackingNumber가 필수입니다" });
|
|
823
|
+
const deliveryInfoSchema = z.object({
|
|
824
|
+
orderItemId: z.string(),
|
|
825
|
+
deliveryMethod: deliveryMethodSchema,
|
|
826
|
+
carrierCode: z.string().nullable(),
|
|
827
|
+
carrierName: z.string().nullable(),
|
|
828
|
+
trackingNumber: z.string().nullable(),
|
|
829
|
+
trackingUrl: z.url().nullable(),
|
|
830
|
+
status: z.enum([
|
|
831
|
+
"DISPATCHED",
|
|
832
|
+
"DELIVERING",
|
|
833
|
+
"DELIVERED"
|
|
834
|
+
]),
|
|
835
|
+
dispatchedAt: z.string(),
|
|
836
|
+
deliveredAt: z.string().nullable()
|
|
837
|
+
});
|
|
838
|
+
const carrierSchema = z.object({
|
|
839
|
+
carrierCode: z.string(),
|
|
840
|
+
name: z.string(),
|
|
841
|
+
trackingUrlTemplate: z.string()
|
|
842
|
+
});
|
|
843
|
+
const holdOrderItemRequestSchema = z.object({
|
|
844
|
+
reason: z.enum([
|
|
845
|
+
"OUT_OF_STOCK",
|
|
846
|
+
"CUSTOMER_REQUEST",
|
|
847
|
+
"CUSTOMS",
|
|
848
|
+
"ETC"
|
|
849
|
+
]),
|
|
850
|
+
detail: z.string().max(500).optional(),
|
|
851
|
+
expectedDispatchDate: z.string()
|
|
852
|
+
});
|
|
853
|
+
const cancelBySellerRequestSchema = z.object({
|
|
854
|
+
reason: z.enum([
|
|
855
|
+
"OUT_OF_STOCK",
|
|
856
|
+
"PRICE_ERROR",
|
|
857
|
+
"DELIVERY_IMPOSSIBLE",
|
|
858
|
+
"ETC"
|
|
859
|
+
]),
|
|
860
|
+
detail: z.string().max(500).optional()
|
|
861
|
+
});
|
|
862
|
+
//#endregion
|
|
863
|
+
//#region src/schemas/payment.ts
|
|
864
|
+
/**
|
|
865
|
+
* 스토어 결제(PG) 설정과 매출 리포트 계약.
|
|
866
|
+
*
|
|
867
|
+
* PG는 셀러가 직접 계약한다. 자격 증명은 스토어별로 저장되고, 비밀 값(시크릿 키·API Secret·
|
|
868
|
+
* 웹훅 시크릿)은 저장 후 다시 조회할 수 없다. 응답에는 앞 4자리 마스킹만 나온다. 공개 값
|
|
869
|
+
* (클라이언트 키·Store ID·채널 키)은 원래 브라우저 결제창에 노출되는 값이라 그대로 돌려준다.
|
|
870
|
+
*/
|
|
871
|
+
const pgProviderSchema = z.enum(["tosspayments", "portone"]).describe("PG 식별자. `tosspayments`=토스페이먼츠, `portone`=포트원(V2)");
|
|
872
|
+
const pgPaymentMethodSchema = z.enum([
|
|
873
|
+
"CARD",
|
|
874
|
+
"BANK_TRANSFER",
|
|
875
|
+
"VIRTUAL_ACCOUNT",
|
|
876
|
+
"MOBILE",
|
|
877
|
+
"EASY_PAY"
|
|
878
|
+
]).describe("결제수단 (스토어프론트 결제 요청의 `paymentMethod`와 같은 값)");
|
|
879
|
+
const paymentRoutingModeSchema = z.enum(["PRIORITY_DISPLAY", "AUTO_FAILOVER"]).describe("PG가 둘 이상 사용 중일 때의 라우팅 방식. `PRIORITY_DISPLAY`=결제창에 사용 중인 PG를 모두 보여 주고 1순위를 기본 선택, `AUTO_FAILOVER`=1순위로 결제창을 열고, 결제창을 띄우기 전 장애(PG 준비 실패)일 때만 다음 순위로 자동 전환");
|
|
880
|
+
const effectivePaymentRoutingModeSchema = z.enum([
|
|
881
|
+
"NONE",
|
|
882
|
+
"SINGLE",
|
|
883
|
+
"PRIORITY_DISPLAY",
|
|
884
|
+
"AUTO_FAILOVER"
|
|
885
|
+
]).describe("실제로 적용되는 라우팅. 사용 중인 PG가 0개면 `NONE`(결제 불가), 1개면 `SINGLE`, 2개 이상이면 `routingMode` 값");
|
|
886
|
+
const pgEnvironmentSchema = z.enum(["TEST", "LIVE"]).describe("PG 결제 환경. `TEST`=PG 샌드박스(테스트 키, 실제로 돈이 오가지 않음), `LIVE`=실결제(운영 키)");
|
|
887
|
+
const pgVerificationResultSchema = z.enum(["SUCCESS", "FAILED"]).describe("연결 테스트 결과");
|
|
888
|
+
/** 비밀 값 입력 — 저장 후 다시 조회할 수 없다 */
|
|
889
|
+
const secretInput = () => z.string().trim().min(8).max(512);
|
|
890
|
+
/**
|
|
891
|
+
* 토스페이먼츠 결제위젯 키 접두사. 결제창은 API 개별 연동(`*_ck_`·`*_sk_`)만 쓴다. 위젯 키는 결제창 SDK가
|
|
892
|
+
* `NOT_SUPPORTED_WIDGET_KEY`로, 서버 API가 404 `NOT_FOUND_MERCHANT`로 거부하므로 저장 전에 막는다.
|
|
893
|
+
* 요청 스키마·api 어댑터·콘솔 폼이 같은 상수를 쓴다(원천). `g` 플래그가 없어 `.test()`가 상태를 갖지 않는다.
|
|
894
|
+
*/
|
|
895
|
+
const TOSS_WIDGET_CLIENT_KEY_PATTERN = /^(test|live)_gck_/;
|
|
896
|
+
/** 토스페이먼츠 결제위젯 시크릿 키 접두사. {@link TOSS_WIDGET_CLIENT_KEY_PATTERN} 참고 */
|
|
897
|
+
const TOSS_WIDGET_SECRET_KEY_PATTERN = /^(test|live)_gsk_/;
|
|
898
|
+
/** 결제위젯 키를 거부할 때의 필드 메시지 */
|
|
899
|
+
const TOSS_WIDGET_KEY_MESSAGE = "결제위젯 키가 아니라 API 개별 연동 키를 입력해주세요";
|
|
900
|
+
const tossPaymentsCredentialsInputSchema = z.object({
|
|
901
|
+
clientKey: z.string().trim().min(8).max(200).describe("클라이언트 키 (`test_ck_…`/`live_ck_…`). 공개 값 — 결제창에서 쓰인다"),
|
|
902
|
+
secretKey: secretInput().optional().describe("시크릿 키 (`test_sk_…`/`live_sk_…`). 비밀 값. 최초 연결 시 필수, 수정 시 생략하면 기존 값을 유지한다"),
|
|
903
|
+
webhookSecret: secretInput().nullable().optional().describe("웹훅 서명 검증용 보안 키. 비밀 값. 생략하면 기존 값 유지, null이면 삭제")
|
|
904
|
+
}).superRefine((value, ctx) => {
|
|
905
|
+
if (TOSS_WIDGET_CLIENT_KEY_PATTERN.test(value.clientKey)) ctx.addIssue({
|
|
906
|
+
code: "custom",
|
|
907
|
+
path: ["clientKey"],
|
|
908
|
+
message: TOSS_WIDGET_KEY_MESSAGE
|
|
909
|
+
});
|
|
910
|
+
if (value.secretKey !== void 0 && TOSS_WIDGET_SECRET_KEY_PATTERN.test(value.secretKey)) ctx.addIssue({
|
|
911
|
+
code: "custom",
|
|
912
|
+
path: ["secretKey"],
|
|
913
|
+
message: TOSS_WIDGET_KEY_MESSAGE
|
|
914
|
+
});
|
|
915
|
+
});
|
|
916
|
+
/** 포트원 채널 목록 최소 개수. 결제수단을 받으려면 채널이 하나는 있어야 한다 */
|
|
917
|
+
const PORTONE_MIN_CHANNELS = 1;
|
|
918
|
+
/** 포트원 채널 목록 최대 개수 */
|
|
919
|
+
const PORTONE_MAX_CHANNELS = 20;
|
|
920
|
+
/** 포트원 채널 표시 이름 최대 길이 */
|
|
921
|
+
const PORTONE_CHANNEL_NAME_MAX_LENGTH = 50;
|
|
922
|
+
const portOneChannelSchema = z.object({
|
|
923
|
+
channelKey: z.string().trim().min(8).max(200).describe("포트원 콘솔 [연동 정보] > [채널 관리]의 채널 키. 공개 값"),
|
|
924
|
+
name: z.string().trim().max(50).nullable().optional().describe("채널 표시 이름 (예: 카드 전용)"),
|
|
925
|
+
payMethods: z.array(pgPaymentMethodSchema).min(1).max(pgPaymentMethodSchema.options.length).describe("이 채널로 받을 결제수단. 결제수단마다 먼저 나열된 채널이 쓰인다")
|
|
926
|
+
});
|
|
927
|
+
const portOneCredentialsInputSchema = z.object({
|
|
928
|
+
storeId: z.string().trim().min(8).max(200).describe("포트원 Store ID (`store-…`). 공개 값"),
|
|
929
|
+
channels: z.array(portOneChannelSchema).min(1).max(20).describe("채널 목록. 포트원 V2는 PG·결제수단별로 채널을 따로 추가하므로 여러 개를 등록할 수 있다"),
|
|
930
|
+
apiSecret: secretInput().optional().describe("V2 API Secret. 비밀 값. 최초 연결 시 필수, 수정 시 생략하면 기존 값을 유지한다"),
|
|
931
|
+
webhookSecret: secretInput().nullable().optional().describe("웹훅 서명 검증용 시크릿. 비밀 값. 생략하면 기존 값 유지, null이면 삭제")
|
|
932
|
+
});
|
|
933
|
+
const upsertEnvironment = () => pgEnvironmentSchema.describe("저장할 자격 증명 세트. PG마다 `TEST`(샌드박스)·`LIVE`(실결제) 세트를 따로 둔다. 어느 세트로 결제할지는 이 값이 아니라 샌드박스 스위치(`sandboxMode`)가 정한다");
|
|
934
|
+
const upsertTossPaymentsProviderRequestSchema = z.object({
|
|
935
|
+
environment: upsertEnvironment(),
|
|
936
|
+
credentials: tossPaymentsCredentialsInputSchema
|
|
937
|
+
});
|
|
938
|
+
const upsertPortOneProviderRequestSchema = z.object({
|
|
939
|
+
environment: upsertEnvironment(),
|
|
940
|
+
credentials: portOneCredentialsInputSchema
|
|
941
|
+
});
|
|
942
|
+
const updatePaymentProviderRequestSchema = z.object({
|
|
943
|
+
enabled: z.boolean().optional().describe("사용 여부. 켜려면 활성 환경(샌드박스면 TEST, 아니면 LIVE) 자격 증명이 모두 저장돼 있어야 한다(`PAYMENT_PROVIDER_INCOMPLETE`)"),
|
|
944
|
+
sandboxMode: z.boolean().optional().describe("샌드박스 스위치. `true`면 새 결제에 TEST 세트를 쓴다(항상 허용). `false`(라이브 전환)는 라이브 개방 조건을 모두 충족해야 한다(`LIVE_GATE_NOT_MET`)")
|
|
945
|
+
}).refine((body) => body.enabled !== void 0 || body.sandboxMode !== void 0, { message: "enabled 또는 sandboxMode 중 하나는 보내야 해요" });
|
|
946
|
+
const verifyPaymentProviderRequestSchema = z.object({ environment: pgEnvironmentSchema.optional().describe("연결 테스트할 자격 증명 세트. 생략하면 활성 환경(샌드박스면 TEST, 아니면 LIVE)") });
|
|
947
|
+
const updatePaymentSettingsRequestSchema = z.object({
|
|
948
|
+
routingMode: paymentRoutingModeSchema,
|
|
949
|
+
priority: z.array(pgProviderSchema).min(1).max(50).describe("PG 우선순위(앞이 1순위). 연결된 PG 수만큼 **모두 한 번씩** 나열한다(길이 상한 50은 안전장치다). 빠지거나 중복되거나 연결되지 않은 PG가 있으면 `INVALID_PRIORITY`")
|
|
950
|
+
});
|
|
951
|
+
const maskedSecretSchema = z.object({
|
|
952
|
+
field: z.string().describe("비밀 필드 이름 (`secretKey`·`apiSecret`·`webhookSecret`)"),
|
|
953
|
+
configured: z.boolean().describe("값이 저장돼 있는가"),
|
|
954
|
+
masked: z.string().nullable().describe("앞 4자리만 남긴 마스킹 값 (예: `test****`). 저장되지 않았으면 null")
|
|
955
|
+
});
|
|
956
|
+
const publicConfigSchema = z.union([z.object({ clientKey: z.string() }), z.object({
|
|
957
|
+
storeId: z.string(),
|
|
958
|
+
channels: z.array(portOneChannelSchema)
|
|
959
|
+
})]);
|
|
960
|
+
const verificationSchema = z.object({
|
|
961
|
+
result: pgVerificationResultSchema,
|
|
962
|
+
message: z.string().nullable(),
|
|
963
|
+
checkedAt: z.string()
|
|
964
|
+
});
|
|
965
|
+
const paymentCredentialSetSchema = z.object({
|
|
966
|
+
environment: pgEnvironmentSchema,
|
|
967
|
+
publicConfig: publicConfigSchema.describe("공개 값. 토스페이먼츠는 `{clientKey}`, 포트원은 `{storeId, channels}`"),
|
|
968
|
+
secrets: z.array(maskedSecretSchema).describe("비밀 값의 저장 여부와 마스킹. 평문은 어떤 응답에도 나오지 않는다"),
|
|
969
|
+
lastVerification: verificationSchema.nullable().describe("이 세트의 마지막 연결 테스트 결과. 세트를 바꾸면 초기화된다"),
|
|
970
|
+
updatedAt: z.string()
|
|
971
|
+
});
|
|
972
|
+
const liveGateConditionCodeSchema = z.enum([
|
|
973
|
+
"TEST_CONNECTION",
|
|
974
|
+
"SANDBOX_PAYMENT",
|
|
975
|
+
"SANDBOX_REFUND",
|
|
976
|
+
"LIVE_CONNECTION",
|
|
977
|
+
"PLATFORM_LIVE_ENABLED"
|
|
978
|
+
]).describe("라이브 개방 조건. `TEST_CONNECTION`=TEST 세트 연결 테스트 성공, `SANDBOX_PAYMENT`=현재 TEST 세트로 샌드박스 결제 1건 승인, `SANDBOX_REFUND`=그 샌드박스 결제의 환불 1건 성공, `LIVE_CONNECTION`=LIVE 세트 연결 테스트 성공, `PLATFORM_LIVE_ENABLED`=플랫폼이 실결제를 열어 둠");
|
|
979
|
+
const liveGateConditionSchema = z.object({
|
|
980
|
+
code: liveGateConditionCodeSchema,
|
|
981
|
+
met: z.boolean().describe("충족 여부"),
|
|
982
|
+
message: z.string().describe("조건 설명 또는 미충족 사유")
|
|
983
|
+
});
|
|
984
|
+
const liveGateSchema = z.object({
|
|
985
|
+
status: z.enum(["LOCKED", "OPEN"]).describe("`OPEN`=조건을 모두 충족해 샌드박스를 끌 수 있다, `LOCKED`=아직 끌 수 없다"),
|
|
986
|
+
conditions: z.array(liveGateConditionSchema),
|
|
987
|
+
openedAt: z.string().nullable().describe("마지막으로 라이브로 전환한 시각. 없으면 null")
|
|
988
|
+
});
|
|
989
|
+
/**
|
|
990
|
+
* 409 `LIVE_GATE_NOT_MET`·`LIVE_PAYMENTS_DISABLED`의 `ApiError.details` 형태.
|
|
991
|
+
* 응답 파싱용이라 관대하다 — 알 수 없는 키는 버리고, 형태가 다르면 safeParse 실패로 처리한다.
|
|
992
|
+
*/
|
|
993
|
+
const liveGateErrorDetailsSchema = z.object({ conditions: z.array(liveGateConditionSchema).describe("라이브 개방 조건별 충족 여부 (순서 고정)") });
|
|
994
|
+
const paymentProviderSchema = z.object({
|
|
995
|
+
provider: pgProviderSchema,
|
|
996
|
+
displayName: z.string().describe("표시 이름 (토스페이먼츠·포트원)"),
|
|
997
|
+
connected: z.boolean().describe("자격 증명이 저장돼 있는가"),
|
|
998
|
+
enabled: z.boolean().describe("결제에 사용 중인가"),
|
|
999
|
+
priority: z.number().int().nullable().describe("우선순위 (1이 가장 높다). 연결되지 않았으면 null"),
|
|
1000
|
+
environment: pgEnvironmentSchema.nullable().describe("새 결제에 쓰는 환경(활성 환경). 샌드박스 스위치가 켜져 있으면 `TEST`, 꺼져 있으면 `LIVE`. 연결되지 않았으면 null"),
|
|
1001
|
+
publicConfig: publicConfigSchema.nullable().describe("활성 환경 세트의 공개 값. 토스페이먼츠는 `{clientKey}`, 포트원은 `{storeId, channels}`. 활성 세트가 없으면 null"),
|
|
1002
|
+
secrets: z.array(maskedSecretSchema).describe("활성 환경 세트의 비밀 값 저장 여부와 마스킹. 평문은 어떤 응답에도 나오지 않는다"),
|
|
1003
|
+
lastVerification: verificationSchema.nullable().describe("활성 환경 세트의 마지막 연결 테스트 결과. 세트를 바꾸면 초기화된다"),
|
|
1004
|
+
updatedAt: z.string().nullable(),
|
|
1005
|
+
sandboxMode: z.boolean().describe("샌드박스 스위치. `true`면 새 결제에 TEST 세트를 쓴다(실제로 돈이 오가지 않는다). 연결되지 않았으면 true"),
|
|
1006
|
+
credentialSets: z.object({
|
|
1007
|
+
TEST: paymentCredentialSetSchema.nullable(),
|
|
1008
|
+
LIVE: paymentCredentialSetSchema.nullable()
|
|
1009
|
+
}).describe("환경별 자격 증명 세트. 등록되지 않은 세트는 null"),
|
|
1010
|
+
liveGate: liveGateSchema.describe("라이브 개방 조건. 조건을 모두 충족해야 샌드박스를 끌 수 있다"),
|
|
1011
|
+
retiredCredentialCount: z.number().int().describe("보관된(가맹점 교체·연결 해제로 새 결제에 더 쓰이지 않는) 자격 증명 수. 그 가맹점으로 받은 결제의 환불·대사에 계속 쓰인다"),
|
|
1012
|
+
routingExclusion: z.string().nullable().optional().default(null).describe("지금 새 결제의 후보가 아닌 사유. 후보면 null. 판정 원천은 서버 라우팅이다(콘솔이 다시 계산하지 않는다). 후보가 아닌 PG에는 항상 사유 코드를 붙인다(연결 안 된 PG 포함) — 모든 PG는 `candidates`에 있거나 이 값이 null이 아니다. 현재 값: `NOT_CONNECTED`(연결 안 됨) · `DISABLED`(사용 중지) · `CREDENTIAL_MISSING`(활성 환경 자격 증명 없음) · `SANDBOX_IN_LIVE_STORE`(다른 PG가 라이브라 이 샌드박스 PG는 실구매자 결제에 쓰지 않음) · `PLATFORM_BLOCKED`(플랫폼이 실결제를 막음) · `MODE_MISMATCH`(라이브를 연 서버 모드와 현재 모드가 다름). 값이 추가될 수 있어 문자열로 받는다 — 모르는 값은 후보 아님으로 다룬다. 서버는 항상 싣는다. 이 필드가 없는 이전 서버 응답은 기본값(null)으로 읽는다")
|
|
1013
|
+
});
|
|
1014
|
+
/**
|
|
1015
|
+
* `PaymentProvider.routingExclusion`의 현재 값 — 응답 스키마는 서버가 사유를 추가해도 파싱이 깨지지 않게 문자열로 받는다.
|
|
1016
|
+
* 화면 문구 분기는 이 목록으로 하고, 모르는 값은 "후보 아님"으로 다룬다.
|
|
1017
|
+
*/
|
|
1018
|
+
const PAYMENT_ROUTING_EXCLUSION_CODES = [
|
|
1019
|
+
"NOT_CONNECTED",
|
|
1020
|
+
"DISABLED",
|
|
1021
|
+
"CREDENTIAL_MISSING",
|
|
1022
|
+
"SANDBOX_IN_LIVE_STORE",
|
|
1023
|
+
"PLATFORM_BLOCKED",
|
|
1024
|
+
"MODE_MISMATCH"
|
|
1025
|
+
];
|
|
1026
|
+
const paymentSettingsSchema = z.object({
|
|
1027
|
+
routingMode: paymentRoutingModeSchema.describe("저장된 라우팅 방식 (기본 `AUTO_FAILOVER`). PG가 하나뿐이면 무시된다"),
|
|
1028
|
+
effectiveRoutingMode: effectivePaymentRoutingModeSchema,
|
|
1029
|
+
providers: z.array(paymentProviderSchema).describe("지원하는 모든 PG. 연결된 것은 우선순위순, 연결되지 않은 것은 뒤에 온다"),
|
|
1030
|
+
environment: pgEnvironmentSchema.nullable().optional().default(null).describe("지금 결제를 시작하면 쓰는 결제 환경. 사용 가능한 PG 중 하나라도 라이브면 `LIVE`(샌드박스 PG는 제외), 아니면 `TEST`. 사용 가능한 PG가 없으면 null. 서버는 항상 싣는다. 이 필드가 없는 이전 서버 응답은 기본값(null)으로 읽는다"),
|
|
1031
|
+
candidates: z.array(z.string()).optional().default(() => []).describe("지금 결제를 시작하면 쓰는 PG, 우선순위순(`tosspayments`·`portone`). 비어 있으면 구매자가 결제할 수 없다. 빠진 PG의 사유는 `providers[].routingExclusion` — 후보가 아닌 PG에는 항상 사유 코드를 붙인다. 서버는 항상 싣는다. 이 필드가 없는 이전 서버 응답은 기본값(빈 배열)으로 읽는다")
|
|
1032
|
+
});
|
|
1033
|
+
const paymentProviderVerificationSchema = z.object({
|
|
1034
|
+
provider: pgProviderSchema,
|
|
1035
|
+
environment: pgEnvironmentSchema.describe("연결 테스트한 자격 증명 세트"),
|
|
1036
|
+
result: pgVerificationResultSchema,
|
|
1037
|
+
message: z.string().nullable().describe("실패 사유 또는 참고 메시지"),
|
|
1038
|
+
checkedAt: z.string()
|
|
1039
|
+
});
|
|
1040
|
+
const salesReportGroupBySchema = z.enum([
|
|
1041
|
+
"DAY",
|
|
1042
|
+
"PROVIDER",
|
|
1043
|
+
"METHOD",
|
|
1044
|
+
"PROVIDER_METHOD"
|
|
1045
|
+
]).describe("행 집계 기준. 기본 `DAY`");
|
|
1046
|
+
const reportDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "날짜 형식은 YYYY-MM-DD 입니다");
|
|
1047
|
+
const salesReportQuerySchema = z.object({
|
|
1048
|
+
from: reportDate.describe("시작일 (YYYY-MM-DD, Asia/Seoul 기준, 포함)"),
|
|
1049
|
+
to: reportDate.describe("종료일 (YYYY-MM-DD, 포함). from부터 최대 92일"),
|
|
1050
|
+
provider: pgProviderSchema.optional().describe("PG 필터"),
|
|
1051
|
+
method: pgPaymentMethodSchema.optional().describe("결제수단 필터 — **실제 결제수단**(PG가 보고한 수단, 모르면 요청 수단) 기준. 결제창에서 결제수단이 바뀔 수 있다(카드 결제창의 간편결제 탭 등). 관리 주문 API의 method는 호환용이며 간편결제를 CARD로 표시한다. 실제 수단은 approvedMethod를 본다"),
|
|
1052
|
+
groupBy: salesReportGroupBySchema.optional(),
|
|
1053
|
+
includeTest: z.enum(["true", "false"]).optional().describe("테스트 결제(샌드박스 결제) 포함 여부. 기본 `false` — 테스트 결제의 승인·환불은 집계에서 빠진다")
|
|
1054
|
+
});
|
|
1055
|
+
const salesTotalsSchema = z.object({
|
|
1056
|
+
paymentCount: z.number().int().describe("승인 건수"),
|
|
1057
|
+
paymentAmount: z.number().int().describe("승인 금액 합계"),
|
|
1058
|
+
refundCount: z.number().int().describe("환불(취소) 건수. 부분 취소도 1건"),
|
|
1059
|
+
refundAmount: z.number().int().describe("환불 금액 합계"),
|
|
1060
|
+
netAmount: z.number().int().describe("순매출 = 승인 금액 - 환불 금액"),
|
|
1061
|
+
pgFeeAmount: z.number().int().nullable().describe("PG 수수료 (참고용). PG에서 확정 값을 받지 못하면 null")
|
|
1062
|
+
});
|
|
1063
|
+
const salesReportRowSchema = salesTotalsSchema.extend({
|
|
1064
|
+
date: z.string().nullable().describe("일자 (YYYY-MM-DD, Asia/Seoul). groupBy=DAY일 때만 값"),
|
|
1065
|
+
provider: pgProviderSchema.nullable().describe("PG. groupBy에 PROVIDER가 포함될 때만 값"),
|
|
1066
|
+
method: pgPaymentMethodSchema.nullable().describe("실제 결제수단(PG가 보고한 수단, 모르면 요청 수단). groupBy에 METHOD가 포함될 때만 값. 관리 주문 API의 method는 호환용이며 간편결제를 CARD로 표시한다. 실제 수단은 approvedMethod를 본다")
|
|
1067
|
+
});
|
|
1068
|
+
const salesReportSchema = z.object({
|
|
1069
|
+
from: z.string(),
|
|
1070
|
+
to: z.string(),
|
|
1071
|
+
timezone: z.literal("Asia/Seoul"),
|
|
1072
|
+
currency: z.literal("KRW"),
|
|
1073
|
+
groupBy: salesReportGroupBySchema,
|
|
1074
|
+
provider: pgProviderSchema.nullable().describe("적용된 PG 필터"),
|
|
1075
|
+
method: pgPaymentMethodSchema.nullable().describe("적용된 결제수단 필터"),
|
|
1076
|
+
includeTest: z.boolean().describe("테스트 결제(샌드박스 결제)를 집계에 포함했는가"),
|
|
1077
|
+
summary: salesTotalsSchema.describe("기간 전체 합계"),
|
|
1078
|
+
rows: z.array(salesReportRowSchema).describe("집계 행. 활동이 없는 날짜·PG는 생략된다")
|
|
1079
|
+
});
|
|
1080
|
+
/**
|
|
1081
|
+
* 결제 내역 — 결제 건(세션) 단위 공개 상태. 주문 단위 상태와 다르다.
|
|
1082
|
+
* - `APPROVED`: 승인됐고 환불이 없다
|
|
1083
|
+
* - `PARTIALLY_REFUNDED`·`REFUNDED`: 승인 뒤 일부·전액을 환불했다
|
|
1084
|
+
* - `PENDING`: 승인 결과를 아직 확정하지 못했다(대사 잡이 확정한다)
|
|
1085
|
+
* - `FAILED`: 승인되지 않고 끝났다(거절·준비 장애·구매자 취소·만료)
|
|
1086
|
+
*/
|
|
1087
|
+
const paymentRecordStatusSchema = z.enum([
|
|
1088
|
+
"APPROVED",
|
|
1089
|
+
"PARTIALLY_REFUNDED",
|
|
1090
|
+
"REFUNDED",
|
|
1091
|
+
"PENDING",
|
|
1092
|
+
"FAILED"
|
|
1093
|
+
]);
|
|
1094
|
+
const paymentAttemptStatusSchema = z.string().describe("시도 상태 — ready·approving·approved·unknown·declined·prepare_failed·canceled·failed·abandoned·voided. 새 값이 추가될 수 있으니 모르는 값은 '진행 중이 아닌 시도'로 다룬다");
|
|
1095
|
+
const paymentRecordSchema = z.object({
|
|
1096
|
+
paymentId: z.string().describe("결제 건 id. 상세 조회 키"),
|
|
1097
|
+
orderId: z.string().nullable().describe("승인돼 만들어진 주문. 승인 전에 끝난 결제는 null"),
|
|
1098
|
+
orderName: z.string().describe("주문명 (예: 무선 이어폰 프로 외 1건)"),
|
|
1099
|
+
customerName: z.string().describe("구매자명"),
|
|
1100
|
+
status: paymentRecordStatusSchema,
|
|
1101
|
+
amount: z.number().int().describe("결제 금액"),
|
|
1102
|
+
refundedAmount: z.number().int().describe("환불 완료 금액 합계. 실패한 환불은 빼고 센다"),
|
|
1103
|
+
provider: pgProviderSchema.nullable().describe("승인한 PG. 승인 전에 끝났으면 마지막 시도의 PG"),
|
|
1104
|
+
method: pgPaymentMethodSchema.nullable().describe("실제 결제수단. 모르면 요청 수단"),
|
|
1105
|
+
environment: pgEnvironmentSchema.nullable().describe("결제 환경"),
|
|
1106
|
+
testPayment: z.boolean().describe("샌드박스 결제인가"),
|
|
1107
|
+
pgTransactionId: z.string().nullable().describe("PG 거래 id (토스 paymentKey, 포트원 txId)"),
|
|
1108
|
+
attemptId: z.string().nullable().describe("PG에 보낸 주문번호 = 시도 id"),
|
|
1109
|
+
attemptCount: z.number().int().describe("시도 횟수"),
|
|
1110
|
+
failCode: z.string().nullable().describe("실패 코드. 실패로 끝난 결제만"),
|
|
1111
|
+
failMessage: z.string().nullable().describe("실패 사유"),
|
|
1112
|
+
paidAt: z.string().nullable().describe("승인 시각 (ISO 8601)"),
|
|
1113
|
+
createdAt: z.string().describe("결제 시작 시각 (ISO 8601)")
|
|
1114
|
+
});
|
|
1115
|
+
const paymentRecordPageSchema = pageSchema(paymentRecordSchema);
|
|
1116
|
+
const paymentAttemptRecordSchema = z.object({
|
|
1117
|
+
attemptId: z.string(),
|
|
1118
|
+
sequence: z.number().int().describe("결제 건 안에서의 순번 (1부터)"),
|
|
1119
|
+
provider: pgProviderSchema,
|
|
1120
|
+
status: paymentAttemptStatusSchema,
|
|
1121
|
+
method: pgPaymentMethodSchema.describe("주문서에서 고른 결제수단"),
|
|
1122
|
+
approvedMethod: pgPaymentMethodSchema.nullable().describe("PG가 보고한 실제 결제수단"),
|
|
1123
|
+
amount: z.number().int(),
|
|
1124
|
+
environment: pgEnvironmentSchema,
|
|
1125
|
+
pgTransactionId: z.string().nullable(),
|
|
1126
|
+
failCode: z.string().nullable(),
|
|
1127
|
+
failMessage: z.string().nullable(),
|
|
1128
|
+
createdAt: z.string(),
|
|
1129
|
+
updatedAt: z.string()
|
|
1130
|
+
});
|
|
1131
|
+
const paymentRefundRecordSchema = z.object({
|
|
1132
|
+
refundId: z.string(),
|
|
1133
|
+
amount: z.number().int(),
|
|
1134
|
+
reason: z.string().describe("환불 사유 코드"),
|
|
1135
|
+
kind: z.string().describe("환불 원인 — SELLER_CANCEL·CLAIM·COMPENSATION·DUPLICATE. 새 값이 추가될 수 있다"),
|
|
1136
|
+
status: z.string().describe("SUCCEEDED·FAILED"),
|
|
1137
|
+
failMessage: z.string().nullable(),
|
|
1138
|
+
requestedAt: z.string(),
|
|
1139
|
+
completedAt: z.string().nullable()
|
|
1140
|
+
});
|
|
1141
|
+
const paymentRecordDetailSchema = paymentRecordSchema.extend({
|
|
1142
|
+
refundableAmount: z.number().int().describe("환불 가능 잔액 = 승인 금액 - 환불 완료 금액"),
|
|
1143
|
+
merchantId: z.string().nullable().describe("가맹점 식별자 지문 (토스 clientKey, 포트원 Store ID). 비밀 값은 없다"),
|
|
1144
|
+
attempts: z.array(paymentAttemptRecordSchema).describe("시도 이력. 순번 오름차순"),
|
|
1145
|
+
refunds: z.array(paymentRefundRecordSchema).describe("환불 이력. 실패한 요청도 남는다")
|
|
1146
|
+
});
|
|
1147
|
+
/** `GET /v1/payments` 쿼리 — OpenAPI 문서용 형태(값은 쿼리 문자열) */
|
|
1148
|
+
const paymentListQuerySchema = z.object({
|
|
1149
|
+
from: z.string().optional().describe("조회 시작 시각 (ISO 8601, 포함). 생략하면 최근 30일"),
|
|
1150
|
+
to: z.string().optional().describe("조회 끝 시각 (ISO 8601, 포함). 기간은 최대 92일"),
|
|
1151
|
+
status: z.string().optional().describe("결제 상태 필터 (PaymentRecordStatus)"),
|
|
1152
|
+
provider: z.string().optional().describe("PG 필터"),
|
|
1153
|
+
method: z.string().optional().describe("실제 결제수단 필터"),
|
|
1154
|
+
keyword: z.string().optional().describe("결제 id·주문번호·PG 거래 id·시도 id(일치), 구매자명(포함)"),
|
|
1155
|
+
includeTest: z.string().optional().describe("테스트 결제 포함 여부 (`true`·`false`. 기본 false — 실결제만)"),
|
|
1156
|
+
page: z.string().optional().describe("페이지 번호 (1부터)"),
|
|
1157
|
+
size: z.string().optional().describe("페이지 크기 (기본 20, 최대 100)")
|
|
1158
|
+
});
|
|
1159
|
+
//#endregion
|
|
1160
|
+
//#region src/schemas/product.ts
|
|
1161
|
+
const productStatusSchema = z.enum([
|
|
1162
|
+
"SALE",
|
|
1163
|
+
"SUSPENDED",
|
|
1164
|
+
"OUT_OF_STOCK",
|
|
1165
|
+
"CLOSED"
|
|
1166
|
+
]);
|
|
1167
|
+
const deliveryTypeSchema = z.enum([
|
|
1168
|
+
"FREE",
|
|
1169
|
+
"PAID",
|
|
1170
|
+
"CONDITIONAL_FREE"
|
|
1171
|
+
]);
|
|
1172
|
+
const productSummarySchema = z.object({
|
|
1173
|
+
productId: z.string(),
|
|
1174
|
+
name: z.string(),
|
|
1175
|
+
status: productStatusSchema,
|
|
1176
|
+
salePrice: z.number().int(),
|
|
1177
|
+
discountedPrice: z.number().int().nullable(),
|
|
1178
|
+
/** usable variant 재고 합계 (서버 계산) */
|
|
1179
|
+
stockQuantity: z.number().int(),
|
|
1180
|
+
categoryId: z.string().nullable(),
|
|
1181
|
+
thumbnailUrl: z.url().optional(),
|
|
1182
|
+
/** optionGroups.length > 0 */
|
|
1183
|
+
hasOptions: z.boolean(),
|
|
1184
|
+
updatedAt: z.string()
|
|
1185
|
+
});
|
|
1186
|
+
/** 옵션 축(그룹)이 가지는 값 하나 — 예: 축 "색상"의 값 "블랙" */
|
|
1187
|
+
const optionValueSchema = z.object({
|
|
1188
|
+
valueId: z.string(),
|
|
1189
|
+
name: z.string(),
|
|
1190
|
+
sortOrder: z.number().int()
|
|
1191
|
+
});
|
|
1192
|
+
/** 옵션 축(그룹) — 예: "색상", "사이즈" */
|
|
1193
|
+
const optionGroupSchema = z.object({
|
|
1194
|
+
groupId: z.string(),
|
|
1195
|
+
name: z.string(),
|
|
1196
|
+
sortOrder: z.number().int(),
|
|
1197
|
+
values: z.array(optionValueSchema)
|
|
1198
|
+
});
|
|
1199
|
+
/**
|
|
1200
|
+
* 판매 단위(SKU). variantId는 기존 optionId 값 공간을 승계하므로
|
|
1201
|
+
* 카트·주문·리뷰·클레임의 optionId가 곧 variantId다.
|
|
1202
|
+
*/
|
|
1203
|
+
const productVariantSchema = z.object({
|
|
1204
|
+
variantId: z.string(),
|
|
1205
|
+
/** 축 sortOrder 순서와 1:1 대응. 옵션 없는 상품은 [] */
|
|
1206
|
+
valueIds: z.array(z.string()),
|
|
1207
|
+
/** 값 이름을 " / "로 이은 파생 표시명. 옵션 없으면 "" */
|
|
1208
|
+
name: z.string(),
|
|
1209
|
+
additionalPrice: z.number().int(),
|
|
1210
|
+
stockQuantity: z.number().int(),
|
|
1211
|
+
sku: z.string().nullable(),
|
|
1212
|
+
barcode: z.string().nullable(),
|
|
1213
|
+
costPrice: z.number().int().nullable(),
|
|
1214
|
+
weightGram: z.number().int().nullable(),
|
|
1215
|
+
sellerCode: z.string().nullable(),
|
|
1216
|
+
usable: z.boolean()
|
|
1217
|
+
});
|
|
1218
|
+
const productSchema = productSummarySchema.extend({
|
|
1219
|
+
description: z.string().optional(),
|
|
1220
|
+
/**
|
|
1221
|
+
* 상세설명 에디터(@avarlabs/editor)의 직렬화 상태. 재편집 때 이 값으로 문서를 복원한다.
|
|
1222
|
+
* 표시는 `description`(HTML)이 맡는다 — 소비자는 이 필드를 몰라도 된다.
|
|
1223
|
+
*/
|
|
1224
|
+
descriptionJson: z.string().nullable().optional(),
|
|
1225
|
+
originalPrice: z.number().int().nullable().optional(),
|
|
1226
|
+
images: z.array(z.url()).optional(),
|
|
1227
|
+
attributes: z.array(z.object({
|
|
1228
|
+
attributeId: z.string(),
|
|
1229
|
+
value: z.string()
|
|
1230
|
+
})).optional(),
|
|
1231
|
+
optionGroups: z.array(optionGroupSchema),
|
|
1232
|
+
/** 모든 상품이 최소 1개 variant를 갖는다 (옵션 없으면 default variant 1개) */
|
|
1233
|
+
variants: z.array(productVariantSchema),
|
|
1234
|
+
deliveryType: deliveryTypeSchema.optional(),
|
|
1235
|
+
deliveryFee: z.number().int().optional(),
|
|
1236
|
+
conditionalFreeAmount: z.number().int().nullable().optional(),
|
|
1237
|
+
returnFee: z.number().int().optional(),
|
|
1238
|
+
exchangeFee: z.number().int().optional(),
|
|
1239
|
+
shippingAddressId: z.string().nullable().optional(),
|
|
1240
|
+
returnAddressId: z.string().nullable().optional(),
|
|
1241
|
+
createdAt: z.string().optional()
|
|
1242
|
+
});
|
|
1243
|
+
/** 옵션 축(그룹) 최대 개수 — 스키마 상한의 단일 원천 */
|
|
1244
|
+
const MAX_OPTION_GROUPS = 3;
|
|
1245
|
+
/** 축 하나가 가질 수 있는 값의 최대 개수 */
|
|
1246
|
+
const MAX_VALUES_PER_GROUP = 100;
|
|
1247
|
+
/** 상품 하나가 가질 수 있는 variant(SKU) 최대 개수 */
|
|
1248
|
+
const MAX_VARIANTS = 500;
|
|
1249
|
+
/**
|
|
1250
|
+
* 식별 코드 — 빈 문자열·공백만 있는 값은 거부한다.
|
|
1251
|
+
* `""`를 통과시키면 서버 중복 사전 검사(falsy skip)와 DB partial unique
|
|
1252
|
+
* (`WHERE ... is not null`)의 판정이 갈려 409 대신 500이 나간다.
|
|
1253
|
+
*/
|
|
1254
|
+
const codeSchema = z.string().trim().min(1).max(64);
|
|
1255
|
+
const optionValueRequestSchema = z.object({
|
|
1256
|
+
/** 전달 시 기존 값 행을 유지(UPDATE). 생략하면 신규 발급 */
|
|
1257
|
+
valueId: z.string().optional(),
|
|
1258
|
+
name: z.string().min(1).max(50)
|
|
1259
|
+
});
|
|
1260
|
+
const optionGroupRequestSchema = z.object({
|
|
1261
|
+
/** 전달 시 기존 축 행을 유지(UPDATE). 생략하면 신규 발급 */
|
|
1262
|
+
groupId: z.string().optional(),
|
|
1263
|
+
name: z.string().min(1).max(50),
|
|
1264
|
+
values: z.array(optionValueRequestSchema).min(1).max(100)
|
|
1265
|
+
});
|
|
1266
|
+
const productVariantRequestSchema = z.object({
|
|
1267
|
+
/** 기존 variant 유지 시 전달. 생략하면 신규 생성 */
|
|
1268
|
+
variantId: z.string().optional(),
|
|
1269
|
+
/** optionGroups 순서대로 각 축에서 고른 값 이름. 서버가 이름 → valueId를 해석한다 */
|
|
1270
|
+
valueNames: z.array(z.string()).default([]),
|
|
1271
|
+
additionalPrice: z.number().int().default(0),
|
|
1272
|
+
/**
|
|
1273
|
+
* 생략 = 기존 재고 유지 (신규 variant는 0).
|
|
1274
|
+
* 구조 교체(PUT)가 폼 로드 시점의 재고를 되쓰지 않게 하는 옵트인 필드다 —
|
|
1275
|
+
* 재고만 바꿀 때는 PATCH /products/{id}/variants/{variantId}/stock을 쓴다.
|
|
1276
|
+
*/
|
|
1277
|
+
stockQuantity: z.number().int().min(0).optional(),
|
|
1278
|
+
sku: codeSchema.optional(),
|
|
1279
|
+
barcode: codeSchema.optional(),
|
|
1280
|
+
costPrice: z.number().int().min(0).optional(),
|
|
1281
|
+
weightGram: z.number().int().min(0).optional(),
|
|
1282
|
+
sellerCode: codeSchema.optional(),
|
|
1283
|
+
usable: z.boolean().default(true)
|
|
1284
|
+
});
|
|
1285
|
+
/** 축 × 값 → variant 전체 교체 요청 (PUT /products/{productId}/options) */
|
|
1286
|
+
const optionModelRequestSchema = z.object({
|
|
1287
|
+
optionGroups: z.array(optionGroupRequestSchema).max(3).default([]),
|
|
1288
|
+
variants: z.array(productVariantRequestSchema).min(1).max(500)
|
|
1289
|
+
});
|
|
1290
|
+
/** GET /products/{productId}/options 응답 */
|
|
1291
|
+
const optionModelSchema = z.object({
|
|
1292
|
+
optionGroups: z.array(optionGroupSchema),
|
|
1293
|
+
variants: z.array(productVariantSchema)
|
|
1294
|
+
});
|
|
1295
|
+
/**
|
|
1296
|
+
* 재고 변경 택일 위반 문구 — SDK `.refine`과 서버 DomainException이 공유하는 단일 원천.
|
|
1297
|
+
* 경로에 따라 셀러가 다른 문구를 받지 않게 한다.
|
|
1298
|
+
*/
|
|
1299
|
+
const VARIANT_STOCK_EXCLUSIVE_MESSAGE = "stockQuantity 또는 adjustment 중 하나만 전달해야 합니다";
|
|
1300
|
+
/**
|
|
1301
|
+
* variant 재고 변경 요청 — 절대값(`stockQuantity`) 또는 증감(`adjustment`) 택일.
|
|
1302
|
+
* 서버도 같은 규칙을 400 VALIDATION_FAILED로 검증한다(이중 방어).
|
|
1303
|
+
*/
|
|
1304
|
+
const variantStockRequestSchema = z.object({
|
|
1305
|
+
stockQuantity: z.number().int().min(0).optional(),
|
|
1306
|
+
adjustment: z.number().int().optional()
|
|
1307
|
+
}).refine((v) => v.stockQuantity !== void 0 !== (v.adjustment !== void 0), { message: VARIANT_STOCK_EXCLUSIVE_MESSAGE });
|
|
1308
|
+
/** PATCH /products/{productId}/variants/{variantId}/stock 응답 */
|
|
1309
|
+
const variantStockSchema = z.object({
|
|
1310
|
+
variantId: z.string(),
|
|
1311
|
+
stockQuantity: z.number().int()
|
|
1312
|
+
});
|
|
1313
|
+
const productCreateRequestSchema = z.object({
|
|
1314
|
+
name: z.string().min(1).max(100),
|
|
1315
|
+
categoryId: z.string().min(1, "카테고리를 선택해주세요"),
|
|
1316
|
+
salePrice: z.number().int().min(10).multipleOf(10, "판매가는 10원 단위여야 합니다 (INVALID_PRICE_UNIT)"),
|
|
1317
|
+
originalPrice: z.number().int().optional(),
|
|
1318
|
+
description: z.string().optional(),
|
|
1319
|
+
/**
|
|
1320
|
+
* 상세설명 에디터(@avarlabs/editor)의 직렬화 상태. 재편집 때 이 값으로 문서를 복원한다.
|
|
1321
|
+
* 표시는 `description`(HTML)이 맡는다 — 소비자는 이 필드를 몰라도 된다.
|
|
1322
|
+
*/
|
|
1323
|
+
descriptionJson: z.string().nullable().optional(),
|
|
1324
|
+
images: z.array(z.url()).min(1).max(10).optional(),
|
|
1325
|
+
attributes: z.array(z.object({
|
|
1326
|
+
attributeId: z.string(),
|
|
1327
|
+
value: z.string()
|
|
1328
|
+
})).optional(),
|
|
1329
|
+
deliveryType: deliveryTypeSchema.default("PAID"),
|
|
1330
|
+
deliveryFee: z.number().int().default(0),
|
|
1331
|
+
conditionalFreeAmount: z.number().int().optional(),
|
|
1332
|
+
returnFee: z.number().int().default(0),
|
|
1333
|
+
exchangeFee: z.number().int().default(0),
|
|
1334
|
+
shippingAddressId: z.string().optional(),
|
|
1335
|
+
returnAddressId: z.string().optional()
|
|
1336
|
+
}).extend(optionModelRequestSchema.shape);
|
|
1337
|
+
const productPatchRequestSchema = z.object({
|
|
1338
|
+
name: z.string().max(100).optional(),
|
|
1339
|
+
salePrice: z.number().int().multipleOf(10).optional(),
|
|
1340
|
+
status: productStatusSchema.optional(),
|
|
1341
|
+
deliveryFee: z.number().int().optional()
|
|
1342
|
+
}).refine((value) => Object.values(value).some((v) => v !== void 0), { message: "최소 1개 필드가 필요합니다" });
|
|
1343
|
+
const discountSchema = z.object({
|
|
1344
|
+
productId: z.string(),
|
|
1345
|
+
discountType: z.enum(["RATE", "AMOUNT"]),
|
|
1346
|
+
discountValue: z.number().int(),
|
|
1347
|
+
discountedPrice: z.number().int(),
|
|
1348
|
+
startAt: z.string().nullable(),
|
|
1349
|
+
endAt: z.string().nullable()
|
|
1350
|
+
});
|
|
1351
|
+
const discountRequestSchema = z.object({
|
|
1352
|
+
discountType: z.enum(["RATE", "AMOUNT"]),
|
|
1353
|
+
discountValue: z.number().int().min(1),
|
|
1354
|
+
startAt: z.string().optional(),
|
|
1355
|
+
endAt: z.string().optional()
|
|
1356
|
+
});
|
|
1357
|
+
//#endregion
|
|
1358
|
+
//#region src/schemas/store.ts
|
|
1359
|
+
/**
|
|
1360
|
+
* 스토어 코드 규칙 — 스토어프론트 API가 테넌트를 가르는 값이다(`X-Store-Code` 헤더).
|
|
1361
|
+
* **개설할 때 서버가 랜덤으로 발급하고** 이후 바꿀 수 없다. 이미 나간 링크와 승인된 결제가
|
|
1362
|
+
* 이 값에 묶여 있어서 바꾸면 함께 깨진다.
|
|
1363
|
+
*
|
|
1364
|
+
* 호스트 라벨로도 쓸 수 있게 DNS 라벨 규칙까지 좁힌다. 하이픈으로 시작하거나 끝나는 라벨은 호스트가 될 수 없다.
|
|
1365
|
+
*/
|
|
1366
|
+
const STORE_CODE_PATTERN = /^[a-z0-9](?:[a-z0-9-]{1,28}[a-z0-9])$/;
|
|
1367
|
+
/** 플랫폼이 쓰거나 쓸 호스트 이름 — 스토어 코드로 내주지 않는다 */
|
|
1368
|
+
const RESERVED_STORE_CODES = [
|
|
1369
|
+
"www",
|
|
1370
|
+
"api",
|
|
1371
|
+
"store",
|
|
1372
|
+
"stores",
|
|
1373
|
+
"checkout",
|
|
1374
|
+
"docs",
|
|
1375
|
+
"admin",
|
|
1376
|
+
"app",
|
|
1377
|
+
"console",
|
|
1378
|
+
"dashboard",
|
|
1379
|
+
"platform",
|
|
1380
|
+
"internal",
|
|
1381
|
+
"auth",
|
|
1382
|
+
"login",
|
|
1383
|
+
"oauth",
|
|
1384
|
+
"account",
|
|
1385
|
+
"accounts",
|
|
1386
|
+
"pay",
|
|
1387
|
+
"payment",
|
|
1388
|
+
"payments",
|
|
1389
|
+
"webhook",
|
|
1390
|
+
"webhooks",
|
|
1391
|
+
"status",
|
|
1392
|
+
"support",
|
|
1393
|
+
"help",
|
|
1394
|
+
"blog",
|
|
1395
|
+
"cdn",
|
|
1396
|
+
"static",
|
|
1397
|
+
"assets",
|
|
1398
|
+
"img",
|
|
1399
|
+
"images",
|
|
1400
|
+
"media",
|
|
1401
|
+
"mail",
|
|
1402
|
+
"smtp",
|
|
1403
|
+
"ftp",
|
|
1404
|
+
"ns",
|
|
1405
|
+
"ns1",
|
|
1406
|
+
"ns2",
|
|
1407
|
+
"mx",
|
|
1408
|
+
"dev",
|
|
1409
|
+
"test",
|
|
1410
|
+
"staging",
|
|
1411
|
+
"stage",
|
|
1412
|
+
"sandbox"
|
|
1413
|
+
];
|
|
1414
|
+
function isReservedStoreCode(code) {
|
|
1415
|
+
return RESERVED_STORE_CODES.includes(code.toLowerCase());
|
|
1416
|
+
}
|
|
1417
|
+
const storeCodeSchema = z.string().regex(STORE_CODE_PATTERN, "소문자·숫자·하이픈 3~30자여야 하고 하이픈으로 시작하거나 끝날 수 없어요").refine((code) => !isReservedStoreCode(code), "플랫폼이 쓰는 이름이라 스토어 코드로 쓸 수 없어요");
|
|
1418
|
+
const storeSchema = z.object({
|
|
1419
|
+
storeId: z.string(),
|
|
1420
|
+
name: z.string(),
|
|
1421
|
+
storeCode: z.string(),
|
|
1422
|
+
storefrontUrl: z.url(),
|
|
1423
|
+
status: z.enum([
|
|
1424
|
+
"ACTIVE",
|
|
1425
|
+
"SUSPENDED",
|
|
1426
|
+
"CLOSED"
|
|
1427
|
+
]),
|
|
1428
|
+
logoUrl: z.url().nullable(),
|
|
1429
|
+
businessNumber: z.string().nullable(),
|
|
1430
|
+
customerCenterPhone: z.string().nullable(),
|
|
1431
|
+
memberCount: z.number().int(),
|
|
1432
|
+
createdAt: z.string()
|
|
1433
|
+
});
|
|
1434
|
+
const storeMemberSchema = z.object({
|
|
1435
|
+
userId: z.string(),
|
|
1436
|
+
name: z.string(),
|
|
1437
|
+
email: z.email(),
|
|
1438
|
+
role: storeRoleSchema,
|
|
1439
|
+
joinedAt: z.string(),
|
|
1440
|
+
lastActiveAt: z.string().nullable()
|
|
1441
|
+
});
|
|
1442
|
+
const invitationSchema = z.object({
|
|
1443
|
+
invitationId: z.string(),
|
|
1444
|
+
email: z.email(),
|
|
1445
|
+
role: z.enum(["ADMIN", "STAFF"]),
|
|
1446
|
+
status: z.enum([
|
|
1447
|
+
"PENDING",
|
|
1448
|
+
"ACCEPTED",
|
|
1449
|
+
"EXPIRED",
|
|
1450
|
+
"CANCELED"
|
|
1451
|
+
]),
|
|
1452
|
+
invitedBy: z.string(),
|
|
1453
|
+
expiresAt: z.string(),
|
|
1454
|
+
createdAt: z.string()
|
|
1455
|
+
});
|
|
1456
|
+
const addressTypeSchema = z.enum(["SHIPPING", "RETURN"]);
|
|
1457
|
+
const sellerAddressSchema = z.object({
|
|
1458
|
+
addressId: z.string(),
|
|
1459
|
+
type: addressTypeSchema,
|
|
1460
|
+
name: z.string(),
|
|
1461
|
+
isDefault: z.boolean(),
|
|
1462
|
+
contactName: z.string(),
|
|
1463
|
+
phone: z.string(),
|
|
1464
|
+
zipCode: z.string(),
|
|
1465
|
+
address1: z.string(),
|
|
1466
|
+
address2: z.string().optional(),
|
|
1467
|
+
createdAt: z.string()
|
|
1468
|
+
});
|
|
1469
|
+
const sellerAddressRequestSchema = z.object({
|
|
1470
|
+
type: addressTypeSchema,
|
|
1471
|
+
name: z.string().min(1).max(50),
|
|
1472
|
+
contactName: z.string().min(1),
|
|
1473
|
+
phone: z.string().regex(/^01[0-9]{8,9}$/),
|
|
1474
|
+
zipCode: z.string().regex(/^[0-9]{5}$/),
|
|
1475
|
+
address1: z.string().min(1),
|
|
1476
|
+
address2: z.string().optional()
|
|
1477
|
+
});
|
|
1478
|
+
//#endregion
|
|
1479
|
+
//#region src/schemas/upload.ts
|
|
1480
|
+
/**
|
|
1481
|
+
* 미디어 업로드 자리 발급 — `POST /v1/uploads`.
|
|
1482
|
+
*
|
|
1483
|
+
* 파일 바이트는 api를 거치지 않는다. 응답의 `uploadUrl`로 클라이언트가 직접 PUT하고,
|
|
1484
|
+
* 끝나면 `url`이 공개 주소가 된다. 서버가 객체 키를 정하므로 요청은 파일명만 보낸다
|
|
1485
|
+
* (확장자만 쓰이고 이름 자체는 저장되지 않는다).
|
|
1486
|
+
*/
|
|
1487
|
+
const uploadRequestSchema = z.object({ filename: z.string().min(1).max(255) });
|
|
1488
|
+
const uploadTicketSchema = z.object({
|
|
1489
|
+
/** 업로드 완료 후 공개될 주소 */
|
|
1490
|
+
url: z.url(),
|
|
1491
|
+
/** 파일 바이트를 PUT할 presigned URL. 1시간 유효 */
|
|
1492
|
+
uploadUrl: z.url()
|
|
1493
|
+
});
|
|
1494
|
+
//#endregion
|
|
1495
|
+
//#region src/client.ts
|
|
1496
|
+
function formBody(fields) {
|
|
1497
|
+
return new URLSearchParams(fields).toString();
|
|
1498
|
+
}
|
|
1499
|
+
function createStoreClient(options) {
|
|
1500
|
+
const fetchFn = options.fetch ?? globalThis.fetch;
|
|
1501
|
+
const { session } = options;
|
|
1502
|
+
const userTokenSource = options.userToken ?? (session ? () => session.getToken() : void 0);
|
|
1503
|
+
const refresh = options.refresh ?? (session ? () => session.refresh() : void 0);
|
|
1504
|
+
const onUnauthorized = options.onUnauthorized ?? (session ? () => session.clear() : void 0);
|
|
1505
|
+
const storeTokenSource = options.storeToken ?? options.auth?.apiToken;
|
|
1506
|
+
const userHttp = createHttp({
|
|
1507
|
+
baseUrl: options.baseUrl,
|
|
1508
|
+
fetch: fetchFn,
|
|
1509
|
+
headers: () => {
|
|
1510
|
+
const token = resolveToken(userTokenSource);
|
|
1511
|
+
return token ? { authorization: `Bearer ${token}` } : {};
|
|
1512
|
+
},
|
|
1513
|
+
refresh,
|
|
1514
|
+
onUnauthorized
|
|
1515
|
+
});
|
|
1516
|
+
const storeHttp = createHttp({
|
|
1517
|
+
baseUrl: options.baseUrl,
|
|
1518
|
+
fetch: fetchFn,
|
|
1519
|
+
headers: () => {
|
|
1520
|
+
const token = resolveToken(storeTokenSource);
|
|
1521
|
+
const storeId = resolveToken(options.storeId);
|
|
1522
|
+
return {
|
|
1523
|
+
...token ? { authorization: `Bearer ${token}` } : {},
|
|
1524
|
+
...storeId ? { "x-store-id": storeId } : {}
|
|
1525
|
+
};
|
|
1526
|
+
},
|
|
1527
|
+
refresh,
|
|
1528
|
+
onUnauthorized
|
|
1529
|
+
});
|
|
1530
|
+
/** `PATCH /v1/store/payment-providers/{provider}` 단일 구현 — `updateProvider`·`setSandboxMode`가 공유한다 */
|
|
1531
|
+
function updatePaymentProvider(provider, body) {
|
|
1532
|
+
return storeHttp.request("PATCH", path`/store/payment-providers/${pgProviderSchema.parse(provider)}`, paymentProviderSchema, { body: updatePaymentProviderRequestSchema.parse(body) });
|
|
1533
|
+
}
|
|
1534
|
+
function idempotency(key) {
|
|
1535
|
+
return key ? { "idempotency-key": key } : void 0;
|
|
1536
|
+
}
|
|
1537
|
+
function requireSession() {
|
|
1538
|
+
if (!session) throw new Error("oauth 세션 없음 — createStoreSession 필요");
|
|
1539
|
+
return session;
|
|
1540
|
+
}
|
|
1541
|
+
return {
|
|
1542
|
+
/** session의 현재 userToken (session 미배선 시 null — 던지지 않음) */
|
|
1543
|
+
get accessToken() {
|
|
1544
|
+
return session ? session.accessToken : null;
|
|
1545
|
+
},
|
|
1546
|
+
/** session의 현재 refresh 토큰 (session 미배선 시 null — 던지지 않음) */
|
|
1547
|
+
get refreshToken() {
|
|
1548
|
+
return session ? session.refreshToken : null;
|
|
1549
|
+
},
|
|
1550
|
+
/** session.authorize 패스스루 — session 미배선 시 명확한 에러를 던진다 */
|
|
1551
|
+
authorize: (authorizeOptions) => requireSession().authorize(authorizeOptions),
|
|
1552
|
+
/** session.userinfo 패스스루 — session 미배선 시 명확한 에러를 던진다 */
|
|
1553
|
+
userinfo: () => requireSession().userinfo(),
|
|
1554
|
+
auth: {
|
|
1555
|
+
/**
|
|
1556
|
+
* 서버 간 연동(M2M) — client_credentials grant (client_secret_basic).
|
|
1557
|
+
* 클라이언트가 스토어에 바인딩되어 있어 **storeToken이 곧바로 발급**된다.
|
|
1558
|
+
* ⚠ client_secret은 서버 환경에서만 사용할 것 (브라우저 반입 금지)
|
|
1559
|
+
*/
|
|
1560
|
+
async issueStoreTokenByClientCredentials(clientId, clientSecret, scope) {
|
|
1561
|
+
const credentials = base64Utf8(`${clientId}:${clientSecret}`);
|
|
1562
|
+
const response = await fetchFn(`${options.baseUrl}/oauth2/token`, {
|
|
1563
|
+
method: "POST",
|
|
1564
|
+
headers: {
|
|
1565
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
1566
|
+
authorization: `Basic ${credentials}`
|
|
1567
|
+
},
|
|
1568
|
+
body: formBody({
|
|
1569
|
+
grant_type: "client_credentials",
|
|
1570
|
+
...scope ? { scope } : {}
|
|
1571
|
+
})
|
|
1572
|
+
});
|
|
1573
|
+
if (!response.ok) {
|
|
1574
|
+
const body = await response.json().catch(() => null);
|
|
1575
|
+
throw new Error(body?.error_description ?? `토큰 발급 실패 (HTTP ${response.status})`);
|
|
1576
|
+
}
|
|
1577
|
+
return clientCredentialsTokenResponseSchema.parse(unwrapData(await response.json()));
|
|
1578
|
+
},
|
|
1579
|
+
/** 2단계 — 소속 스토어 목록 */
|
|
1580
|
+
listMyStores: () => userHttp.request("GET", "/me/stores", z.array(myStoreSchema)),
|
|
1581
|
+
/** RFC 7009 — token을 폐기한다 (public client는 client_id만 동반) */
|
|
1582
|
+
async revoke(token, clientId) {
|
|
1583
|
+
await fetchFn(`${options.baseUrl}/oauth2/revoke`, {
|
|
1584
|
+
method: "POST",
|
|
1585
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
1586
|
+
body: formBody({
|
|
1587
|
+
token,
|
|
1588
|
+
client_id: clientId
|
|
1589
|
+
})
|
|
1590
|
+
});
|
|
1591
|
+
},
|
|
1592
|
+
me: () => userHttp.request("GET", "/me", myAccountSchema)
|
|
1593
|
+
},
|
|
1594
|
+
/** API 토큰(PAT) 관리 — OWNER/ADMIN 전용. 발급된 토큰은 storeToken처럼 Bearer로 사용 */
|
|
1595
|
+
apiTokens: {
|
|
1596
|
+
create: (body) => storeHttp.request("POST", "/store/api-tokens", apiTokenCreatedSchema, { body: apiTokenCreateRequestSchema.parse(body) }),
|
|
1597
|
+
list: () => storeHttp.request("GET", "/store/api-tokens", z.array(apiTokenSchema)),
|
|
1598
|
+
revoke: (tokenId) => storeHttp.requestVoid("DELETE", path`/store/api-tokens/${tokenId}`)
|
|
1599
|
+
},
|
|
1600
|
+
/**
|
|
1601
|
+
* 앱(OAuth 클라이언트) 관리 — OWNER/ADMIN storeToken 전용(PAT·M2M 거부). auth-spec §12, 부록 B-13.
|
|
1602
|
+
* 시크릿 평문은 register·rotateSecret 응답에서만 한 번 노출된다(apiTokens.create와 같은 성격).
|
|
1603
|
+
*/
|
|
1604
|
+
apiClients: {
|
|
1605
|
+
/**
|
|
1606
|
+
* DCR 등록 (RFC 7591) — `POST /v1/oauth2/register`. OAuth 프로토콜 표면이라 응답이 평면 JSON이다.
|
|
1607
|
+
* 등록 오류(`invalid_client_metadata`·`invalid_redirect_uri`)는 ApiError(code=error,
|
|
1608
|
+
* message=error_description)로, Bearer 인증 오류는 엔벨로프 그대로 ApiError로 던진다.
|
|
1609
|
+
*/
|
|
1610
|
+
register: (body) => storeHttp.request("POST", "/oauth2/register", oauthClientRegistrationResponseSchema, { body: oauthClientRegistrationRequestSchema.parse(body) }),
|
|
1611
|
+
list: (params) => storeHttp.request("GET", "/store/api-clients", z.array(apiClientSchema), { query: params }),
|
|
1612
|
+
get: (clientId) => storeHttp.request("GET", path`/store/api-clients/${clientId}`, apiClientSchema),
|
|
1613
|
+
/** confidential 전용. 기존 시크릿은 즉시 무효(유예 없음) */
|
|
1614
|
+
rotateSecret: (clientId) => storeHttp.request("POST", path`/store/api-clients/${clientId}/rotate-secret`, apiClientSecretRotatedSchema),
|
|
1615
|
+
/** REVOKED로 전환(되돌릴 수 없음). 이미 비활성이면 현재 상태를 그대로 돌려준다 */
|
|
1616
|
+
deactivate: (clientId) => storeHttp.request("POST", path`/store/api-clients/${clientId}/deactivate`, apiClientSchema)
|
|
1617
|
+
},
|
|
1618
|
+
/**
|
|
1619
|
+
* 미디어 업로드 — 자리를 받아(`POST /uploads`) 파일 바이트는 호출자가
|
|
1620
|
+
* `uploadUrl`로 직접 PUT한다. 업로드가 끝나면 `url`이 공개 주소가 된다.
|
|
1621
|
+
*/
|
|
1622
|
+
uploads: { create: (body) => storeHttp.request("POST", "/uploads", uploadTicketSchema, { body: uploadRequestSchema.parse(body) }) },
|
|
1623
|
+
products: {
|
|
1624
|
+
list: (params) => storeHttp.request("GET", "/products", pageSchema(productSummarySchema), { query: params }),
|
|
1625
|
+
get: (productId) => storeHttp.request("GET", path`/products/${productId}`, productSchema),
|
|
1626
|
+
create: (body, idempotencyKey) => storeHttp.request("POST", "/products", productSchema, {
|
|
1627
|
+
body: productCreateRequestSchema.parse(body),
|
|
1628
|
+
headers: idempotency(idempotencyKey)
|
|
1629
|
+
}),
|
|
1630
|
+
update: (productId, body) => storeHttp.request("PUT", path`/products/${productId}`, productSchema, { body: productCreateRequestSchema.parse(body) }),
|
|
1631
|
+
patch: (productId, body) => storeHttp.requestVoid("PATCH", path`/products/${productId}`, { body: productPatchRequestSchema.parse(body) }),
|
|
1632
|
+
remove: (productId) => storeHttp.requestVoid("DELETE", path`/products/${productId}`),
|
|
1633
|
+
bulkUpdateStatus: (productIds, status, idempotencyKey) => storeHttp.request("POST", "/products/bulk/status", bulkResultSchema, {
|
|
1634
|
+
body: {
|
|
1635
|
+
productIds,
|
|
1636
|
+
status: productStatusSchema.parse(status)
|
|
1637
|
+
},
|
|
1638
|
+
headers: idempotency(idempotencyKey)
|
|
1639
|
+
}),
|
|
1640
|
+
/** 옵션 축(그룹) × 값 → variant 모델 조회 */
|
|
1641
|
+
getOptionModel: (productId) => storeHttp.request("GET", path`/products/${productId}/options`, optionModelSchema),
|
|
1642
|
+
/**
|
|
1643
|
+
* 옵션 모델 전체 교체 — variants는 valueNames로 축 값을 지목한다.
|
|
1644
|
+
* 응답으로 서버가 배정한 groupId·valueId·신규 variantId가 실린 교체 결과를 돌려준다.
|
|
1645
|
+
*/
|
|
1646
|
+
replaceOptionModel: (productId, body) => storeHttp.request("PUT", path`/products/${productId}/options`, optionModelSchema, { body: optionModelRequestSchema.parse(body) }),
|
|
1647
|
+
/** 재고 변경 — stockQuantity(절대값) 또는 adjustment(증감) 하나만 전달한다 */
|
|
1648
|
+
updateVariantStock: (productId, variantId, body) => storeHttp.request("PATCH", path`/products/${productId}/variants/${variantId}/stock`, variantStockSchema, { body: variantStockRequestSchema.parse(body) }),
|
|
1649
|
+
getDiscount: (productId) => storeHttp.request("GET", path`/products/${productId}/discount`, discountSchema),
|
|
1650
|
+
setDiscount: (productId, body) => storeHttp.request("PUT", path`/products/${productId}/discount`, discountSchema, { body: discountRequestSchema.parse(body) }),
|
|
1651
|
+
removeDiscount: (productId) => storeHttp.requestVoid("DELETE", path`/products/${productId}/discount`)
|
|
1652
|
+
},
|
|
1653
|
+
categories: {
|
|
1654
|
+
list: (params) => storeHttp.request("GET", "/categories", z.array(categorySchema), { query: params }),
|
|
1655
|
+
tree: () => storeHttp.request("GET", "/categories", z.array(categoryTreeNodeSchema), { query: { tree: true } }),
|
|
1656
|
+
search: (q) => storeHttp.request("GET", "/categories", z.array(categorySearchResultSchema), { query: { q } }),
|
|
1657
|
+
get: (categoryId) => storeHttp.request("GET", path`/categories/${categoryId}`, categoryWithPathSchema),
|
|
1658
|
+
listAttributes: (categoryId) => storeHttp.request("GET", path`/categories/${categoryId}/attributes`, z.array(categoryAttributeSchema)),
|
|
1659
|
+
create: (body, idempotencyKey) => storeHttp.request("POST", "/categories", categorySchema, {
|
|
1660
|
+
body: categoryCreateRequestSchema.parse(body),
|
|
1661
|
+
headers: idempotency(idempotencyKey)
|
|
1662
|
+
}),
|
|
1663
|
+
update: (categoryId, body) => storeHttp.request("PATCH", path`/categories/${categoryId}`, categorySchema, { body: categoryPatchRequestSchema.parse(body) }),
|
|
1664
|
+
remove: (categoryId, opts) => storeHttp.requestVoid("DELETE", path`/categories/${categoryId}`, { query: opts?.strategy ? { strategy: opts.strategy } : {} })
|
|
1665
|
+
},
|
|
1666
|
+
orders: {
|
|
1667
|
+
/**
|
|
1668
|
+
* `GET /v1/orders`. `testPayment: "true"`는 테스트 결제(샌드박스) 주문만, `"false"`는 실결제 주문만, 생략하면 전부다.
|
|
1669
|
+
* 그 밖의 값은 400 `INVALID_PARAMETER`. 행마다 `testPayment`가 붙는다.
|
|
1670
|
+
*/
|
|
1671
|
+
list: (params) => storeHttp.request("GET", "/orders", pageSchema(orderSummarySchema), { query: params }),
|
|
1672
|
+
get: (orderId) => storeHttp.request("GET", path`/orders/${orderId}`, orderSchema)
|
|
1673
|
+
},
|
|
1674
|
+
orderItems: {
|
|
1675
|
+
/** `GET /v1/order-items`. `testPayment` 필터는 `orders.list`와 같다 */
|
|
1676
|
+
listChanged: (params) => storeHttp.request("GET", "/order-items", pageSchema(orderItemSchema), { query: params }),
|
|
1677
|
+
confirm: (orderItemId, idempotencyKey) => storeHttp.requestVoid("POST", path`/order-items/${orderItemId}/confirm`, { headers: idempotency(idempotencyKey) }),
|
|
1678
|
+
confirmBulk: (orderItemIds, idempotencyKey) => storeHttp.request("POST", "/order-items/confirm", bulkResultSchema, {
|
|
1679
|
+
body: { orderItemIds },
|
|
1680
|
+
headers: idempotency(idempotencyKey)
|
|
1681
|
+
}),
|
|
1682
|
+
hold: (orderItemId, body) => storeHttp.requestVoid("POST", path`/order-items/${orderItemId}/hold`, { body: holdOrderItemRequestSchema.parse(body) }),
|
|
1683
|
+
releaseHold: (orderItemId) => storeHttp.requestVoid("DELETE", path`/order-items/${orderItemId}/hold`),
|
|
1684
|
+
cancelBySeller: (orderItemId, body, idempotencyKey) => storeHttp.request("POST", path`/order-items/${orderItemId}/cancel`, z.object({
|
|
1685
|
+
orderItemId: z.string(),
|
|
1686
|
+
refundAmount: z.number().int(),
|
|
1687
|
+
refundedAt: z.string()
|
|
1688
|
+
}), {
|
|
1689
|
+
body: cancelBySellerRequestSchema.parse(body),
|
|
1690
|
+
headers: idempotency(idempotencyKey)
|
|
1691
|
+
}),
|
|
1692
|
+
updateMemo: (orderItemId, memo) => storeHttp.requestVoid("PUT", path`/order-items/${orderItemId}/memo`, { body: { memo } }),
|
|
1693
|
+
requestPurchaseDecision: (orderItemId) => storeHttp.requestVoid("POST", path`/order-items/${orderItemId}/purchase-decision/request`),
|
|
1694
|
+
extendPurchaseDecision: (orderItemId) => storeHttp.request("POST", path`/order-items/${orderItemId}/purchase-decision/extend`, z.object({
|
|
1695
|
+
orderItemId: z.string(),
|
|
1696
|
+
autoDecisionDate: z.string(),
|
|
1697
|
+
extendCount: z.number().int().max(2)
|
|
1698
|
+
})),
|
|
1699
|
+
dispatch: (orderItemId, body, idempotencyKey) => storeHttp.request("POST", path`/order-items/${orderItemId}/dispatch`, deliveryInfoSchema, {
|
|
1700
|
+
body: dispatchRequestSchema.parse(body),
|
|
1701
|
+
headers: idempotency(idempotencyKey)
|
|
1702
|
+
}),
|
|
1703
|
+
dispatchBulk: (dispatches, idempotencyKey) => storeHttp.request("POST", "/order-items/dispatch", bulkResultSchema, {
|
|
1704
|
+
body: { dispatches },
|
|
1705
|
+
headers: idempotency(idempotencyKey)
|
|
1706
|
+
}),
|
|
1707
|
+
updateTracking: (orderItemId, carrierCode, trackingNumber) => storeHttp.requestVoid("PUT", path`/order-items/${orderItemId}/dispatch/tracking`, { body: {
|
|
1708
|
+
carrierCode,
|
|
1709
|
+
trackingNumber
|
|
1710
|
+
} }),
|
|
1711
|
+
getDelivery: (orderItemId) => storeHttp.request("GET", path`/order-items/${orderItemId}/delivery`, deliveryInfoSchema.extend({ trackingEvents: z.array(z.object({
|
|
1712
|
+
status: z.string(),
|
|
1713
|
+
location: z.string(),
|
|
1714
|
+
occurredAt: z.string()
|
|
1715
|
+
})).optional() }))
|
|
1716
|
+
},
|
|
1717
|
+
carriers: { list: () => storeHttp.request("GET", "/carriers", z.array(carrierSchema)) },
|
|
1718
|
+
claims: {
|
|
1719
|
+
list: (params) => storeHttp.request("GET", "/claims", pageSchema(claimSchema), { query: params }),
|
|
1720
|
+
get: (claimId) => storeHttp.request("GET", path`/claims/${claimId}`, claimSchema.extend({ histories: z.array(z.object({
|
|
1721
|
+
status: claimStatusSchema,
|
|
1722
|
+
actor: z.enum([
|
|
1723
|
+
"BUYER",
|
|
1724
|
+
"SELLER",
|
|
1725
|
+
"SYSTEM"
|
|
1726
|
+
]),
|
|
1727
|
+
occurredAt: z.string()
|
|
1728
|
+
})).optional() })),
|
|
1729
|
+
approve: (claimId, deductAmount, idempotencyKey) => storeHttp.request("POST", path`/claims/${claimId}/approve`, approveClaimResultSchema, {
|
|
1730
|
+
body: deductAmount !== void 0 ? { deductAmount } : {},
|
|
1731
|
+
headers: idempotency(idempotencyKey)
|
|
1732
|
+
}),
|
|
1733
|
+
reject: (claimId, body) => storeHttp.requestVoid("POST", path`/claims/${claimId}/reject`, { body: rejectClaimRequestSchema.parse(body) }),
|
|
1734
|
+
registerCollect: (claimId, body) => storeHttp.requestVoid("POST", path`/claims/${claimId}/collect`, { body }),
|
|
1735
|
+
completeCollect: (claimId) => storeHttp.requestVoid("POST", path`/claims/${claimId}/collect/complete`),
|
|
1736
|
+
registerRedelivery: (claimId, carrierCode, trackingNumber) => storeHttp.requestVoid("POST", path`/claims/${claimId}/redelivery`, { body: {
|
|
1737
|
+
carrierCode,
|
|
1738
|
+
trackingNumber
|
|
1739
|
+
} }),
|
|
1740
|
+
hold: (claimId, reason) => storeHttp.request("POST", path`/claims/${claimId}/hold`, z.object({
|
|
1741
|
+
claimId: z.string(),
|
|
1742
|
+
holdExpiresAt: z.string()
|
|
1743
|
+
}), { body: { reason } }),
|
|
1744
|
+
releaseHold: (claimId) => storeHttp.requestVoid("DELETE", path`/claims/${claimId}/hold`)
|
|
1745
|
+
},
|
|
1746
|
+
inquiries: {
|
|
1747
|
+
list: (params) => storeHttp.request("GET", "/inquiries", pageSchema(inquirySchema), { query: params }),
|
|
1748
|
+
get: (inquiryId) => storeHttp.request("GET", path`/inquiries/${inquiryId}`, inquirySchema),
|
|
1749
|
+
answer: (inquiryId, content) => storeHttp.requestVoid("POST", path`/inquiries/${inquiryId}/answer`, { body: { content } }),
|
|
1750
|
+
updateAnswer: (inquiryId, content) => storeHttp.requestVoid("PUT", path`/inquiries/${inquiryId}/answer`, { body: { content } })
|
|
1751
|
+
},
|
|
1752
|
+
customerInquiries: {
|
|
1753
|
+
list: (params) => storeHttp.request("GET", "/customer-inquiries", pageSchema(sellerCustomerInquirySchema), { query: params }),
|
|
1754
|
+
get: (inquiryId) => storeHttp.request("GET", path`/customer-inquiries/${inquiryId}`, sellerCustomerInquirySchema),
|
|
1755
|
+
answer: (inquiryId, content) => storeHttp.requestVoid("POST", path`/customer-inquiries/${inquiryId}/answer`, { body: { content } }),
|
|
1756
|
+
updateAnswer: (inquiryId, content) => storeHttp.requestVoid("PUT", path`/customer-inquiries/${inquiryId}/answer`, { body: { content } })
|
|
1757
|
+
},
|
|
1758
|
+
/**
|
|
1759
|
+
* @deprecated 정산 API는 폐기 예정이다. 새 정산 레코드는 더 이상 만들어지지 않고 기존 레코드 조회만
|
|
1760
|
+
* 유지된다(서버 응답 헤더 `Deprecation: true`). 대체는 {@link salesReport}(`GET /v1/sales-report`)다.
|
|
1761
|
+
*/
|
|
1762
|
+
settlements: {
|
|
1763
|
+
/** @deprecated `salesReport.get`을 쓴다. 기존 정산 레코드 조회만 유지된다 */
|
|
1764
|
+
list: (params) => storeHttp.request("GET", "/settlements", pageSchema(settlementSummarySchema), { query: params }),
|
|
1765
|
+
/** @deprecated `salesReport.get`을 쓴다. 기존 정산 레코드 조회만 유지된다 */
|
|
1766
|
+
get: (settlementId, params) => storeHttp.request("GET", path`/settlements/${settlementId}`, settlementSummarySchema.extend({ details: z.array(settlementDetailSchema).optional() }), { query: params })
|
|
1767
|
+
},
|
|
1768
|
+
/**
|
|
1769
|
+
* 매출 리포트 (정산 API 대체) — 스코프 `settlement:r`. PG 승인 시각 기준 결제, PG 취소 완료 시각 기준
|
|
1770
|
+
* 환불을 Asia/Seoul 날짜로 집계한다. `rows`는 활동 없는 날짜·PG·결제수단을 생략하므로 차트는 빈 구간을 0으로 채운다.
|
|
1771
|
+
*/
|
|
1772
|
+
salesReport: {
|
|
1773
|
+
/**
|
|
1774
|
+
* `GET /v1/sales-report`. from·to는 양끝 포함, 최대 92일. 테스트 결제(샌드박스)의 승인·환불은 기본 제외이고
|
|
1775
|
+
* `includeTest: "true"`면 포함한다(응답 `includeTest`에 적용 값). 400 `VALIDATION_FAILED`(형식),
|
|
1776
|
+
* 400 `INVALID_PARAMETER`(없는 날짜·to<from), 400 `RANGE_TOO_WIDE`(기간 초과)
|
|
1777
|
+
*/
|
|
1778
|
+
get: (params) => storeHttp.request("GET", "/sales-report", salesReportSchema, { query: salesReportQuerySchema.parse(params) }) },
|
|
1779
|
+
/**
|
|
1780
|
+
* 결제 내역 — 스코프 `settlement:r`. 결제 건(세션) 단위이고 주문 목록과 다르다. 주문이 만들어지지 않은
|
|
1781
|
+
* 결제(카드 거절·결제창 이탈·승인 결과 확인 중)도 그대로 나온다.
|
|
1782
|
+
*/
|
|
1783
|
+
payments: {
|
|
1784
|
+
/**
|
|
1785
|
+
* `GET /v1/payments`. 기간 기준은 결제 시작 시각, 최대 92일이고 생략하면 최근 30일이다. 테스트 결제는
|
|
1786
|
+
* `includeTest: true`일 때만 포함한다. 400 `INVALID_PARAMETER`(시각 형식·to<from), 400 `RANGE_TOO_WIDE`
|
|
1787
|
+
*/
|
|
1788
|
+
list: (params = {}) => storeHttp.request("GET", "/payments", paymentRecordPageSchema, { query: params }),
|
|
1789
|
+
/** `GET /v1/payments/{paymentId}` — 시도·환불 이력 포함. 404 `PAYMENT_NOT_FOUND` */
|
|
1790
|
+
get: (paymentId) => storeHttp.request("GET", path`/payments/${paymentId}`, paymentRecordDetailSchema)
|
|
1791
|
+
},
|
|
1792
|
+
/**
|
|
1793
|
+
* 결제(PG) 설정 — **콘솔 OWNER 전용**. ADMIN·STAFF·PAT·M2M·3rd-party 앱 토큰은 스코프가 있어도
|
|
1794
|
+
* 403 `INSUFFICIENT_ROLE`이다(조회 포함). OWNER 여부는 요청 시점의 스토어 멤버십으로 다시 확인하므로
|
|
1795
|
+
* 양도·강등 직후의 옛 storeToken도 403이다(토큰을 재발급받는다). 비밀 값(시크릿 키·API Secret·웹훅 시크릿)은 요청으로만 보내고
|
|
1796
|
+
* 응답에는 `secrets[].masked`(앞 4자리 마스킹)만 나온다.
|
|
1797
|
+
*/
|
|
1798
|
+
paymentSettings: {
|
|
1799
|
+
/** `GET /v1/store/payment-settings` — 라우팅 모드와 지원 PG 전부(연결된 것은 우선순위순) */
|
|
1800
|
+
get: () => storeHttp.request("GET", "/store/payment-settings", paymentSettingsSchema),
|
|
1801
|
+
/**
|
|
1802
|
+
* `PUT /v1/store/payment-settings` — `priority`에는 연결된 PG를 개수와 무관하게 모두 한 번씩 나열한다
|
|
1803
|
+
* (빠지거나 중복되면 400 `INVALID_PRIORITY`). 진행 중인 결제에는 적용되지 않는다.
|
|
1804
|
+
*/
|
|
1805
|
+
update: (body) => storeHttp.request("PUT", "/store/payment-settings", paymentSettingsSchema, { body: updatePaymentSettingsRequestSchema.parse(body) }),
|
|
1806
|
+
/**
|
|
1807
|
+
* `PUT /v1/store/payment-providers/tosspayments` — 자격 증명 세트 저장. `environment`는 **저장할 세트**(`TEST`·`LIVE`)이고
|
|
1808
|
+
* 결제에 쓰는 세트는 샌드박스 스위치(`setSandboxMode`)가 정한다. 처음 연결하면 `enabled: false`·`sandboxMode: true`로 만들어진다.
|
|
1809
|
+
* 같은 가맹점(`clientKey`)이면 그 세트를 갱신하고, 가맹점이 바뀌면 새 세트를 만들고 이전 세트는 보관한다
|
|
1810
|
+
* (`retiredCredentialCount` +1, 이미 받은 결제의 환불·대사에 계속 쓰인다). `secretKey`·`webhookSecret`을 생략하면
|
|
1811
|
+
* 현재 세트의 값을 이어받고 `webhookSecret: null`은 삭제다. 저장하면 그 세트의 `lastVerification`이 초기화된다.
|
|
1812
|
+
*
|
|
1813
|
+
* 에러: 400 `PAYMENT_PROVIDER_INCOMPLETE`(시크릿 키 없음) · 400 `INVALID_CREDENTIALS`(형식·환경 불일치) ·
|
|
1814
|
+
* 409 `PAYMENT_PROVIDER_VERIFICATION_FAILED`(라이브 사용 중인 PG의 LIVE 세트 가맹점을 바꾸려는데 저장 전 연결 테스트 실패.
|
|
1815
|
+
* 저장되지 않는다. 같은 가맹점의 비밀 값 교체는 검사하지 않는다)
|
|
1816
|
+
*/
|
|
1817
|
+
upsertTossPayments: (body) => storeHttp.request("PUT", "/store/payment-providers/tosspayments", paymentProviderSchema, { body: upsertTossPaymentsProviderRequestSchema.parse(body) }),
|
|
1818
|
+
/**
|
|
1819
|
+
* `PUT /v1/store/payment-providers/portone` — 자격 증명 세트 저장. 규칙·에러는 `upsertTossPayments`와 같다
|
|
1820
|
+
* (가맹점 식별자는 `storeId`, 비밀 필드는 `apiSecret`·`webhookSecret`). 400 `INVALID_CREDENTIALS`에는 채널 키 중복이 포함된다.
|
|
1821
|
+
*/
|
|
1822
|
+
upsertPortOne: (body) => storeHttp.request("PUT", "/store/payment-providers/portone", paymentProviderSchema, { body: upsertPortOneProviderRequestSchema.parse(body) }),
|
|
1823
|
+
/**
|
|
1824
|
+
* `PATCH /v1/store/payment-providers/{provider}` — 사용 여부(`enabled`)·샌드박스 스위치(`sandboxMode`) 변경.
|
|
1825
|
+
* 하나 이상 보내야 한다(아니면 요청 전에 ZodError). 검증을 모두 통과해야 한 번에 적용된다(부분 적용 없음).
|
|
1826
|
+
* 끈 PG로 승인된 결제의 환불은 계속 그 PG로 간다.
|
|
1827
|
+
*
|
|
1828
|
+
* 에러
|
|
1829
|
+
* - 404 `PAYMENT_PROVIDER_NOT_FOUND`: 연결되지 않은 PG
|
|
1830
|
+
* - 409 `PAYMENT_PROVIDER_INCOMPLETE`: 켜려는데 활성 환경(샌드박스면 TEST, 아니면 LIVE) 세트가 없거나 불완전
|
|
1831
|
+
* - 409 `LIVE_GATE_NOT_MET`: `sandboxMode: false`(라이브 전환)인데 개방 조건 미충족. `ApiError.details`가
|
|
1832
|
+
* `{ conditions: LiveGateCondition[] }`(조건별 `met`)이다. `liveGateErrorDetailsSchema`로 파싱한다
|
|
1833
|
+
* - 409 `LIVE_PAYMENTS_DISABLED`: 플랫폼 실결제 스위치만 미충족. `details`는 위와 같다
|
|
1834
|
+
*
|
|
1835
|
+
* `sandboxMode: true`는 항상 허용되고 즉시 실결제가 멈춘다.
|
|
1836
|
+
*/
|
|
1837
|
+
updateProvider: updatePaymentProvider,
|
|
1838
|
+
/**
|
|
1839
|
+
* 샌드박스 스위치만 바꾼다 — `updateProvider(provider, { sandboxMode })`와 같다.
|
|
1840
|
+
* `false`(라이브 전환)는 409 `LIVE_GATE_NOT_MET`·`LIVE_PAYMENTS_DISABLED`(둘 다 `ApiError.details.conditions`)가 날 수 있다.
|
|
1841
|
+
*/
|
|
1842
|
+
setSandboxMode: (provider, sandboxMode) => updatePaymentProvider(provider, { sandboxMode }),
|
|
1843
|
+
/**
|
|
1844
|
+
* `DELETE /v1/store/payment-providers/{provider}` (204) — 연결 해제. 결제 이력과 무관하게 허용된다.
|
|
1845
|
+
* 모든 세트는 보관되고 PG 설정에서 사라진다. 이미 받은 결제의 취소·환불·대사는 보관된 자격 증명으로 계속 처리된다.
|
|
1846
|
+
* 404 `PAYMENT_PROVIDER_NOT_FOUND`.
|
|
1847
|
+
*/
|
|
1848
|
+
disconnect: (provider) => storeHttp.requestVoid("DELETE", path`/store/payment-providers/${pgProviderSchema.parse(provider)}`),
|
|
1849
|
+
/**
|
|
1850
|
+
* `DELETE /v1/store/payment-providers/{provider}/credentials/{TEST|LIVE}` (200) — 활성 환경이 아닌 세트 하나를 보관 처리하고
|
|
1851
|
+
* 갱신된 `PaymentProvider`를 돌려준다. 보관된 세트는 그 세트로 받은 결제의 환불에 계속 쓰인다.
|
|
1852
|
+
*
|
|
1853
|
+
* 에러: 404 `PAYMENT_PROVIDER_NOT_FOUND`(미연결 PG) · 404 `PAYMENT_CREDENTIAL_NOT_FOUND`(등록되지 않은 세트) ·
|
|
1854
|
+
* 409 `PAYMENT_CREDENTIAL_ACTIVE`(지금 결제에 쓰는 세트. 먼저 `setSandboxMode`로 스위치를 바꾼다)
|
|
1855
|
+
*/
|
|
1856
|
+
removeCredentialSet: (provider, environment) => storeHttp.request("DELETE", path`/store/payment-providers/${pgProviderSchema.parse(provider)}/credentials/${pgEnvironmentSchema.parse(environment)}`, paymentProviderSchema),
|
|
1857
|
+
/**
|
|
1858
|
+
* `POST /v1/store/payment-providers/{provider}/verify` — 저장된 세트로 연결 테스트하고 결과를 그 세트에 저장한다.
|
|
1859
|
+
* `environment`를 생략하면 활성 환경 세트를 테스트한다. 실패해도 200이고 `result: "FAILED"`와 `message`에 사유가 온다.
|
|
1860
|
+
* 라이브 개방 조건 `TEST_CONNECTION`·`LIVE_CONNECTION`은 이 결과로 판정한다. 404 `PAYMENT_PROVIDER_NOT_FOUND`.
|
|
1861
|
+
*/
|
|
1862
|
+
verify: (provider, body) => storeHttp.request("POST", path`/store/payment-providers/${pgProviderSchema.parse(provider)}/verify`, paymentProviderVerificationSchema, { body: verifyPaymentProviderRequestSchema.parse(body ?? {}) })
|
|
1863
|
+
},
|
|
1864
|
+
webhooks: {
|
|
1865
|
+
list: () => storeHttp.request("GET", "/webhooks", z.array(webhookSchema)),
|
|
1866
|
+
/** 서명 시크릿 평문은 이 응답에서만 노출된다 */
|
|
1867
|
+
create: (body) => storeHttp.request("POST", "/webhooks", webhookCreatedSchema, { body: webhookRequestSchema.parse(body) }),
|
|
1868
|
+
get: (webhookId) => storeHttp.request("GET", path`/webhooks/${webhookId}`, webhookSchema),
|
|
1869
|
+
/** SUSPENDED 구독은 수정하면 ACTIVE로 돌아간다. 수정된 구독을 돌려준다 */
|
|
1870
|
+
update: (webhookId, body) => storeHttp.request("PUT", path`/webhooks/${webhookId}`, webhookSchema, { body: webhookRequestSchema.parse(body) }),
|
|
1871
|
+
remove: (webhookId) => storeHttp.requestVoid("DELETE", path`/webhooks/${webhookId}`),
|
|
1872
|
+
rotateSecret: (webhookId) => storeHttp.request("POST", path`/webhooks/${webhookId}/rotate-secret`, webhookSecretRotatedSchema),
|
|
1873
|
+
/** SUSPENDED 구독을 ACTIVE로 재개하고 연속 실패 기록을 초기화한다. 이미 ACTIVE면 그대로 돌려준다(멱등) */
|
|
1874
|
+
resume: (webhookId) => storeHttp.request("POST", path`/webhooks/${webhookId}/resume`, webhookSchema),
|
|
1875
|
+
/** 전송 기록 (최신 적재순, 기본 50건·최대 100건, 보관 30일) */
|
|
1876
|
+
listDeliveries: (webhookId, params) => storeHttp.request("GET", path`/webhooks/${webhookId}/deliveries`, z.array(webhookDeliverySchema), { query: params }),
|
|
1877
|
+
/**
|
|
1878
|
+
* FAILED 전송 기록 한 건을 같은 eventId로 다시 보낸다(202). 새로 적재된 기록을 돌려주며
|
|
1879
|
+
* `redeliveryOf`가 원본 deliveryId다. 원본이 FAILED가 아니면 409 `DELIVERY_NOT_FAILED`,
|
|
1880
|
+
* 원본이 이미 재전송됐으면 409 `DELIVERY_ALREADY_REDELIVERED`(원본당 1회, 원본의 `redeliveredAs` 참고),
|
|
1881
|
+
* 구독이 SUSPENDED면 409 `WEBHOOK_SUSPENDED`.
|
|
1882
|
+
*/
|
|
1883
|
+
redeliver: (webhookId, deliveryId) => storeHttp.request("POST", path`/webhooks/${webhookId}/deliveries/${deliveryId}/redeliver`, webhookDeliverySchema),
|
|
1884
|
+
/**
|
|
1885
|
+
* 최근 72시간의 FAILED 중 아직 재전송되지 않은 건 전체(최대 500건)를 다시 보낸다(202).
|
|
1886
|
+
* 구독이 SUSPENDED면 409 `WEBHOOK_SUSPENDED` — 먼저 `resume`한다.
|
|
1887
|
+
*/
|
|
1888
|
+
redeliverFailed: (webhookId) => storeHttp.request("POST", path`/webhooks/${webhookId}/deliveries/redeliver-failed`, webhookRedeliverFailedResultSchema),
|
|
1889
|
+
/**
|
|
1890
|
+
* 지정한 이벤트 타입의 샘플 페이로드(이벤트 id `evt_test_…`)를 실제로 보내고 수신 서버 응답을
|
|
1891
|
+
* 동기로 돌려준다. 재시도·정지 판정·전송 기록이 없다.
|
|
1892
|
+
*/
|
|
1893
|
+
sendTest: (webhookId, body) => storeHttp.request("POST", path`/webhooks/${webhookId}/test`, webhookTestResultSchema, { body: webhookTestRequestSchema.parse(body) })
|
|
1894
|
+
},
|
|
1895
|
+
/** API 로그 (보관 7일, 콘솔 OWNER/ADMIN) */
|
|
1896
|
+
apiLogs: {
|
|
1897
|
+
/** 최신순 커서 페이지네이션. 다음 페이지는 응답의 `nextCursor`를 `cursor`로 넘긴다 */
|
|
1898
|
+
list: (params) => storeHttp.request("GET", "/api-logs", apiLogPageSchema, { query: params && {
|
|
1899
|
+
...params,
|
|
1900
|
+
principalType: Array.isArray(params.principalType) ? params.principalType.join(",") || void 0 : params.principalType
|
|
1901
|
+
} }),
|
|
1902
|
+
/** 최근 24시간 요청 수·에러 수 */
|
|
1903
|
+
summary: () => storeHttp.request("GET", "/api-logs/summary", apiLogSummarySchema)
|
|
1904
|
+
},
|
|
1905
|
+
reviews: {
|
|
1906
|
+
list: (params) => storeHttp.request("GET", "/reviews", pageSchema(reviewSchema), { query: params }),
|
|
1907
|
+
get: (reviewId) => storeHttp.request("GET", path`/reviews/${reviewId}`, reviewSchema),
|
|
1908
|
+
createReply: (reviewId, content) => storeHttp.requestVoid("POST", path`/reviews/${reviewId}/reply`, { body: { content } }),
|
|
1909
|
+
updateReply: (reviewId, content) => storeHttp.requestVoid("PUT", path`/reviews/${reviewId}/reply`, { body: { content } }),
|
|
1910
|
+
deleteReply: (reviewId) => storeHttp.requestVoid("DELETE", path`/reviews/${reviewId}/reply`)
|
|
1911
|
+
},
|
|
1912
|
+
sellerAddresses: {
|
|
1913
|
+
list: (type) => storeHttp.request("GET", "/seller-addresses", z.array(sellerAddressSchema), { query: { type } }),
|
|
1914
|
+
create: (body) => storeHttp.requestVoid("POST", "/seller-addresses", { body: sellerAddressRequestSchema.parse(body) }),
|
|
1915
|
+
update: (addressId, body) => storeHttp.requestVoid("PUT", path`/seller-addresses/${addressId}`, { body: sellerAddressRequestSchema.parse(body) }),
|
|
1916
|
+
remove: (addressId) => storeHttp.requestVoid("DELETE", path`/seller-addresses/${addressId}`),
|
|
1917
|
+
setDefault: (addressId) => storeHttp.requestVoid("POST", path`/seller-addresses/${addressId}/default`)
|
|
1918
|
+
},
|
|
1919
|
+
store: {
|
|
1920
|
+
get: () => storeHttp.request("GET", "/store", storeSchema),
|
|
1921
|
+
listMembers: () => storeHttp.request("GET", "/store/members", z.array(storeMemberSchema)),
|
|
1922
|
+
listInvitations: () => storeHttp.request("GET", "/store/invitations", z.array(invitationSchema))
|
|
1923
|
+
}
|
|
1924
|
+
};
|
|
1925
|
+
}
|
|
1926
|
+
//#endregion
|
|
1927
|
+
//#region src/oauth.ts
|
|
1928
|
+
/**
|
|
1929
|
+
* OAuth 2.1 브라우저 플로우 헬퍼 (authorization_code + PKCE).
|
|
1930
|
+
* Web Crypto만 사용 — 브라우저/Node 모두에서 동작하며 **시크릿을 다루지 않는다**.
|
|
1931
|
+
*/
|
|
1932
|
+
/** RFC 6749 §5.1 토큰 응답 (+ RFC 8693 확장 멤버 허용) */
|
|
1933
|
+
const oauthTokenResponseSchema = z.object({
|
|
1934
|
+
access_token: z.string(),
|
|
1935
|
+
token_type: z.literal("Bearer"),
|
|
1936
|
+
expires_in: z.number().int(),
|
|
1937
|
+
scope: z.string().optional(),
|
|
1938
|
+
refresh_token: z.string().optional()
|
|
1939
|
+
}).loose();
|
|
1940
|
+
/** PKCE 페어 생성 — verifier(43~128자) + S256 challenge */
|
|
1941
|
+
async function createPkcePair() {
|
|
1942
|
+
const random = /* @__PURE__ */ new Uint8Array(48);
|
|
1943
|
+
crypto.getRandomValues(random);
|
|
1944
|
+
const verifier = base64UrlFromBytes(random);
|
|
1945
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
|
|
1946
|
+
return {
|
|
1947
|
+
verifier,
|
|
1948
|
+
challenge: base64UrlFromBytes(new Uint8Array(digest))
|
|
1949
|
+
};
|
|
1950
|
+
}
|
|
1951
|
+
/** authorize 엔드포인트 URL 구성 — 브라우저는 이 URL로 이동(top-level navigation) */
|
|
1952
|
+
function buildAuthorizeUrl(options) {
|
|
1953
|
+
const url = new URL(`${options.baseUrl}/oauth2/authorize`);
|
|
1954
|
+
url.searchParams.set("response_type", "code");
|
|
1955
|
+
url.searchParams.set("client_id", options.clientId);
|
|
1956
|
+
url.searchParams.set("redirect_uri", options.redirectUri);
|
|
1957
|
+
url.searchParams.set("state", options.state);
|
|
1958
|
+
url.searchParams.set("code_challenge", options.codeChallenge);
|
|
1959
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
1960
|
+
if (options.scope) url.searchParams.set("scope", options.scope);
|
|
1961
|
+
return url.toString();
|
|
1962
|
+
}
|
|
1963
|
+
async function tokenRequest(baseUrl, form, fetchFn = globalThis.fetch) {
|
|
1964
|
+
const response = await fetchFn(`${baseUrl}/oauth2/token`, {
|
|
1965
|
+
method: "POST",
|
|
1966
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
1967
|
+
body: new URLSearchParams(form).toString()
|
|
1968
|
+
});
|
|
1969
|
+
const json = await response.json().catch(() => null);
|
|
1970
|
+
if (!response.ok) {
|
|
1971
|
+
const body = json;
|
|
1972
|
+
throw new Error(body?.error_description ?? body?.error ?? `HTTP ${response.status}`);
|
|
1973
|
+
}
|
|
1974
|
+
return oauthTokenResponseSchema.parse(unwrapData(json));
|
|
1975
|
+
}
|
|
1976
|
+
/** 콜백에서 받은 code를 userToken으로 교환 (PKCE verifier 동반) */
|
|
1977
|
+
async function exchangeAuthorizationCode(options) {
|
|
1978
|
+
return tokenRequest(options.baseUrl, {
|
|
1979
|
+
grant_type: "authorization_code",
|
|
1980
|
+
code: options.code,
|
|
1981
|
+
redirect_uri: options.redirectUri,
|
|
1982
|
+
client_id: options.clientId,
|
|
1983
|
+
code_verifier: options.codeVerifier
|
|
1984
|
+
}, options.fetch);
|
|
1985
|
+
}
|
|
1986
|
+
/** refresh grant (rotation — 응답의 새 refresh_token으로 반드시 교체 저장할 것) */
|
|
1987
|
+
async function refreshUserToken(options) {
|
|
1988
|
+
return tokenRequest(options.baseUrl, {
|
|
1989
|
+
grant_type: "refresh_token",
|
|
1990
|
+
refresh_token: options.refreshToken,
|
|
1991
|
+
client_id: options.clientId
|
|
1992
|
+
}, options.fetch);
|
|
1993
|
+
}
|
|
1994
|
+
/** userToken → storeToken 교환 (RFC 8693 token-exchange grant) */
|
|
1995
|
+
async function exchangeStoreTokenViaOAuth(options) {
|
|
1996
|
+
return tokenRequest(options.baseUrl, {
|
|
1997
|
+
grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
|
|
1998
|
+
subject_token: options.userToken,
|
|
1999
|
+
subject_token_type: "urn:ietf:params:oauth:token-type:access_token",
|
|
2000
|
+
audience: options.storeId,
|
|
2001
|
+
client_id: options.clientId
|
|
2002
|
+
}, options.fetch);
|
|
2003
|
+
}
|
|
2004
|
+
//#endregion
|
|
2005
|
+
//#region src/schemas/scope.ts
|
|
2006
|
+
/**
|
|
2007
|
+
* 스토어 스코프 카탈로그 (auth-spec §7) — 형식 `{리소스}:{r|rw}`, rw는 r을 함의한다.
|
|
2008
|
+
*
|
|
2009
|
+
* 관리 API가 요구하는 스코프와 역할별 자동 부여 스코프의 **단일 원천**이다.
|
|
2010
|
+
* 서버(apps/api)가 이 상수를 그대로 가져다 역할→스코프를 산출하므로, 콘솔 권한 매트릭스와
|
|
2011
|
+
* 서버 인가가 갈라지지 않는다.
|
|
2012
|
+
*/
|
|
2013
|
+
const scopeAccessSchema = z.enum(["r", "rw"]).describe("접근 수준. `r`=읽기, `rw`=읽기+쓰기(r을 함의)");
|
|
2014
|
+
/** 리소스별로 부여 가능한 접근 수준. settlement는 쓰기 API가 없어 `r`만 존재한다 */
|
|
2015
|
+
const STORE_SCOPE_RESOURCES = {
|
|
2016
|
+
product: ["r", "rw"],
|
|
2017
|
+
category: ["r", "rw"],
|
|
2018
|
+
order: ["r", "rw"],
|
|
2019
|
+
claim: ["r", "rw"],
|
|
2020
|
+
inquiry: ["r", "rw"],
|
|
2021
|
+
review: ["r", "rw"],
|
|
2022
|
+
settlement: ["r"],
|
|
2023
|
+
webhook: ["r", "rw"],
|
|
2024
|
+
store: ["r", "rw"],
|
|
2025
|
+
member: ["r", "rw"]
|
|
2026
|
+
};
|
|
2027
|
+
const storeScopeResourceSchema = z.enum(Object.keys(STORE_SCOPE_RESOURCES)).describe("스코프 리소스");
|
|
2028
|
+
/** 카탈로그상 존재하는 스코프 문자열인가 (예: `settlement:rw`는 false) */
|
|
2029
|
+
function isStoreScope(scope) {
|
|
2030
|
+
const [resource, access, ...rest] = scope.split(":");
|
|
2031
|
+
if (rest.length > 0 || !resource || !access) return false;
|
|
2032
|
+
return STORE_SCOPE_RESOURCES[resource]?.includes(access) ?? false;
|
|
2033
|
+
}
|
|
2034
|
+
/**
|
|
2035
|
+
* 역할별 자동 부여 스코프 (auth-spec §7).
|
|
2036
|
+
* storeToken(사람)의 스코프는 이 표로 결정된다. PAT·M2M 클라이언트에는 역할이 없고
|
|
2037
|
+
* 발급 시 지정한 스코프만 갖는다(발급자 보유 스코프 이내).
|
|
2038
|
+
*/
|
|
2039
|
+
const STORE_ROLE_SCOPES = {
|
|
2040
|
+
OWNER: [
|
|
2041
|
+
"product:rw",
|
|
2042
|
+
"category:rw",
|
|
2043
|
+
"order:rw",
|
|
2044
|
+
"claim:rw",
|
|
2045
|
+
"inquiry:rw",
|
|
2046
|
+
"review:rw",
|
|
2047
|
+
"settlement:r",
|
|
2048
|
+
"webhook:rw",
|
|
2049
|
+
"store:rw",
|
|
2050
|
+
"member:rw"
|
|
2051
|
+
],
|
|
2052
|
+
ADMIN: [
|
|
2053
|
+
"product:rw",
|
|
2054
|
+
"category:rw",
|
|
2055
|
+
"order:rw",
|
|
2056
|
+
"claim:rw",
|
|
2057
|
+
"inquiry:rw",
|
|
2058
|
+
"review:rw",
|
|
2059
|
+
"webhook:rw",
|
|
2060
|
+
"store:rw",
|
|
2061
|
+
"member:rw"
|
|
2062
|
+
],
|
|
2063
|
+
STAFF: [
|
|
2064
|
+
"product:rw",
|
|
2065
|
+
"category:rw",
|
|
2066
|
+
"order:rw",
|
|
2067
|
+
"claim:rw",
|
|
2068
|
+
"inquiry:rw",
|
|
2069
|
+
"review:rw"
|
|
2070
|
+
]
|
|
2071
|
+
};
|
|
2072
|
+
//#endregion
|
|
2073
|
+
//#region src/schemas/webhook-event.ts
|
|
2074
|
+
const envelopeFields = {
|
|
2075
|
+
id: z.string().describe("이벤트 ID (`evt_…`, 테스트 전송은 `evt_test_…`). 중복 제거 키"),
|
|
2076
|
+
createdAt: z.string().describe("이벤트 발생 일시 (ISO 8601)"),
|
|
2077
|
+
storeId: z.string().describe("이벤트가 발생한 스토어 ID")
|
|
2078
|
+
};
|
|
2079
|
+
const orderWebhookEventDataSchema = z.object({
|
|
2080
|
+
orderId: z.string().describe("주문 ID"),
|
|
2081
|
+
status: orderItemStatusSchema.describe("이벤트 발생 시점의 주문 대표 상태"),
|
|
2082
|
+
testPayment: z.boolean().optional().default(false).describe("테스트 결제(샌드박스 결제) 주문인가. true면 실제로 돈이 오가지 않았다. 이 필드가 없는 이전 이벤트는 false로 읽는다")
|
|
2083
|
+
});
|
|
2084
|
+
const claimWebhookEventDataSchema = z.object({
|
|
2085
|
+
claimId: z.string().describe("클레임 ID"),
|
|
2086
|
+
orderId: z.string().describe("주문 ID"),
|
|
2087
|
+
orderItemId: z.string().describe("클레임 대상 주문 상품 ID"),
|
|
2088
|
+
claimType: claimTypeSchema.describe("클레임 유형"),
|
|
2089
|
+
status: claimStatusSchema.describe("이벤트 발생 시점의 클레임 상태"),
|
|
2090
|
+
testPayment: z.boolean().optional().default(false).describe("테스트 결제(샌드박스 결제) 주문의 클레임인가. 이 필드가 없는 이전 이벤트는 false로 읽는다")
|
|
2091
|
+
});
|
|
2092
|
+
const inquiryWebhookEventDataSchema = z.object({
|
|
2093
|
+
inquiryId: z.string().describe("상품 문의 ID"),
|
|
2094
|
+
productId: z.string().describe("상품 ID")
|
|
2095
|
+
});
|
|
2096
|
+
const reviewWebhookEventDataSchema = z.object({
|
|
2097
|
+
reviewId: z.string().describe("리뷰 ID"),
|
|
2098
|
+
productId: z.string().describe("상품 ID"),
|
|
2099
|
+
rating: z.number().int().min(1).max(5).describe("별점 (1~5)")
|
|
2100
|
+
});
|
|
2101
|
+
const productOutOfStockWebhookEventDataSchema = z.object({
|
|
2102
|
+
productId: z.string().describe("상품 ID"),
|
|
2103
|
+
variantId: z.string().describe("재고가 0이 된 판매 단위(variant) ID. 주문 상품의 `optionId`와 같은 값 공간이다")
|
|
2104
|
+
});
|
|
2105
|
+
const orderWebhookEventSchema = z.object({
|
|
2106
|
+
...envelopeFields,
|
|
2107
|
+
type: z.enum([
|
|
2108
|
+
"ORDER.PAID",
|
|
2109
|
+
"ORDER.CONFIRMED",
|
|
2110
|
+
"ORDER.DISPATCHED",
|
|
2111
|
+
"ORDER.DELIVERED",
|
|
2112
|
+
"ORDER.PURCHASE_DECIDED",
|
|
2113
|
+
"ORDER.ADDRESS_CHANGE_REQUESTED"
|
|
2114
|
+
]),
|
|
2115
|
+
data: orderWebhookEventDataSchema
|
|
2116
|
+
});
|
|
2117
|
+
const claimWebhookEventSchema = z.object({
|
|
2118
|
+
...envelopeFields,
|
|
2119
|
+
type: z.enum([
|
|
2120
|
+
"CLAIM.CANCEL_REQUESTED",
|
|
2121
|
+
"CLAIM.RETURN_REQUESTED",
|
|
2122
|
+
"CLAIM.EXCHANGE_REQUESTED",
|
|
2123
|
+
"CLAIM.COMPLETED"
|
|
2124
|
+
]),
|
|
2125
|
+
data: claimWebhookEventDataSchema
|
|
2126
|
+
});
|
|
2127
|
+
const inquiryWebhookEventSchema = z.object({
|
|
2128
|
+
...envelopeFields,
|
|
2129
|
+
type: z.literal("INQUIRY.CREATED"),
|
|
2130
|
+
data: inquiryWebhookEventDataSchema
|
|
2131
|
+
});
|
|
2132
|
+
const reviewWebhookEventSchema = z.object({
|
|
2133
|
+
...envelopeFields,
|
|
2134
|
+
type: z.literal("REVIEW.CREATED"),
|
|
2135
|
+
data: reviewWebhookEventDataSchema
|
|
2136
|
+
});
|
|
2137
|
+
const productOutOfStockWebhookEventSchema = z.object({
|
|
2138
|
+
...envelopeFields,
|
|
2139
|
+
type: z.literal("PRODUCT.OUT_OF_STOCK"),
|
|
2140
|
+
data: productOutOfStockWebhookEventDataSchema
|
|
2141
|
+
});
|
|
2142
|
+
/** 웹훅 요청 본문. `type`으로 판별하며 `data` 형태가 타입별로 다르다 */
|
|
2143
|
+
const webhookEventSchema = z.discriminatedUnion("type", [
|
|
2144
|
+
orderWebhookEventSchema,
|
|
2145
|
+
claimWebhookEventSchema,
|
|
2146
|
+
inquiryWebhookEventSchema,
|
|
2147
|
+
reviewWebhookEventSchema,
|
|
2148
|
+
productOutOfStockWebhookEventSchema
|
|
2149
|
+
]).describe("웹훅 요청 본문 (얇은 이벤트). 개인정보를 싣지 않으며 `data`의 id로 관리 API를 조회한다");
|
|
2150
|
+
//#endregion
|
|
2151
|
+
//#region src/session.ts
|
|
2152
|
+
/**
|
|
2153
|
+
* 세션 키 상수 — SDK가 소유한다. 값은 apps/store의 기존 쿠키 이름(SESSION_COOKIE/REFRESH_COOKIE)과
|
|
2154
|
+
* 동일하게 맞춰, 앱 어댑터·SSR 라우트 가드가 같은 원천을 읽도록 패리티를 보장한다.
|
|
2155
|
+
*/
|
|
2156
|
+
const STORE_SESSION_KEYS = {
|
|
2157
|
+
session: "sayren.store.session",
|
|
2158
|
+
refresh: "sayren.store.refresh",
|
|
2159
|
+
/** authorize() → handleCallback() 간 1회용 PKCE 블롭({ verifier, state, redirectUri }) 저장 키 */
|
|
2160
|
+
pkce: "sayren.store.pkce"
|
|
2161
|
+
};
|
|
2162
|
+
/** Web Locks 이름 — 탭 간 refresh 회전 직렬화용 */
|
|
2163
|
+
const REFRESH_LOCK = STORE_SESSION_KEYS.refresh;
|
|
2164
|
+
function createStoreSession(options) {
|
|
2165
|
+
const { baseUrl, clientId, storage } = options;
|
|
2166
|
+
const fetchFn = options.fetch ?? globalThis.fetch;
|
|
2167
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
2168
|
+
function notify() {
|
|
2169
|
+
for (const listener of listeners) listener();
|
|
2170
|
+
}
|
|
2171
|
+
function getToken() {
|
|
2172
|
+
return storage.get(STORE_SESSION_KEYS.session);
|
|
2173
|
+
}
|
|
2174
|
+
function setTokens(tokens) {
|
|
2175
|
+
storage.set(STORE_SESSION_KEYS.session, tokens.access_token);
|
|
2176
|
+
if (tokens.refresh_token) storage.set(STORE_SESSION_KEYS.refresh, tokens.refresh_token);
|
|
2177
|
+
notify();
|
|
2178
|
+
}
|
|
2179
|
+
function clear() {
|
|
2180
|
+
storage.remove(STORE_SESSION_KEYS.session);
|
|
2181
|
+
storage.remove(STORE_SESSION_KEYS.refresh);
|
|
2182
|
+
notify();
|
|
2183
|
+
}
|
|
2184
|
+
let refreshInFlight = null;
|
|
2185
|
+
function refresh() {
|
|
2186
|
+
if (!refreshInFlight) refreshInFlight = doRefresh().finally(() => {
|
|
2187
|
+
refreshInFlight = null;
|
|
2188
|
+
});
|
|
2189
|
+
return refreshInFlight;
|
|
2190
|
+
}
|
|
2191
|
+
async function doRefresh() {
|
|
2192
|
+
const before = getToken();
|
|
2193
|
+
const locks = typeof navigator !== "undefined" ? navigator.locks : void 0;
|
|
2194
|
+
return locks ? locks.request(REFRESH_LOCK, () => rotate(before)) : rotate(before);
|
|
2195
|
+
}
|
|
2196
|
+
async function rotate(before) {
|
|
2197
|
+
if (getToken() !== before) return true;
|
|
2198
|
+
const refreshToken = storage.get(STORE_SESSION_KEYS.refresh);
|
|
2199
|
+
if (!refreshToken) return false;
|
|
2200
|
+
try {
|
|
2201
|
+
const tokens = await refreshUserToken({
|
|
2202
|
+
baseUrl,
|
|
2203
|
+
clientId,
|
|
2204
|
+
refreshToken,
|
|
2205
|
+
fetch: fetchFn
|
|
2206
|
+
});
|
|
2207
|
+
setTokens({
|
|
2208
|
+
access_token: tokens.access_token,
|
|
2209
|
+
refresh_token: tokens.refresh_token
|
|
2210
|
+
});
|
|
2211
|
+
return true;
|
|
2212
|
+
} catch {
|
|
2213
|
+
clear();
|
|
2214
|
+
return false;
|
|
2215
|
+
}
|
|
2216
|
+
}
|
|
2217
|
+
function subscribe(cb) {
|
|
2218
|
+
listeners.add(cb);
|
|
2219
|
+
return () => {
|
|
2220
|
+
listeners.delete(cb);
|
|
2221
|
+
};
|
|
2222
|
+
}
|
|
2223
|
+
/** crypto.randomUUID가 있으면 그걸, 없으면 무작위 바이트를 base64url로 인코딩해 state를 만든다 */
|
|
2224
|
+
function randomState() {
|
|
2225
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID();
|
|
2226
|
+
const bytes = /* @__PURE__ */ new Uint8Array(16);
|
|
2227
|
+
crypto.getRandomValues(bytes);
|
|
2228
|
+
return base64UrlFromBytes(bytes);
|
|
2229
|
+
}
|
|
2230
|
+
async function authorize(authorizeOptions) {
|
|
2231
|
+
const { verifier, challenge } = await createPkcePair();
|
|
2232
|
+
const state = randomState();
|
|
2233
|
+
const blob = {
|
|
2234
|
+
verifier,
|
|
2235
|
+
state,
|
|
2236
|
+
redirectUri: authorizeOptions.redirectUri
|
|
2237
|
+
};
|
|
2238
|
+
storage.set(STORE_SESSION_KEYS.pkce, JSON.stringify(blob));
|
|
2239
|
+
const url = new URL(buildAuthorizeUrl({
|
|
2240
|
+
baseUrl,
|
|
2241
|
+
clientId,
|
|
2242
|
+
redirectUri: authorizeOptions.redirectUri,
|
|
2243
|
+
state,
|
|
2244
|
+
codeChallenge: challenge,
|
|
2245
|
+
scope: authorizeOptions.scope
|
|
2246
|
+
}));
|
|
2247
|
+
if (authorizeOptions.screen === "signup") url.pathname = url.pathname.replace(/\/oauth2\/authorize$/, "/oauth2/signup");
|
|
2248
|
+
const finalUrl = url.toString();
|
|
2249
|
+
if (typeof window !== "undefined") {
|
|
2250
|
+
window.location.assign(finalUrl);
|
|
2251
|
+
return;
|
|
2252
|
+
}
|
|
2253
|
+
return finalUrl;
|
|
2254
|
+
}
|
|
2255
|
+
async function handleCallback(code, state) {
|
|
2256
|
+
const raw = storage.get(STORE_SESSION_KEYS.pkce);
|
|
2257
|
+
storage.remove(STORE_SESSION_KEYS.pkce);
|
|
2258
|
+
if (!raw) throw new Error("로그인 세션이 만료되었습니다. 다시 시도해주세요");
|
|
2259
|
+
const saved = JSON.parse(raw);
|
|
2260
|
+
if (saved.state !== state) throw new Error("state가 일치하지 않습니다. 다시 시도해주세요");
|
|
2261
|
+
const tokens = await exchangeAuthorizationCode({
|
|
2262
|
+
baseUrl,
|
|
2263
|
+
clientId,
|
|
2264
|
+
redirectUri: saved.redirectUri,
|
|
2265
|
+
code,
|
|
2266
|
+
codeVerifier: saved.verifier,
|
|
2267
|
+
fetch: fetchFn
|
|
2268
|
+
});
|
|
2269
|
+
setTokens({
|
|
2270
|
+
access_token: tokens.access_token,
|
|
2271
|
+
refresh_token: tokens.refresh_token
|
|
2272
|
+
});
|
|
2273
|
+
}
|
|
2274
|
+
async function userinfo() {
|
|
2275
|
+
const token = getToken();
|
|
2276
|
+
if (!token) return null;
|
|
2277
|
+
const response = await fetchFn(`${baseUrl}/me`, { headers: { authorization: `Bearer ${token}` } });
|
|
2278
|
+
const json = await response.json().catch(() => null);
|
|
2279
|
+
if (!response.ok) throw toApiError(response.status, json);
|
|
2280
|
+
return myAccountSchema.parse(unwrapData(json));
|
|
2281
|
+
}
|
|
2282
|
+
return {
|
|
2283
|
+
getToken,
|
|
2284
|
+
setTokens,
|
|
2285
|
+
refresh,
|
|
2286
|
+
clear,
|
|
2287
|
+
subscribe,
|
|
2288
|
+
authorize,
|
|
2289
|
+
handleCallback,
|
|
2290
|
+
userinfo,
|
|
2291
|
+
get accessToken() {
|
|
2292
|
+
return getToken();
|
|
2293
|
+
},
|
|
2294
|
+
get refreshToken() {
|
|
2295
|
+
return storage.get(STORE_SESSION_KEYS.refresh);
|
|
2296
|
+
}
|
|
2297
|
+
};
|
|
2298
|
+
}
|
|
2299
|
+
//#endregion
|
|
2300
|
+
export { ApiError, MAX_OPTION_GROUPS, MAX_VALUES_PER_GROUP, MAX_VARIANTS, PAYMENT_ROUTING_EXCLUSION_CODES, PORTONE_CHANNEL_NAME_MAX_LENGTH, PORTONE_MAX_CHANNELS, PORTONE_MIN_CHANNELS, RESERVED_STORE_CODES, STORE_CODE_PATTERN, STORE_ROLE_SCOPES, STORE_SCOPE_RESOURCES, STORE_SESSION_KEYS, TOSS_WIDGET_CLIENT_KEY_PATTERN, TOSS_WIDGET_KEY_MESSAGE, TOSS_WIDGET_SECRET_KEY_PATTERN, VARIANT_STOCK_EXCLUSIVE_MESSAGE, WEBHOOK_DEFAULT_TOLERANCE_SEC, WEBHOOK_DELIVERY_ID_HEADER, WEBHOOK_EVENT_ID_HEADER, WEBHOOK_EVENT_TYPE_HEADER, WEBHOOK_HEADERS, WEBHOOK_SIGNATURE_HEADER, WEBHOOK_USER_AGENT, addressTypeSchema, apiClientListQuerySchema, apiClientSchema, apiClientSecretRotatedSchema, apiClientStatusSchema, apiClientTypeSchema, apiLogErrorSchema, apiLogListQuerySchema, apiLogPageSchema, apiLogPrincipalSchema, apiLogPrincipalTypeSchema, apiLogSchema, apiLogStatusFilterSchema, apiLogSummarySchema, apiTokenCreateRequestSchema, apiTokenCreatedSchema, apiTokenSchema, approveClaimResultSchema, buildAuthorizeUrl, buildQuery, bulkResultSchema, cancelBySellerRequestSchema, carrierSchema, categoryAttributeSchema, categoryCreateRequestSchema, categoryDeleteStrategySchema, categoryPatchRequestSchema, categoryProductSortSchema, categorySchema, categorySearchResultSchema, categoryTreeNodeSchema, categoryWithPathSchema, changeTypeSchema, claimReasonSchema, claimSchema, claimStatusSchema, claimTypeSchema, claimWebhookEventDataSchema, claimWebhookEventSchema, clientCredentialsTokenResponseSchema, createPkcePair, createStoreClient, createStoreSession, customerInquiryCategorySchema, deliveryInfoSchema, deliveryMethodSchema, deliveryTypeSchema, discountRequestSchema, discountSchema, dispatchRequestSchema, effectivePaymentRoutingModeSchema, eventTypeSchema, exchangeAuthorizationCode, exchangeStoreTokenViaOAuth, holdOrderItemRequestSchema, inquirySchema, inquiryWebhookEventDataSchema, inquiryWebhookEventSchema, invitationSchema, isReservedStoreCode, isStoreScope, liveGateConditionCodeSchema, liveGateConditionSchema, liveGateErrorDetailsSchema, liveGateSchema, maskedSecretSchema, myAccountSchema, myStoreSchema, oauthClientRegistrationRequestSchema, oauthClientRegistrationResponseSchema, oauthTokenResponseSchema, optionGroupRequestSchema, optionGroupSchema, optionModelRequestSchema, optionModelSchema, optionValueRequestSchema, optionValueSchema, orderItemListQuerySchema, orderItemPageSchema, orderItemSchema, orderItemStatusSchema, orderListQuerySchema, orderSchema, orderSummaryPageSchema, orderSummarySchema, orderWebhookEventDataSchema, orderWebhookEventSchema, pageInfoSchema, pageSchema, paymentAttemptRecordSchema, paymentAttemptStatusSchema, paymentCredentialSetSchema, paymentListQuerySchema, paymentProviderSchema, paymentProviderVerificationSchema, paymentRecordDetailSchema, paymentRecordPageSchema, paymentRecordSchema, paymentRecordStatusSchema, paymentRefundRecordSchema, paymentRoutingModeSchema, paymentSettingsSchema, pgEnvironmentSchema, pgPaymentMethodSchema, pgProviderSchema, pgVerificationResultSchema, portOneChannelSchema, portOneCredentialsInputSchema, productCreateRequestSchema, productOutOfStockWebhookEventDataSchema, productOutOfStockWebhookEventSchema, productPatchRequestSchema, productSchema, productStatusSchema, productSummarySchema, productVariantRequestSchema, productVariantSchema, refreshUserToken, rejectClaimRequestSchema, reviewSchema, reviewWebhookEventDataSchema, reviewWebhookEventSchema, salesReportGroupBySchema, salesReportQuerySchema, salesReportRowSchema, salesReportSchema, salesTotalsSchema, scopeAccessSchema, sellerAddressRequestSchema, sellerAddressSchema, sellerCustomerInquirySchema, settlementDetailSchema, settlementStatusSchema, settlementSummarySchema, shippingAddressSchema, storeCodeSchema, storeMemberSchema, storeRoleSchema, storeSchema, storeScopeResourceSchema, storeTokenResponseSchema, tossPaymentsCredentialsInputSchema, updatePaymentProviderRequestSchema, updatePaymentSettingsRequestSchema, uploadRequestSchema, uploadTicketSchema, upsertPortOneProviderRequestSchema, upsertTossPaymentsProviderRequestSchema, userTokenResponseSchema, variantStockRequestSchema, variantStockSchema, verifyPaymentProviderRequestSchema, webhookCreatedSchema, webhookDeliveryErrorCodeSchema, webhookDeliveryListQuerySchema, webhookDeliverySchema, webhookDeliveryStatusSchema, webhookEventSchema, webhookRedeliverFailedResultSchema, webhookRequestSchema, webhookSchema, webhookSecretRotatedSchema, webhookStatusSchema, webhookTestRequestSchema, webhookTestResultSchema };
|