Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/features/order/constants/order-error-messages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/** 주문 생성(체크아웃) 에러 메시지. */
export const ORDER_CHECKOUT_ERRORS = {
BUYER_ACCOUNT_NOT_ACTIVE: '유효한 사용자 계정이 아닙니다.',
BUYER_NOT_USER: 'USER 계정만 주문할 수 있습니다.',
PRODUCT_NOT_FOUND: '상품을 찾을 수 없습니다.',
DUPLICATE_OPTION_ITEM: '중복된 옵션 선택입니다.',
INVALID_OPTION_ITEM: '해당 상품의 옵션이 아닙니다.',
OPTION_GROUP_RULE_VIOLATION: '옵션 그룹의 선택 규칙을 충족하지 않습니다.',
OPTION_CUSTOMIZATION_REQUIRED:
'커스텀 정보가 필요한 옵션은 아직 주문할 수 없습니다.',
PICKUP_NOT_AVAILABLE: '선택한 픽업 일시는 예약할 수 없습니다.',
ORDER_AMOUNT_OUT_OF_RANGE: '주문 금액이 처리 가능한 범위를 벗어났습니다.',
UNSUPPORTED_CURRENCY: 'KRW 상품만 주문할 수 있습니다.',
BUYER_PHONE_REQUIRED:
'주문자 연락처가 필요합니다. 프로필에 전화번호를 등록하거나 입력해 주세요.',
ORDER_NUMBER_GENERATION_FAILED:
'주문번호 생성에 실패했습니다. 잠시 후 다시 시도해 주세요.',
} as const;
6 changes: 6 additions & 0 deletions src/features/order/constants/order.constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/**
* 주문자 전화번호 형식. 프로필 전화번호 정책(user feature `PHONE_REGEX`,
* 010-XXXX-XXXX 고정 13자)과 동일해야 한다 — user는 배럴 없는 feature라
* cross-feature import 대신 정책을 복제하고 출처를 명시한다.
*/
export const ORDER_BUYER_PHONE_REGEX = /^010-\d{4}-\d{4}$/;
71 changes: 71 additions & 0 deletions src/features/order/dto/inputs/create-order.input.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import 'reflect-metadata';

import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';

import { CreateOrderInput } from '@/features/order/dto/inputs/create-order.input';

function build(plain: object): CreateOrderInput {
return plainToInstance(CreateOrderInput, plain);
}

const VALID = {
productId: '1',
optionItemIds: ['10', '11'],
pickupAt: new Date('2026-09-18T05:00:00.000Z'),
};

describe('CreateOrderInput', () => {
it('필수 필드만으로 통과한다 (quantity/buyer는 optional)', async () => {
expect(await validate(build(VALID))).toHaveLength(0);
});

it('buyer 필드·quantity 포함 통과', async () => {
const errors = await validate(
build({
...VALID,
quantity: 3,
buyerName: '차차',
buyerPhone: '010-0000-1111',
}),
);
expect(errors).toHaveLength(0);
});

it('optionItemIds가 배열이 아니면 거절한다', async () => {
const errors = await validate(build({ ...VALID, optionItemIds: '10' }));
expect(errors[0].property).toBe('optionItemIds');
});

it('pickupAt이 Date가 아니면 거절한다', async () => {
const errors = await validate(build({ ...VALID, pickupAt: 'not-a-date' }));
expect(errors[0].property).toBe('pickupAt');
});

it('quantity 0·100은 범위 위반으로 거절한다', async () => {
expect((await validate(build({ ...VALID, quantity: 0 })))[0].property).toBe(
'quantity',
);
expect(
(await validate(build({ ...VALID, quantity: 100 })))[0].property,
).toBe('quantity');
});

it('buyerName 빈 문자열은 거절한다', async () => {
const errors = await validate(build({ ...VALID, buyerName: '' }));
expect(errors[0].property).toBe('buyerName');
});

it('buyerPhone은 010-XXXX-XXXX 형식만 허용한다', async () => {
expect(
(await validate(build({ ...VALID, buyerPhone: 'abc' })))[0].property,
).toBe('buyerPhone');
expect(
(await validate(build({ ...VALID, buyerPhone: '01000001111' })))[0]
.property,
).toBe('buyerPhone');
expect(
await validate(build({ ...VALID, buyerPhone: '010-0000-1111' })),
).toHaveLength(0);
});
});
46 changes: 46 additions & 0 deletions src/features/order/dto/inputs/create-order.input.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import {
IsArray,
IsDate,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
Matches,
Max,
MaxLength,
Min,
} from 'class-validator';

