@sayren/store-sdk 0.7.0 → 0.9.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sayren/store-sdk",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "main": "./dist/index.mjs",
5
5
  "types": "./dist/index.d.mts",
6
6
  "exports": {
package/src/client.ts CHANGED
@@ -1,6 +1,14 @@
1
1
  import { z } from "zod";
2
2
  import { base64Utf8 } from "./base64";
3
- import { createHttp, path, type Query, resolveToken, type TokenSource, unwrapData } from "./http";
3
+ import {
4
+ ApiError,
5
+ createHttp,
6
+ path,
7
+ type Query,
8
+ resolveToken,
9
+ type TokenSource,
10
+ unwrapData,
11
+ } from "./http";
4
12
  import {
5
13
  type AnalyticsPagesParams,
6
14
  type AnalyticsProductsParams,
@@ -173,7 +181,14 @@ import {
173
181
  type UpdateStoreRequest,
174
182
  updateStoreRequestSchema,
175
183
  } from "./schemas/store";
176
- import { type UploadRequest, uploadRequestSchema, uploadTicketSchema } from "./schemas/upload";
184
+ import {
185
+ type UploadPurpose,
186
+ type UploadRequest,
187
+ type UploadTicket,
188
+ uploadRejection,
189
+ uploadRequestSchema,
190
+ uploadTicketSchema,
191
+ } from "./schemas/upload";
177
192
  import type { AuthorizeOptions, StoreSession } from "./session";
178
193
 
179
194
  export interface StoreClientOptions {
@@ -372,6 +387,44 @@ export function createStoreClient(options: StoreClientOptions) {
372
387
  storeHttp.request("POST", "/uploads", uploadTicketSchema, {
373
388
  body: uploadRequestSchema.parse(body),
374
389
  }),
390
+ /**
391
+ * 자리 발급 + 바이트 PUT을 한 번에 — 끝나면 `url`이 공개 주소다.
392
+ *
393
+ * 용도 제한(확장자·크기)은 서버가 판정하지만, 같은 규칙으로 여기서 먼저 걸러
394
+ * 큰 파일을 CDN까지 보내고 나서 거절되는 왕복을 없앤다.
395
+ */
396
+ upload: async (
397
+ file: Blob,
398
+ options?: { purpose?: UploadPurpose; filename?: string },
399
+ ): Promise<UploadTicket> => {
400
+ const filename =
401
+ options?.filename ??
402
+ (typeof File !== "undefined" && file instanceof File ? file.name : "");
403
+ if (!filename) {
404
+ throw new ApiError(400, "UPLOAD_FILENAME_REQUIRED", "파일명이 필요합니다");
405
+ }
406
+ if (file.size === 0) {
407
+ // 빈 파일은 PUT 자체는 성공하고 0바이트 객체가 공개돼 깨진 이미지로 남는다
408
+ throw new ApiError(400, "EMPTY_FILE", `파일이 비어 있습니다: ${filename}`);
409
+ }
410
+ const purpose = options?.purpose ?? "description";
411
+ const rejection = uploadRejection(purpose, filename, file.size);
412
+ if (rejection) {
413
+ throw new ApiError(rejection.status, rejection.code, rejection.message);
414
+ }
415
+ const ticket = await storeHttp.request("POST", "/uploads", uploadTicketSchema, {
416
+ body: uploadRequestSchema.parse({ filename, purpose, size: file.size }),
417
+ });
418
+ const put = await fetchFn(ticket.uploadUrl, {
419
+ method: "PUT",
420
+ headers: { "content-type": file.type || "application/octet-stream" },
421
+ body: file,
422
+ });
423
+ if (!put.ok) {
424
+ throw new ApiError(put.status, "UPLOAD_PUT_FAILED", `업로드에 실패했습니다: ${filename}`);
425
+ }
426
+ return ticket;
427
+ },
375
428
  },
376
429
 
377
430
  products: {
@@ -532,6 +585,20 @@ export function createStoreClient(options: StoreClientOptions) {
532
585
  storeHttp.requestVoid("PUT", path`/order-items/${orderItemId}/dispatch/tracking`, {
533
586
  body: { carrierCode, trackingNumber },
534
587
  }),
588
+ /**
589
+ * 배송완료 처리 — `DISPATCHED`·`DELIVERING`만 넘어간다. 응답의 `autoDecisionDate`가 자동 구매확정 예정일이다
590
+ * (스토어 설정 `autoConfirmDays`가 0이면 null).
591
+ */
592
+ markDelivered: (orderItemId: string, idempotencyKey?: string) =>
593
+ storeHttp.request("POST", path`/order-items/${orderItemId}/delivered`, orderItemSchema, {
594
+ headers: idempotency(idempotencyKey),
595
+ }),
596
+ /** 배송완료 일괄 처리 — 항목별 부분 성공이다 */
597
+ markDeliveredBulk: (orderItemIds: string[], idempotencyKey?: string) =>
598
+ storeHttp.request("POST", "/order-items/delivered", bulkResultSchema, {
599
+ body: { orderItemIds },
600
+ headers: idempotency(idempotencyKey),
601
+ }),
535
602
  getDelivery: (orderItemId: string) =>
536
603
  storeHttp.request("GET", path`/order-items/${orderItemId}/delivery`, deliveryStatusSchema),
537
604
  },
@@ -67,6 +67,9 @@ export const changeTypeSchema = z.enum([
67
67
  "CLAIM.CANCEL_REQUESTED",
68
68
  "CLAIM.RETURN_REQUESTED",
69
69
  "CLAIM.EXCHANGE_REQUESTED",
70
+ // 취소·반품 승인으로 주문상품이 취소됐다 — 서버가 이 값을 쓰므로 목록에 있어야 한다
71
+ // (빠져 있던 동안 클레임이 끝난 주문상품이 섞이면 응답 검증이 깨졌다)
72
+ "CLAIM.COMPLETED",
70
73
  "ORDER.ADDRESS_CHANGE_REQUESTED",
71
74
  ]);
72
75
 
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { changeTypeSchema, orderItemStatusSchema, type PageParams, pageSchema } from "./common";
3
+ import { easyPayProviderResponseSchema } from "./payment";
3
4
 
4
5
  export const orderSummarySchema = z.object({
5
6
  orderId: z.string(),
@@ -84,6 +85,15 @@ export const orderSchema = z.object({
84
85
  "매출 리포트·스토어프론트·환불 원장과 같은 값이다. 값이 추가될 수 있어 문자열로 받는다. PG가 알려 주지 않았으면 요청 수단, " +
85
86
  "시도 기록이 없는 오래된 주문은 null. 서버는 항상 싣는다",
86
87
  ),
88
+ easyPayProvider: easyPayProviderResponseSchema
89
+ .nullable()
90
+ .optional()
91
+ .default(null)
92
+ .describe(
93
+ "승인된 간편결제사. `approvedMethod`가 `EASY_PAY`일 때만 값이 있고, PG가 알려 주지 않았으면 요청한 간편결제사다. " +
94
+ "그 밖의 결제수단과 간편결제 이전 주문은 null. 서버는 항상 싣는다. " +
95
+ "현재 값은 `NAVERPAY`·`KAKAOPAY`·`TOSSPAY`·`PAYCO`이고 값이 추가될 수 있어 문자열로 받는다",
96
+ ),
87
97
  productAmount: z.number().int(),
88
98
  deliveryFee: z.number().int(),
89
99
  discountAmount: z.number().int(),
@@ -19,20 +19,19 @@ export const pgPaymentMethodSchema = z
19
19
  .describe("결제수단 (스토어프론트 결제 요청의 `paymentMethod`와 같은 값)");
20
20
  export type PgPaymentMethod = z.infer<typeof pgPaymentMethodSchema>;
21
21
 
22
- export const paymentRoutingModeSchema = z
23
- .enum(["PRIORITY_DISPLAY", "AUTO_FAILOVER"])
24
- .describe(
25
- "PG가 둘 이상 사용 중일 때의 라우팅 방식. `PRIORITY_DISPLAY`=결제창에 사용 중인 PG를 모두 보여 주고 1순위를 기본 선택, " +
26
- "`AUTO_FAILOVER`=1순위로 결제창을 열고, 결제창을 띄우기 전 장애(PG 준비 실패)일 때만 다음 순위로 자동 전환",
27
- );
28
- export type PaymentRoutingMode = z.infer<typeof paymentRoutingModeSchema>;
22
+ /**
23
+ * 간편결제사 — 결제 옵션(스토어프론트 `paymentOptions`)과 PG 설정의 `easyPayProviders`에 쓴다.
24
+ * `@sayren/storefront-sdk`의 `easyPayProviderSchema`와 같은 값이다(api 계약 테스트가 고정한다)
25
+ */
26
+ export const easyPayProviderSchema = z.enum(["NAVERPAY", "KAKAOPAY", "TOSSPAY", "PAYCO"]);
27
+ export type EasyPayProvider = z.infer<typeof easyPayProviderSchema>;
29
28
 
30
- export const effectivePaymentRoutingModeSchema = z
31
- .enum(["NONE", "SINGLE", "PRIORITY_DISPLAY", "AUTO_FAILOVER"])
32
- .describe(
33
- "실제로 적용되는 라우팅. 사용 중인 PG가 0개면 `NONE`(결제 불가), 1개면 `SINGLE`, 2개 이상이면 `routingMode` 값",
34
- );
35
- export type EffectivePaymentRoutingMode = z.infer<typeof effectivePaymentRoutingModeSchema>;
29
+ /**
30
+ * 응답에 실리는 간편결제사 — 서버가 간편결제사를 추가해도 파싱이 깨지지 않게 문자열로 받는다
31
+ * (`pgProvider`·결제수단과 같은 방식). 요청(`easyPayProviders`)은 값을 골라 보내는 자리라 enum 그대로다.
32
+ * 현재 값은 `NAVERPAY`·`KAKAOPAY`·`TOSSPAY`·`PAYCO`이고 화면은 모르는 값을 원문 그대로 보여 준다.
33
+ */
34
+ export const easyPayProviderResponseSchema = z.string();
36
35
 
37
36
  export const pgEnvironmentSchema = z
38
37
  .enum(["TEST", "LIVE"])
@@ -174,10 +173,22 @@ export const updatePaymentProviderRequestSchema = z
174
173
  "샌드박스 스위치. `true`면 새 결제에 TEST 세트를 쓴다(항상 허용). `false`(라이브 전환)는 라이브 개방 조건을 " +
175
174
  "모두 충족해야 한다(`LIVE_GATE_NOT_MET`)",
176
175
  ),
176
+ easyPayProviders: z
177
+ .array(easyPayProviderSchema)
178
+ .max(easyPayProviderSchema.options.length)
179
+ .optional()
180
+ .describe(
181
+ "이 PG로 받는 간편결제사 — 통째로 바꾼다. PG 계약(포트원은 채널)에 추가한 간편결제만 켠다. " +
182
+ "켠 간편결제사마다 스토어프론트 결제 옵션이 하나씩 생긴다(포트원은 간편결제를 받는 채널이 있어야 한다)",
183
+ ),
177
184
  })
178
- .refine((body) => body.enabled !== undefined || body.sandboxMode !== undefined, {
179
- message: "enabled 또는 sandboxMode 중 하나는 보내야 해요",
180
- });
185
+ .refine(
186
+ (body) =>
187
+ body.enabled !== undefined ||
188
+ body.sandboxMode !== undefined ||
189
+ body.easyPayProviders !== undefined,
190
+ { message: "enabled·sandboxMode·easyPayProviders 중 하나는 보내야 해요" },
191
+ );
181
192
  export type UpdatePaymentProviderRequest = z.infer<typeof updatePaymentProviderRequestSchema>;
182
193
 
183
194
  export const verifyPaymentProviderRequestSchema = z.object({
@@ -187,18 +198,31 @@ export const verifyPaymentProviderRequestSchema = z.object({
187
198
  });
188
199
  export type VerifyPaymentProviderRequest = z.infer<typeof verifyPaymentProviderRequestSchema>;
189
200
 
190
- export const updatePaymentSettingsRequestSchema = z.object({
191
- routingMode: paymentRoutingModeSchema,
192
- priority: z
193
- .array(pgProviderSchema)
194
- .min(1)
195
- // PG 개수와 무관한 안전장치 — 거대한 배열이 파싱을 거치지 않게만 막는다. 실제 개수는 서버가 본다
196
- .max(50)
197
- .describe(
198
- "PG 우선순위(앞이 1순위). 연결된 PG 수만큼 **모두 한 번씩** 나열한다(길이 상한 50은 안전장치다). " +
199
- "빠지거나 중복되거나 연결되지 않은 PG가 있으면 `INVALID_PRIORITY`",
200
- ),
201
- });
201
+ export const updatePaymentSettingsRequestSchema = z
202
+ .object({
203
+ priority: z
204
+ .array(pgProviderSchema)
205
+ .min(1)
206
+ // PG 개수와 무관한 안전장치 — 거대한 배열이 파싱을 거치지 않게만 막는다. 실제 개수는 서버가 본다
207
+ .max(50)
208
+ .optional()
209
+ .describe(
210
+ "PG 우선순위(앞이 1순위) — 스토어프론트 결제 옵션 목록의 PG 순서다. 연결된 PG 수만큼 **모두 한 번씩** 나열한다" +
211
+ "(길이 상한 50은 안전장치다). 빠지거나 중복되거나 연결되지 않은 PG가 있으면 `INVALID_PRIORITY`",
212
+ ),
213
+ checkoutOrigins: z
214
+ .array(z.string().max(200))
215
+ .max(20)
216
+ .optional()
217
+ .describe(
218
+ "결제 도메인 — 통째로 바꾼다. 결제를 마친 구매자가 돌아올 스토어프론트 origin(`https://shop.example.com`, https·경로 없음). " +
219
+ "결제 시작의 `returnUrl`은 이 목록 안이어야 한다. 비어 있으면 실결제를 시작할 수 없다(테스트 결제는 localhost 허용). " +
220
+ "같은 도메인을 PG 가맹 정보에도 등록한다. 형식이 틀리면 `INVALID_CHECKOUT_ORIGIN`",
221
+ ),
222
+ })
223
+ .refine((body) => body.priority !== undefined || body.checkoutOrigins !== undefined, {
224
+ message: "priority 또는 checkoutOrigins 중 하나는 보내야 해요",
225
+ });
202
226
  export type UpdatePaymentSettingsRequest = z.infer<typeof updatePaymentSettingsRequestSchema>;
203
227
 
204
228
  // ─── 조회 응답 ─────────────────────────────────────────────
@@ -318,6 +342,12 @@ export const paymentProviderSchema = z.object({
318
342
  })
319
343
  .describe("환경별 자격 증명 세트. 등록되지 않은 세트는 null"),
320
344
  liveGate: liveGateSchema.describe("라이브 개방 조건. 조건을 모두 충족해야 샌드박스를 끌 수 있다"),
345
+ easyPayProviders: z
346
+ .array(easyPayProviderResponseSchema)
347
+ .describe(
348
+ "이 PG로 받는 간편결제사. 연결되지 않았으면 빈 목록. " +
349
+ "현재 값은 `NAVERPAY`·`KAKAOPAY`·`TOSSPAY`·`PAYCO`이고 값이 추가될 수 있어 문자열로 받는다",
350
+ ),
321
351
  retiredCredentialCount: z
322
352
  .number()
323
353
  .int()
@@ -356,10 +386,9 @@ export const PAYMENT_ROUTING_EXCLUSION_CODES = [
356
386
  export type KnownPaymentRoutingExclusion = (typeof PAYMENT_ROUTING_EXCLUSION_CODES)[number];
357
387
 
358
388
  export const paymentSettingsSchema = z.object({
359
- routingMode: paymentRoutingModeSchema.describe(
360
- "저장된 라우팅 방식 (기본 `AUTO_FAILOVER`). PG가 하나뿐이면 무시된다",
361
- ),
362
- effectiveRoutingMode: effectivePaymentRoutingModeSchema,
389
+ checkoutOrigins: z
390
+ .array(z.string())
391
+ .describe("결제 도메인 — 결제를 마친 구매자가 돌아올 스토어프론트 origin 목록"),
363
392
  providers: z
364
393
  .array(paymentProviderSchema)
365
394
  .describe("지원하는 모든 PG. 연결된 것은 우선순위순, 연결되지 않은 것은 뒤에 온다"),
@@ -495,6 +524,15 @@ export const paymentRecordSchema = z.object({
495
524
  refundedAmount: z.number().int().describe("환불 완료 금액 합계. 실패한 환불은 빼고 센다"),
496
525
  provider: pgProviderSchema.nullable().describe("승인한 PG. 승인 전에 끝났으면 마지막 시도의 PG"),
497
526
  method: pgPaymentMethodSchema.nullable().describe("실제 결제수단. 모르면 요청 수단"),
527
+ easyPayProvider: easyPayProviderResponseSchema
528
+ .nullable()
529
+ .optional()
530
+ .default(null)
531
+ .describe(
532
+ "간편결제사. `method`가 `EASY_PAY`일 때만 값이 있고, PG가 알려 주지 않았으면 요청한 간편결제사다. " +
533
+ "그 밖의 결제수단과 간편결제 이전 결제는 null. 서버는 항상 싣는다. " +
534
+ "현재 값은 `NAVERPAY`·`KAKAOPAY`·`TOSSPAY`·`PAYCO`이고 값이 추가될 수 있어 문자열로 받는다",
535
+ ),
498
536
  environment: pgEnvironmentSchema.nullable().describe("결제 환경"),
499
537
  testPayment: z.boolean().describe("샌드박스 결제인가"),
500
538
  pgTransactionId: z.string().nullable().describe("PG 거래 id (토스 paymentKey, 포트원 txId)"),
@@ -516,7 +554,21 @@ export const paymentAttemptRecordSchema = z.object({
516
554
  provider: pgProviderSchema,
517
555
  status: paymentAttemptStatusSchema,
518
556
  method: pgPaymentMethodSchema.describe("주문서에서 고른 결제수단"),
557
+ easyPayProvider: easyPayProviderResponseSchema
558
+ .nullable()
559
+ .optional()
560
+ .default(null)
561
+ .describe(
562
+ "주문서에서 고른 간편결제사. 간편결제가 아니면 null. 서버는 항상 싣는다(값이 추가될 수 있어 문자열)",
563
+ ),
519
564
  approvedMethod: pgPaymentMethodSchema.nullable().describe("PG가 보고한 실제 결제수단"),
565
+ approvedEasyPayProvider: easyPayProviderResponseSchema
566
+ .nullable()
567
+ .optional()
568
+ .default(null)
569
+ .describe(
570
+ "PG가 보고한 실제 간편결제사. 모르거나 간편결제가 아니면 null. 서버는 항상 싣는다(값이 추가될 수 있어 문자열)",
571
+ ),
520
572
  amount: z.number().int(),
521
573
  environment: pgEnvironmentSchema,
522
574
  pgTransactionId: z.string().nullable(),
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { type PageParams, pageQueryShape } from "./common";
3
+ import { UPLOAD_PURPOSE_LIMITS, uploadExtensionOf } from "./upload";
3
4
 
4
5
  export const productStatusSchema = z.enum(["SALE", "SUSPENDED", "OUT_OF_STOCK", "CLOSED"]);
5
6
 
@@ -96,6 +97,62 @@ export const productSchema = productSummarySchema.extend({
96
97
 
97
98
  export type Product = z.infer<typeof productSchema>;
98
99
 
100
+ /** 상품 하나에 등록할 수 있는 이미지 최대 장수 — 첫 장이 대표(썸네일)다 */
101
+ export const MAX_PRODUCT_IMAGES = 10;
102
+
103
+ /**
104
+ * 상품 이미지 주소의 와이어 형식 — URL이고 2048자 이하다.
105
+ *
106
+ * https·확장자 규칙은 여기서 강제하지 않고 `productImageRejection`이 판정한다. 서버가
107
+ * **새로 추가된 주소에만** 그 규칙을 적용해야 하기 때문이다(규칙이 생기기 전에 저장된
108
+ * 주소 하나로 상품 수정 전체가 막히면 셀러가 빠져나갈 길이 없다).
109
+ *
110
+ * 응답 스키마(`productSchema.images`)도 넓게 둔다. 과거에 저장된 주소를 파싱 오류로
111
+ * 만들면 상품 조회 자체가 막힌다.
112
+ */
113
+ export const productImageUrlSchema = z
114
+ .url()
115
+ .max(2048)
116
+ .describe("상품 이미지 주소. https만 허용하고, 확장자가 있으면 이미지 형식이어야 한다");
117
+
118
+ export interface ProductImageRejection {
119
+ code: "INVALID_IMAGE_URL" | "UNSUPPORTED_FILE_TYPE";
120
+ message: string;
121
+ }
122
+
123
+ /**
124
+ * 상품에 **새로 붙이는** 이미지 주소의 규칙. 통과하면 `null`.
125
+ *
126
+ * - https만 받는다. http는 https 스토어프론트에서 혼합 콘텐츠로 차단되고 `data:`·`javascript:`는
127
+ * 상세 화면에 박히는 주입 경로가 된다.
128
+ * - 호스트는 제한하지 않는다 — 셀러가 이미 쓰는 이미지 호스트를 그대로 붙일 수 있어야 한다.
129
+ * - 경로에 확장자가 있으면 상품 이미지 형식이어야 한다(svg·동영상 거부). **확장자가 없으면 통과시킨다**
130
+ * — 외부 이미지 CDN은 확장자 없는 키를 쓰는 경우가 흔하고, 주소만으로는 실제 형식을 알 수 없다.
131
+ */
132
+ export function productImageRejection(url: string): ProductImageRejection | null {
133
+ if (url.length > 2048) {
134
+ return { code: "INVALID_IMAGE_URL", message: "이미지 주소가 너무 깁니다" };
135
+ }
136
+ let parsed: URL;
137
+ try {
138
+ parsed = new URL(url);
139
+ } catch {
140
+ return { code: "INVALID_IMAGE_URL", message: "이미지 주소 형식이 아닙니다" };
141
+ }
142
+ if (parsed.protocol !== "https:") {
143
+ return { code: "INVALID_IMAGE_URL", message: "이미지 주소는 https로 시작해야 합니다" };
144
+ }
145
+ const extension = uploadExtensionOf(parsed.pathname);
146
+ const allowed = UPLOAD_PURPOSE_LIMITS["product-image"].extensions;
147
+ if (extension && !allowed.includes(extension)) {
148
+ return {
149
+ code: "UNSUPPORTED_FILE_TYPE",
150
+ message: `상품 이미지는 ${allowed.join(", ")}만 쓸 수 있습니다`,
151
+ };
152
+ }
153
+ return null;
154
+ }
155
+
99
156
  /** 옵션 축(그룹) 최대 개수 — 스키마 상한의 단일 원천 */
100
157
  export const MAX_OPTION_GROUPS = 3;
101
158
  /** 축 하나가 가질 수 있는 값의 최대 개수 */
@@ -212,7 +269,12 @@ export const productCreateRequestSchema = z
212
269
  * 표시는 `description`(HTML)이 맡는다 — 소비자는 이 필드를 몰라도 된다.
213
270
  */
214
271
  descriptionJson: z.string().nullable().optional(),
215
- images: z.array(z.url()).min(1).max(10).optional(),
272
+ images: z
273
+ .array(productImageUrlSchema)
274
+ .min(1)
275
+ .max(MAX_PRODUCT_IMAGES)
276
+ .optional()
277
+ .describe("상품 이미지 주소 목록. 첫 장이 대표 이미지(썸네일)다"),
216
278
  attributes: z.array(z.object({ attributeId: z.string(), value: z.string() })).optional(),
217
279
  deliveryType: deliveryTypeSchema
218
280
  .default("PAID")
@@ -276,13 +338,6 @@ export const productListQuerySchema = z.object({
276
338
  ...pageQueryShape(),
277
339
  });
278
340
 
279
- /** `POST /v1/product-images` 응답 — 업로드된 이미지의 CDN 주소 */
280
- export const productImageUploadResultSchema = z.object({
281
- images: z.array(z.object({ url: z.url(), width: z.number().int(), height: z.number().int() })),
282
- });
283
-
284
- export type ProductImageUploadResult = z.infer<typeof productImageUploadResultSchema>;
285
-
286
341
  export const discountSchema = z.object({
287
342
  productId: z.string(),
288
343
  discountType: z.enum(["RATE", "AMOUNT"]),
@@ -102,6 +102,17 @@ export const analyticsOriginSchema = z
102
102
  /** 허용 오리진 상한 */
103
103
  export const MAX_ANALYTICS_ALLOWED_ORIGINS = 20;
104
104
 
105
+ /** 자동 배송완료·자동 구매확정 대기일 상한 */
106
+ export const MAX_AUTO_TRANSITION_DAYS = 30;
107
+
108
+ const autoDeliverDaysDescription =
109
+ "발송 후 자동 배송완료까지의 일수(0~30, 기본 7). 발송(`DISPATCHED`)·배송중(`DELIVERING`) 주문상품을 " +
110
+ "이 기간이 지나면 배송완료(`DELIVERED`)로 바꾼다. `0`이면 자동 배송완료를 하지 않는다";
111
+
112
+ const autoConfirmDaysDescription =
113
+ "배송완료 후 자동 구매확정까지의 일수(0~30, 기본 7). 배송완료 시점에 구매확정 예정일(`autoDecisionDate`)을 이 값으로 잡고, " +
114
+ "그 날짜가 지나면 구매확정(`PURCHASE_DECIDED`)으로 바꾼다. 진행 중인 클레임이 있으면 건너뛴다. `0`이면 자동 구매확정을 하지 않는다";
115
+
105
116
  /** `PATCH /v1/store` 본문 — 바꿀 필드만 보낸다 */
106
117
  export const updateStoreRequestSchema = z
107
118
  .object({
@@ -132,6 +143,20 @@ export const updateStoreRequestSchema = z
132
143
  "비회원 주문 허용. false(회원만)면 로그인하지 않은 구매자의 주문서 생성·결제 요청이 403 `GUEST_CHECKOUT_DISABLED`다. " +
133
144
  "장바구니 담기와 이미 받은 비회원 주문의 조회·취소는 막지 않는다",
134
145
  ),
146
+ autoDeliverDays: z
147
+ .number()
148
+ .int()
149
+ .min(0)
150
+ .max(MAX_AUTO_TRANSITION_DAYS)
151
+ .optional()
152
+ .describe(autoDeliverDaysDescription),
153
+ autoConfirmDays: z
154
+ .number()
155
+ .int()
156
+ .min(0)
157
+ .max(MAX_AUTO_TRANSITION_DAYS)
158
+ .optional()
159
+ .describe(autoConfirmDaysDescription),
135
160
  })
136
161
  .refine((value) => Object.values(value).some((v) => v !== undefined), {
137
162
  message: "최소 1개 필드가 필요합니다",
@@ -156,6 +181,8 @@ export const storeSchema = z.object({
156
181
  "스토어프론트 도메인 — 소셜 로그인 리디렉트와 방문 분석 수집이 함께 쓴다(`analyticsAllowedOrigins`와 같은 값)",
157
182
  ),
158
183
  guestCheckoutEnabled: z.boolean().describe("비회원 주문 허용. false면 회원만 주문할 수 있다"),
184
+ autoDeliverDays: z.number().int().describe(autoDeliverDaysDescription),
185
+ autoConfirmDays: z.number().int().describe(autoConfirmDaysDescription),
159
186
  memberCount: z.number().int(),
160
187
  createdAt: z.string(),
161
188
  });
@@ -1,5 +1,82 @@
1
1
  import { z } from "zod";
2
2
 
3
+ /**
4
+ * 업로드 용도. 허용 확장자와 크기 상한이 용도별로 다르다.
5
+ *
6
+ * - `product-image`: 상품 목록·상세의 상품 이미지. 목록 썸네일로 쓰이므로 정지 이미지만 받는다.
7
+ * - `description`: 상세설명 본문에 삽입하는 미디어. 동영상과 svg도 받는다.
8
+ */
9
+ export const uploadPurposeSchema = z.enum(["product-image", "description"]);
10
+
11
+ export type UploadPurpose = z.infer<typeof uploadPurposeSchema>;
12
+
13
+ export interface UploadPurposeLimit {
14
+ /** 소문자 확장자 목록 */
15
+ extensions: readonly string[];
16
+ maxBytes: number;
17
+ }
18
+
19
+ const MB = 1024 * 1024;
20
+
21
+ /**
22
+ * 용도별 업로드 제한 — 서버(`POST /v1/uploads`)와 클라이언트가 공유하는 단일 원천이다.
23
+ *
24
+ * 크기는 **요청이 스스로 신고한 값**으로만 검사한다. 바이트는 api를 거치지 않고 CDN으로
25
+ * 직접 PUT되므로 이 검사는 셀러가 올리기 전에 알게 하는 장치이고, 실제 상한은 CDN이 강제한다.
26
+ */
27
+ export const UPLOAD_PURPOSE_LIMITS: Record<UploadPurpose, UploadPurposeLimit> = {
28
+ // svg는 스크립트를 품을 수 있고, 동영상은 목록 썸네일 자리에 쓸 수 없다
29
+ "product-image": {
30
+ extensions: ["jpg", "jpeg", "png", "webp", "avif", "gif"],
31
+ maxBytes: 10 * MB,
32
+ },
33
+ description: {
34
+ extensions: ["jpg", "jpeg", "png", "gif", "webp", "avif", "svg", "mp4", "webm", "mov"],
35
+ maxBytes: 50 * MB,
36
+ },
37
+ };
38
+
39
+ /** 파일명의 소문자 확장자. 확장자가 없으면 `""` */
40
+ export function uploadExtensionOf(filename: string): string {
41
+ const dot = filename.lastIndexOf(".");
42
+ if (dot < 0 || dot === filename.length - 1) return "";
43
+ return filename.slice(dot + 1).toLowerCase();
44
+ }
45
+
46
+ export interface UploadRejection {
47
+ /** 400 형식 불일치 / 413 크기 초과 */
48
+ status: 400 | 413;
49
+ code: "UNSUPPORTED_FILE_TYPE" | "FILE_TOO_LARGE";
50
+ message: string;
51
+ }
52
+
53
+ /**
54
+ * 용도 제한 위반 여부. 통과하면 `null`.
55
+ * 서버가 던지는 오류와 콘솔이 먼저 띄우는 안내가 갈리지 않도록 한 함수로 판정한다.
56
+ */
57
+ export function uploadRejection(
58
+ purpose: UploadPurpose,
59
+ filename: string,
60
+ size?: number,
61
+ ): UploadRejection | null {
62
+ const limit = UPLOAD_PURPOSE_LIMITS[purpose];
63
+ if (!limit.extensions.includes(uploadExtensionOf(filename))) {
64
+ return {
65
+ status: 400,
66
+ code: "UNSUPPORTED_FILE_TYPE",
67
+ message: `지원하지 않는 파일 형식입니다. ${limit.extensions.join(", ")}만 올릴 수 있습니다`,
68
+ };
69
+ }
70
+ if (size !== undefined && size > limit.maxBytes) {
71
+ return {
72
+ status: 413,
73
+ code: "FILE_TOO_LARGE",
74
+ message: `파일이 너무 큽니다. ${Math.floor(limit.maxBytes / MB)}MB까지 올릴 수 있습니다`,
75
+ };
76
+ }
77
+ return null;
78
+ }
79
+
3
80
  /**
4
81
  * 미디어 업로드 자리 발급 — `POST /v1/uploads`.
5
82
  *
@@ -9,6 +86,15 @@ import { z } from "zod";
9
86
  */
10
87
  export const uploadRequestSchema = z.object({
11
88
  filename: z.string().min(1).max(255),
89
+ purpose: uploadPurposeSchema
90
+ .default("description")
91
+ .describe("업로드 용도. 허용 확장자와 크기 상한이 용도별로 다르다"),
92
+ size: z
93
+ .number()
94
+ .int()
95
+ .positive()
96
+ .optional()
97
+ .describe("파일 바이트 크기. 주면 용도별 상한을 발급 전에 검사한다"),
12
98
  });
13
99
 
14
100
  export type UploadRequest = z.infer<typeof uploadRequestSchema>;