diff --git a/package.json b/package.json index fbe8145..5789622 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,8 @@ "test:hello": "ts-node ./test.ts" }, "dependencies": { + "@nestjs-cls/transactional": "^3.1.0", + "@nestjs-cls/transactional-adapter-typeorm": "^1.3.0", "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.0.1", diff --git a/src/app.module.ts b/src/app.module.ts index 212e9a0..ae09d5a 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,6 +1,6 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; -import { TypeOrmModule } from '@nestjs/typeorm'; +import { getDataSourceToken, TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmConfigService } from './database/typeormConfig.service'; import { UserModule } from './user/user.module'; import { AuthModule } from './auth/auth.module'; @@ -10,6 +10,8 @@ import { OrderDetailModule } from './orderDetail/orderDetail.module'; import { ClsModule } from 'nestjs-cls'; import { APP_INTERCEPTOR } from '@nestjs/core'; import { TransactionInterceptor } from './common/decorator/transaction.decorator'; +import { ClsPluginTransactional } from '@nestjs-cls/transactional'; +import { TransactionalAdapterTypeOrm } from '@nestjs-cls/transactional-adapter-typeorm'; @Module({ imports: [ @@ -20,6 +22,14 @@ import { TransactionInterceptor } from './common/decorator/transaction.decorator ClsModule.forRoot({ global: true, middleware: { mount: true }, + plugins: [ + new ClsPluginTransactional({ + imports: [TypeOrmModule], + adapter: new TransactionalAdapterTypeOrm({ + dataSourceToken: getDataSourceToken(), + }), + }), + ], }), UserModule, AuthModule, diff --git a/src/common/entity/base.ts b/src/common/entity/base.ts index 8ad3d9a..2a56410 100644 --- a/src/common/entity/base.ts +++ b/src/common/entity/base.ts @@ -3,12 +3,15 @@ import { PrimaryGeneratedColumn, UpdateDateColumn, } from 'typeorm'; +import z from 'zod'; -export interface IBaseEntity { - id?: number; - createdAt?: Date; - updatedAt?: Date; -} +export const BaseSchema = z.object({ + id: z.number().optional(), + createdAt: z.date().optional(), + updatedAt: z.date().optional(), +}); + +export type IBaseEntity = z.infer; /** * TypeOrm BaseEntity와 이름중복이 있어서 'My'라는 프리픽스를 사용함 diff --git a/src/order/dto/order.dto.ts b/src/order/dto/order.dto.ts index 4660412..66aadd9 100644 --- a/src/order/dto/order.dto.ts +++ b/src/order/dto/order.dto.ts @@ -6,18 +6,16 @@ import { IsString, Length, Min, - registerDecorator, ValidateNested, - ValidationOptions, } from 'class-validator'; import { IBaseEntity } from '../../common/entity/base'; import { IOrderDetail } from '../../orderDetail/entity/orderDetail.entity'; -import { IOrderEntity } from '../entity/order.entity'; import { UserNameVO } from '../../user/vo/name.vo'; import { AddressVO } from '../vo/address.vo'; import { PostalCodeVO } from '../vo/postalCode.vo'; import { IsValidTotalAmount } from '../utils/isValidTotalAmount.decorator'; import { Type } from 'class-transformer'; +import { IOrderEntity } from '../entity/order.entity'; type WithoutBaseEntity = Omit; diff --git a/src/order/entity/order.entity.ts b/src/order/entity/order.entity.ts index 5118b07..5ab6d42 100644 --- a/src/order/entity/order.entity.ts +++ b/src/order/entity/order.entity.ts @@ -1,8 +1,16 @@ -import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; +import { + BaseEntity, + Column, + Entity, + JoinColumn, + ManyToOne, + OneToMany, +} from 'typeorm'; import { IBaseEntity, MyBaseEntity } from '../../common/entity/base'; import { CommonConstraints } from '../../common/entity/base.constraints'; import { UserEntity } from '../../user/entity/user.entity'; import { OrderDetailEntity } from '../../orderDetail/entity/orderDetail.entity'; +import z from 'zod'; export const ORDER_STATUS = { PENDING: 'pending', @@ -13,24 +21,23 @@ export const ORDER_STATUS = { CANCELED: 'canceled', REFUNDED: 'refunded', }; -const extractOrderStatusEnum = () => Object.values(ORDER_STATUS); -const OrderStatusEnum = extractOrderStatusEnum(); -type OrderStatus = typeof ORDER_STATUS; -type TOrderStatus = OrderStatus[keyof OrderStatus]; - -export interface IOrderEntity extends IBaseEntity { - userId: number; - subtotal: number; - shippingFee: number; - totalAmount: number; // (상품 합계 금액 + 배송비) - orderStatus: TOrderStatus; - recipientName: string; - recipientPhone: string; - shippingAddress: string; - shippingDetailAddress?: string; - postalCode: string; -} - +const OrderStatusValues = Object.values(ORDER_STATUS); + +const OrderSchema = z.object({ + userId: z.number(), + subtotal: z.number(), + shippingFee: z.number(), + totalAmount: z.number(), + orderStatus: z.enum(OrderStatusValues), + recipientName: z.string(), + recipientPhone: z.string(), + shippingAddress: z.string(), + postalCode: z.string(), + shippingDetailAddress: z.string().optional(), +}); +type OrderEntityType = z.infer; +export type IOrderEntity = IBaseEntity & OrderEntityType; +export type OrderParam = Omit; export type PersistedOrderEntity = Required>; @Entity({ name: 'orders' }) @@ -45,7 +52,7 @@ export class OrderEntity extends MyBaseEntity implements IOrderEntity { name: 'userId', referencedColumnName: CommonConstraints.DB_CONSTRAINTS.ID, }) - user: UserEntity; + user?: UserEntity; @Column({ type: CommonConstraints.DB_CONSTRAINTS.BASIC_NUMBER, @@ -67,10 +74,10 @@ export class OrderEntity extends MyBaseEntity implements IOrderEntity { @Column({ type: 'enum', - enum: OrderStatusEnum, + enum: OrderStatusValues, default: ORDER_STATUS.PENDING, }) - orderStatus: string; + orderStatus: string = ORDER_STATUS.PENDING; @Column({ type: CommonConstraints.DB_CONSTRAINTS.BASIC_STRING, @@ -99,38 +106,16 @@ export class OrderEntity extends MyBaseEntity implements IOrderEntity { postalCode: string; @OneToMany(() => OrderDetailEntity, (orderDetail) => orderDetail.order) - orderDetails: OrderDetailEntity[]; + orderDetails?: OrderDetailEntity[]; - constructor(param?: IOrderEntity) { + private constructor(param?: IBaseEntity) { super(param); - if (param) { - const { - orderStatus, - postalCode, - recipientName, - recipientPhone, - shippingAddress, - shippingFee, - subtotal, - totalAmount, - userId, - shippingDetailAddress, - } = param; - - this.orderStatus = orderStatus; - this.postalCode = postalCode; - this.recipientName = recipientName; - this.recipientPhone = recipientPhone; - this.shippingAddress = shippingAddress; - this.shippingFee = shippingFee; - this.subtotal = subtotal; - this.totalAmount = totalAmount; - this.userId = userId; - this.shippingDetailAddress = shippingDetailAddress; - } } - static from(param: IOrderEntity) { - return new OrderEntity(param); + static create(param: OrderParam) { + const safeParsedParam = OrderSchema.parse(param); + const entity = new OrderEntity(param); + Object.assign(entity, safeParsedParam); + return entity; } } diff --git a/src/order/entity/orderRequest.entity.ts b/src/order/entity/orderRequest.entity.ts index 662d531..733481a 100644 --- a/src/order/entity/orderRequest.entity.ts +++ b/src/order/entity/orderRequest.entity.ts @@ -54,7 +54,7 @@ export class OrderRequestEntity implements IOrderRequestEntity { hashedPayload: string; @Column({ type: 'json' }) - responseBody: Record; + responseBody: any; @Column({ type: 'datetime' }) createdAt?: Date; diff --git a/src/order/order.repository.ts b/src/order/order.repository.ts index 7794c91..bd332f5 100644 --- a/src/order/order.repository.ts +++ b/src/order/order.repository.ts @@ -1,19 +1,17 @@ -import { Injectable, NotImplementedException } from '@nestjs/common'; -import { OrderEntity } from './entity/order.entity'; -import { DataSource } from 'typeorm'; -import { ClsService } from 'nestjs-cls'; -import { BaseRepository } from '../common/repository/base.repository'; +import { Injectable } from '@nestjs/common'; +import { OrderEntity, PersistedOrderEntity } from './entity/order.entity'; +import { TransactionHost } from '@nestjs-cls/transactional'; +import { TransactionalAdapterTypeOrm } from '@nestjs-cls/transactional-adapter-typeorm'; @Injectable() -export class OrderRepository extends BaseRepository { +export class OrderRepository { constructor( - protected readonly dataSource: DataSource, - protected readonly clsService: ClsService, - ) { - super(clsService, dataSource); - } + private readonly txHost: TransactionHost, + ) {} - async saveOrder() { - throw new NotImplementedException(); + async saveOrder(order: OrderEntity): Promise { + return (await this.txHost.tx + .getRepository(OrderEntity) + .save(order)) as PersistedOrderEntity; } } diff --git a/src/order/order.service.ts b/src/order/order.service.ts index 06160d4..8858c81 100644 --- a/src/order/order.service.ts +++ b/src/order/order.service.ts @@ -4,9 +4,10 @@ import { OrderRequestService } from './orderRequest.service'; import { ProductPriceService } from '../product/productPrice.service'; import { OrderPolicyService } from './policy/order.policy'; import { OrderRepository } from './order.repository'; -import { Transactional } from '../common/decorator/transaction.decorator'; import { ProductService } from '../product/product.service'; -import { OrderItemsInput } from './dto/order.dto'; +import { Transactional } from '@nestjs-cls/transactional'; +import { OrderDto } from './dto/order.dto'; +import { OrderEntity, OrderParam } from './entity/order.entity'; @Injectable() export class OrderService { @@ -39,13 +40,13 @@ export class OrderService { * 2. 재고가 충분하면 재고 감소, 재고가 충분하지 않으면 에러 * 3. 재고가 불충분하면 롤백 및 주문 전체 취소 */ - await this.productService.validateAndDecreaseStocks(orderDto.orderItems); + const { orderItems, ...orderInfos } = orderDto; + await this.productService.validateAndDecreaseStocks(orderItems); - /** - * TODO - * create Order,OrderItems - */ - const result = (await this.orderRepository.saveOrder()) as any; // response + // TODO + // create orderDto + + const result = await this.saveOrder(orderInfos, userId); await this.orderRequestService.save({ id: orderRequestId, orderDto, @@ -53,4 +54,12 @@ export class OrderService { userId, }); } + private async saveOrder( + orderDto: Omit, + userId: number, + ) { + const orderParam: OrderParam = { ...orderDto, userId }; + const order = OrderEntity.create(orderParam); + return await this.orderRepository.saveOrder(order); + } } diff --git a/src/product/entity/productPrice.entity.ts b/src/product/entity/productPrice.entity.ts index 2945b75..adef074 100644 --- a/src/product/entity/productPrice.entity.ts +++ b/src/product/entity/productPrice.entity.ts @@ -1,14 +1,8 @@ import { Column, Entity } from 'typeorm'; -import { MyBaseEntity } from '../../common/entity/base'; +import { IBaseEntity, MyBaseEntity } from '../../common/entity/base'; import { CommonConstraints } from '../../common/entity/base.constraints'; import { z } from 'zod'; -const BaseSchema = z.object({ - id: z.number().optional(), - createdAt: z.date().optional(), - updatedAt: z.date().optional(), -}); - const ProductPriceSchema = z.object({ productId: z.number(), price: z.number(), @@ -16,9 +10,8 @@ const ProductPriceSchema = z.object({ isCurrent: z.boolean(), }); -type BaseType = z.infer; type ProductPriceType = z.infer; -type IProductPriceEntity = BaseType & ProductPriceType; +type IProductPriceEntity = IBaseEntity & ProductPriceType; export type PersistedProductPriceEntity = Required; @Entity({ name: 'product_prices' }) @@ -48,7 +41,7 @@ export class ProductPriceEntity }) isCurrent: boolean; - private constructor(param?: BaseType) { + private constructor(param?: IBaseEntity) { super(param); } diff --git a/src/product/product.repository.ts b/src/product/product.repository.ts index 1bb231d..dc8eb59 100644 --- a/src/product/product.repository.ts +++ b/src/product/product.repository.ts @@ -1,11 +1,11 @@ import { Injectable } from '@nestjs/common'; import { PersistedProductEntity, ProductEntity } from './entity/product.entity'; -import { DataSource, In } from 'typeorm'; +import { In } from 'typeorm'; import { OrderDetailEntity } from '../orderDetail/entity/orderDetail.entity'; -import { BaseRepository } from '../common/repository/base.repository'; -import { ClsService } from 'nestjs-cls'; import { OrderItemsInput } from '../order/dto/order.dto'; import { ProductUpdateException } from '../common/exception/product.exception'; +import { TransactionHost } from '@nestjs-cls/transactional'; +import { TransactionalAdapterTypeOrm } from '@nestjs-cls/transactional-adapter-typeorm'; export type Range = { min: number; @@ -13,13 +13,10 @@ export type Range = { }; @Injectable() -export class ProductRepository extends BaseRepository { +export class ProductRepository { constructor( - protected readonly dataSource: DataSource, - protected readonly clsService: ClsService, - ) { - super(clsService, dataSource); - } + private readonly txHost: TransactionHost, + ) {} /** * @@ -33,8 +30,9 @@ export class ProductRepository extends BaseRepository { * - salesRank: 판매량 순위 (1부터 시작) */ async getPopularTopK(limit: number, month: number, price: Range) { - const popularTopProducts = await this.getManager() - .createQueryBuilder(ProductEntity, 'p') + const popularTopProducts = await this.txHost.tx + .getRepository(ProductEntity) + .createQueryBuilder('p') .select([ 'p.id AS id', 'p.name as name', @@ -58,22 +56,21 @@ export class ProductRepository extends BaseRepository { } async findMany(productIds: number[]) { - /** - * 상품 조회시 lock 추후 적용. - * 현재는 우선 트랜잭션 로직만 작성 - */ - - const products = await this.getRepository(ProductEntity).find({ + const products = await this.txHost.tx.getRepository(ProductEntity).find({ where: { id: In(productIds), }, + lock: { + mode: 'pessimistic_write', + }, }); - return products; + return products as PersistedProductEntity[]; } async decreaseStocks(orderItems: OrderItemsInput[]) { const updateQuries = orderItems.map((oi) => - this.getRepository(ProductEntity) + this.txHost.tx + .getRepository(ProductEntity) .createQueryBuilder() .update() .set({ @@ -95,4 +92,8 @@ export class ProductRepository extends BaseRepository { }); } } + + async save(entity: unknown) { + return (await this.txHost.tx.save(entity)) as PersistedProductEntity[]; + } } diff --git a/src/product/productPrice.repository.ts b/src/product/productPrice.repository.ts index 3ff6038..6ad94e2 100644 --- a/src/product/productPrice.repository.ts +++ b/src/product/productPrice.repository.ts @@ -1,26 +1,23 @@ import { Injectable } from '@nestjs/common'; -import { DataSource, In } from 'typeorm'; +import { In } from 'typeorm'; import { PersistedProductPriceEntity, ProductPriceEntity, } from './entity/productPrice.entity'; -import { BaseRepository } from '../common/repository/base.repository'; -import { ClsService } from 'nestjs-cls'; +import { TransactionHost } from '@nestjs-cls/transactional'; +import { TransactionalAdapterTypeOrm } from '@nestjs-cls/transactional-adapter-typeorm'; @Injectable() -export class ProductPriceRepository extends BaseRepository { +export class ProductPriceRepository { constructor( - protected readonly clsService: ClsService, - protected readonly dataSource: DataSource, - ) { - super(clsService, dataSource); - } + private readonly txHost: TransactionHost, + ) {} async findMany(productIds: number[]) { - return await this.getRepository(ProductPriceEntity).find({ + return (await this.txHost.tx.getRepository(ProductPriceEntity).find({ where: { productId: In(productIds), }, - }); + })) as PersistedProductPriceEntity[]; } } diff --git a/yarn.lock b/yarn.lock index 42fdc88..2f08e24 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1009,6 +1009,16 @@ "@napi-rs/nice-win32-ia32-msvc" "1.0.1" "@napi-rs/nice-win32-x64-msvc" "1.0.1" +"@nestjs-cls/transactional-adapter-typeorm@^1.3.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@nestjs-cls/transactional-adapter-typeorm/-/transactional-adapter-typeorm-1.3.0.tgz#51d3ee6a3b4ed0414569c9242d3d411604fffec3" + integrity sha512-45nzqAWUTDDnhj5pdtfAMXJjx+vMXv00zudqt5hbsZXyIVbMOiJXyrN1QHf0Or5rgd285VoiyQDY8hptPK9ieQ== + +"@nestjs-cls/transactional@^3.1.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@nestjs-cls/transactional/-/transactional-3.1.0.tgz#68909efaced90e7f915d0fc6d0887cd022ae203e" + integrity sha512-HWtS73KWva1Z6Y8PEkVGjDd4dZDb7Qv4rhwCinpsmv5m2wSvxGxqbnG+oTSe7ZCB1EoLihOL9L08GyB6o6bLQA== + "@nestjs/cli@^11.0.0": version "11.0.7" resolved "https://registry.yarnpkg.com/@nestjs/cli/-/cli-11.0.7.tgz#204a1969c2609d7cf5e98a4d65819bd8585bead7"