import { ORDER_BUYER_PHONE_REGEX } from '@/features/order/constants/order.constants';

export class CreateOrderInput {
@IsString()
@IsNotEmpty()
productId!: string;

@IsArray()
@IsString({ each: true })
optionItemIds!: string[];

@IsDate()
pickupAt!: Date;

@IsOptional()
@IsInt()
@Min(1)
@Max(99)
quantity?: number;

@IsOptional()
@IsString()
@IsNotEmpty()
@MaxLength(100)
buyerName?: string;

@IsOptional()
@IsString()
// 프로필 전화번호와 동일 정책(010-XXXX-XXXX 고정) — 임의 문자열이
// 검증된 프로필 값을 덮어쓰지 못하게 형식을 강제한다
@Matches(ORDER_BUYER_PHONE_REGEX)
buyerPhone?: string;
}
27 changes: 27 additions & 0 deletions src/features/order/order-checkout.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
extend type Mutation {
"""주문 생성(구매자). 옵션·픽업 일시를 서버가 재검증하고 가격을 스냅샷한다. 로그인 필수."""
createOrder(input: CreateOrderInput!): CreateOrderOutput!

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Deduplicate retried order submissions

When a client retries after a timeout or submits twice, this mutation has no idempotency key or equivalent lookup, and every invocation generates a fresh random order number. The same checkout therefore creates multiple independent SUBMITTED orders and consumes capacity multiple times; accept a client request key, enforce uniqueness per account, and return the existing result on replay.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

미반영(후속 추적): 멱등 키는 주문 요청 키 컬럼+unique 등 스키마 변경 동반이라 릴리즈 리뷰 fix 범위 초과. 결제 없는 현 단계 위험도가 낮아, 커스텀 확장 시 입력 계약과 함께 설계하기로 하고 후속 이슈로 등록 예정.

}

"""주문 생성 입력. 커스텀 필드는 커스텀 스펙 확정 후 optional로 확장 예정."""
input CreateOrderInput {
productId: ID!
"""선택한 옵션 아이템 ID 목록. 그룹 규칙(필수/min/max)을 서버가 검증한다."""
optionItemIds: [ID!]!
"""픽업 일시. 매장 정책(영업시간·휴무·capacity·리드타임·슬롯 정렬) 재검증."""
pickupAt: DateTime!
quantity: Int = 1
"""미입력 시 프로필 닉네임 사용."""
buyerName: String
"""미입력 시 프로필 전화번호 사용. 둘 다 없으면 거절."""
buyerPhone: String
}

"""주문 생성 결과 요약. 상세는 myOrder로 재조회."""
type CreateOrderOutput {
orderId: ID!
orderNumber: String!
status: OrderStatusType!
pickupAt: DateTime!
totalPrice: Int!
}
15 changes: 14 additions & 1 deletion src/features/order/order.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,23 @@ import { Module } from '@nestjs/common';

import { OrderStatusTransitionPolicy } from '@/features/order/policies/order-status-transition.policy';
import { OrderRepository } from '@/features/order/repositories/order.repository';
import { OrderCheckoutMutationResolver } from '@/features/order/resolvers/order-checkout-mutation.resolver';
import { OrderCheckoutService } from '@/features/order/services/order-checkout.service';
import { OrderDomainService } from '@/features/order/services/order-domain.service';
import { ProductModule } from '@/features/product';
import { StoreModule } from '@/features/store';

@Module({
providers: [OrderRepository, OrderStatusTransitionPolicy, OrderDomainService],
// 주문 생성이 상품 옵션 조회(ProductRepository)와 픽업 판정
// (StorePickupScheduleService)을 소비한다 — 배럴 공개 API 경유.
imports: [ProductModule, StoreModule],
providers: [
OrderRepository,
OrderStatusTransitionPolicy,
OrderDomainService,
OrderCheckoutService,
OrderCheckoutMutationResolver,
],
exports: [OrderRepository, OrderStatusTransitionPolicy, OrderDomainService],
})
export class OrderModule {}
Loading
Loading