diff --git a/src/common/errors/error-catalog.ts b/src/common/errors/error-catalog.ts index 16cc3910..2d71e11f 100644 --- a/src/common/errors/error-catalog.ts +++ b/src/common/errors/error-catalog.ts @@ -436,6 +436,10 @@ export const ERROR_CATALOG = { status: HttpStatus.NOT_FOUND, message: '커스텀 텍스트 토큰을 찾을 수 없습니다.', }, + CUSTOM_TEXT_TOKEN_KEY_TAKEN: { + status: HttpStatus.CONFLICT, + message: '이 템플릿에 이미 같은 키의 커스텀 텍스트 토큰이 있습니다.', + }, INVALID_DAY_OF_WEEK: { status: HttpStatus.BAD_REQUEST, message: 'dayOfWeek는 0~6이어야 합니다.', diff --git a/src/features/product/product-seller.graphql b/src/features/product/product-seller.graphql index 742d3535..37eeb965 100644 --- a/src/features/product/product-seller.graphql +++ b/src/features/product/product-seller.graphql @@ -126,6 +126,8 @@ extend type Mutation { ): SellerCustomTemplate! """ 커스텀 텍스트 토큰을 등록 또는 수정한다. 판매자 로그인 필수. SELLER 계정이 아니면 FORBIDDEN, 매장을 보유하지 않으면 NOT_FOUND. + 등록 시 같은 템플릿에 같은 tokenKey의 삭제된 슬롯이 있으면 그 슬롯(같은 id)을 복구해 입력값으로 갱신한다. + 활성 슬롯과 tokenKey가 겹치면(수정으로 삭제된 슬롯의 키로 바꾸는 경우 포함) CUSTOM_TEXT_TOKEN_KEY_TAKEN. """ sellerUpsertProductCustomTextToken( input: SellerUpsertProductCustomTextTokenInput! @@ -309,19 +311,20 @@ type SellerCustomTextToken { """ isRequired: Boolean! """ - 베이스 이미지 위에서 문구가 놓일 가로 위치. 미지정 시 null이고 배치는 클라이언트가 정한다. + 문구 영역 왼쪽 위 모서리의 가로 위치. 베이스 이미지 한 변을 10000으로 본 정수 비율(0~10000)이고 서버는 범위를 검사하지 않는다. + 미지정 시 null이고 배치는 클라이언트가 정한다. """ posX: Int """ - 베이스 이미지 위에서 문구가 놓일 세로 위치. 미지정 시 null. + 문구 영역 왼쪽 위 모서리의 세로 위치. 베이스 이미지 한 변을 10000으로 본 정수 비율(0~10000). 미지정 시 null. """ posY: Int """ - 문구 영역 너비. 미지정 시 null. + 문구 영역 너비. 베이스 이미지 한 변을 10000으로 본 정수 비율(1~10000). 미지정 시 null. """ width: Int """ - 문구 영역 높이. 미지정 시 null. + 문구 영역 높이. 베이스 이미지 한 변을 10000으로 본 정수 비율(1~10000). 미지정 시 null. """ height: Int } @@ -862,19 +865,20 @@ input SellerUpsertProductCustomTextTokenInput { """ isRequired: Boolean = true """ - 베이스 이미지 위 가로 위치. 생략 시 배치를 클라이언트가 정한다. + 문구 영역 왼쪽 위 모서리의 가로 위치. 베이스 이미지 한 변을 10000으로 본 정수 비율(0~10000)이고 서버는 범위를 검사하지 않는다. + 생략 시 배치를 클라이언트가 정한다. """ posX: Int """ - 베이스 이미지 위 세로 위치. + 문구 영역 왼쪽 위 모서리의 세로 위치. 베이스 이미지 한 변을 10000으로 본 정수 비율(0~10000). """ posY: Int """ - 문구 영역 너비. + 문구 영역 너비. 베이스 이미지 한 변을 10000으로 본 정수 비율(1~10000). """ width: Int """ - 문구 영역 높이. + 문구 영역 높이. 베이스 이미지 한 변을 10000으로 본 정수 비율(1~10000). """ height: Int } diff --git a/src/features/product/repositories/product.repository.ts b/src/features/product/repositories/product.repository.ts index 34c3340d..fcc704c7 100644 --- a/src/features/product/repositories/product.repository.ts +++ b/src/features/product/repositories/product.repository.ts @@ -1,5 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; +import { DomainException } from '@/common/errors/error-catalog'; +import { uniqueConstraintName } from '@/common/utils/prisma-error'; import { AUDIT_LOG_REPOSITORY, type AuditEntry, @@ -17,6 +19,17 @@ import { } from '@/generated/prisma/client'; import { activeWhere, PrismaService, visibleWhere } from '@/prisma'; +/** 활성 슬롯과의 키 중복(경쟁 포함)·삭제 슬롯 키로의 변경은 unique 충돌로만 드러나 도메인 예외로 좁힌다. */ +function rethrowTokenKeyTaken(error: unknown): never { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + uniqueConstraintName(error) === 'uk_product_custom_text_token' + ) { + throw new DomainException('CUSTOM_TEXT_TOKEN_KEY_TAKEN'); + } + throw error; +} + const tagSuggestionInclude = { _count: { select: { @@ -1002,15 +1015,32 @@ export class ProductRepository { height: args.height, }; const tokenId = args.tokenId; - return this.writeWithAudit( - (tx) => - tokenId - ? tx.productCustomTextToken.update({ where: { id: tokenId }, data }) - : tx.productCustomTextToken.create({ - data: { template_id: args.templateId, ...data }, - }), - audit, - ); + return this.writeWithAudit(async (tx) => { + if (tokenId) { + return tx.productCustomTextToken + .update({ where: { id: tokenId }, data }) + .catch(rethrowTokenKeyTaken); + } + // 같은 템플릿의 슬롯 생성을 직렬화한다 — 잠금 없이는 같은 삭제 슬롯을 동시에 복구한 둘이 모두 성공한다 + await tx.$queryRaw`SELECT id FROM product_custom_template WHERE id = ${args.templateId} FOR UPDATE`; + // unique가 삭제 행도 세므로 같은 키의 삭제 슬롯은 새로 만들지 않고 복구한다 + const deleted = await tx.productCustomTextToken.findFirst({ + where: { + template_id: args.templateId, + token_key: args.tokenKey, + deleted_at: { not: null }, + }, + select: { id: true }, + }); + return deleted + ? tx.productCustomTextToken.update({ + where: { id: deleted.id }, + data: { ...data, deleted_at: null }, + }) + : tx.productCustomTextToken + .create({ data: { template_id: args.templateId, ...data } }) + .catch(rethrowTokenKeyTaken); + }, audit); } async findCustomTextTokenById(id: bigint) { diff --git a/src/features/product/services/product-seller-custom-template.service.spec.ts b/src/features/product/services/product-seller-custom-template.service.spec.ts index b8368f90..24d323eb 100644 --- a/src/features/product/services/product-seller-custom-template.service.spec.ts +++ b/src/features/product/services/product-seller-custom-template.service.spec.ts @@ -2,6 +2,7 @@ import { AUDIT_LOG_REPOSITORY } from '@/features/audit-log'; import { AuditLogRepository } from '@/features/audit-log/repositories/audit-log.repository'; import { ProductRepository } from '@/features/product/repositories/product.repository'; import { SellerCustomTemplateService } from '@/features/product/services/product-seller-custom-template.service'; +import type { SellerCustomTextTokenOutput } from '@/features/product/types/product-seller-output.type'; import { StoreSellerRepository } from '@/features/store/repositories/store-seller.repository'; import type { PrismaClient, Product } from '@/generated/prisma/client'; import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; @@ -266,6 +267,224 @@ describe('SellerCustomTemplateService (real DB)', () => { expect(result.id).toBe(token.id.toString()); expect(result.tokenKey).toBe('B'); }); + + describe('같은 tokenKey 재사용', () => { + it('삭제한 슬롯과 같은 키로 등록하면 그 슬롯을 복구해 입력값으로 갱신한다', async () => { + const { accountId, storeId, product } = await setupSellerWithProduct(); + const tpl = await createTemplate(product.id); + const first = await service.sellerUpsertProductCustomTextToken( + accountId, + { + templateId: tpl.id.toString(), + tokenKey: 'NAME', + defaultText: '가', + }, + ); + await service.sellerDeleteProductCustomTextToken( + accountId, + BigInt(first.id), + ); + + const result = await service.sellerUpsertProductCustomTextToken( + accountId, + { + templateId: tpl.id.toString(), + tokenKey: 'NAME', + defaultText: '나', + maxLength: 12, + sortOrder: 3, + isRequired: false, + posX: 1000, + posY: 2000, + width: 8000, + height: 1500, + }, + ); + + expect(result).toEqual({ + id: first.id, + templateId: tpl.id.toString(), + tokenKey: 'NAME', + defaultText: '나', + maxLength: 12, + sortOrder: 3, + isRequired: false, + posX: 1000, + posY: 2000, + width: 8000, + height: 1500, + }); + const rows = await prisma.productCustomTextToken.findMany({ + where: { template_id: tpl.id, deleted_at: undefined }, + }); + expect(rows).toHaveLength(1); + expect(rows[0].deleted_at).toBeNull(); + const audits = await prisma.auditLog.findMany({ + where: { store_id: storeId, action: 'CREATE' }, + orderBy: { id: 'asc' }, + }); + expect(audits.map((a) => a.after_json)).toEqual([ + { tokenId: first.id, tokenKey: 'NAME' }, + { tokenId: first.id, tokenKey: 'NAME' }, + ]); + }); + + it('활성 슬롯과 키가 겹치면 CUSTOM_TEXT_TOKEN_KEY_TAKEN이고 아무것도 바뀌지 않는다', async () => { + const { accountId, storeId, product } = await setupSellerWithProduct(); + const tpl = await createTemplate(product.id); + const live = await prisma.productCustomTextToken.create({ + data: { template_id: tpl.id, token_key: 'NAME', default_text: '가' }, + }); + + await expect( + service.sellerUpsertProductCustomTextToken(accountId, { + templateId: tpl.id.toString(), + tokenKey: 'NAME', + defaultText: '나', + }), + ).rejects.toThrowDomain('CUSTOM_TEXT_TOKEN_KEY_TAKEN'); + + const after = await prisma.productCustomTextToken.findUniqueOrThrow({ + where: { id: live.id }, + }); + expect(after.default_text).toBe('가'); + expect( + await prisma.auditLog.count({ where: { store_id: storeId } }), + ).toBe(0); + }); + + it.each([ + ['활성 슬롯', null], + ['삭제된 슬롯', new Date()], + ])( + '수정으로 %s의 키로 바꾸면 CUSTOM_TEXT_TOKEN_KEY_TAKEN', + async (_label, deletedAt) => { + const { accountId, product } = await setupSellerWithProduct(); + const tpl = await createTemplate(product.id); + await prisma.productCustomTextToken.create({ + data: { + template_id: tpl.id, + token_key: 'TAKEN', + default_text: 'x', + deleted_at: deletedAt, + }, + }); + const mine = await prisma.productCustomTextToken.create({ + data: { template_id: tpl.id, token_key: 'MINE', default_text: 'y' }, + }); + + await expect( + service.sellerUpsertProductCustomTextToken(accountId, { + templateId: tpl.id.toString(), + tokenId: mine.id.toString(), + tokenKey: 'TAKEN', + defaultText: 'y', + }), + ).rejects.toThrowDomain('CUSTOM_TEXT_TOKEN_KEY_TAKEN'); + }, + ); + + it('다른 템플릿의 같은 키는 활성이든 삭제든 무관하다', async () => { + const me = await setupSellerWithProduct(); + const other = await setupSellerWithProduct(); + const myTpl = await createTemplate(me.product.id); + const othersTpl = await createTemplate(other.product.id); + const othersLive = await prisma.productCustomTextToken.create({ + data: { + template_id: othersTpl.id, + token_key: 'NAME', + default_text: 'a', + }, + }); + const othersDeleted = await prisma.productCustomTextToken.create({ + data: { + template_id: othersTpl.id, + token_key: 'GONE', + default_text: 'b', + deleted_at: new Date(), + }, + }); + + const created = await Promise.all( + ['NAME', 'GONE'].map((tokenKey) => + service.sellerUpsertProductCustomTextToken(me.accountId, { + templateId: myTpl.id.toString(), + tokenKey, + defaultText: '내것', + }), + ), + ); + + expect(created.map((t) => t.templateId)).toEqual([ + myTpl.id.toString(), + myTpl.id.toString(), + ]); + const createdIds = created.map((t) => t.id); + expect(createdIds).not.toContain(othersLive.id.toString()); + expect(createdIds).not.toContain(othersDeleted.id.toString()); + const stillDeleted = + await prisma.productCustomTextToken.findUniqueOrThrow({ + where: { id: othersDeleted.id }, + }); + expect(stillDeleted.deleted_at).not.toBeNull(); + }); + + it.each([ + ['새 키', false], + ['삭제된 슬롯의 키', true], + ])( + '%s로 동시에 2건을 등록하면 1건만 성공하고 나머지는 CUSTOM_TEXT_TOKEN_KEY_TAKEN', + async (_label, withDeleted) => { + const { accountId, storeId, product } = + await setupSellerWithProduct(); + const tpl = await createTemplate(product.id); + if (withDeleted) { + await prisma.productCustomTextToken.create({ + data: { + template_id: tpl.id, + token_key: 'NAME', + default_text: 'old', + deleted_at: new Date(), + }, + }); + } + + const results = await Promise.allSettled( + ['A', 'B'].map((defaultText) => + service.sellerUpsertProductCustomTextToken(accountId, { + templateId: tpl.id.toString(), + tokenKey: 'NAME', + defaultText, + }), + ), + ); + + const fulfilled = results.filter( + (r): r is PromiseFulfilledResult => + r.status === 'fulfilled', + ); + const rejected = results.filter( + (r): r is PromiseRejectedResult => r.status === 'rejected', + ); + expect(fulfilled).toHaveLength(1); + expect(rejected).toHaveLength(1); + expect(rejected[0].reason).toThrowDomain( + 'CUSTOM_TEXT_TOKEN_KEY_TAKEN', + ); + const rows = await prisma.productCustomTextToken.findMany({ + where: { template_id: tpl.id, deleted_at: undefined }, + }); + expect(rows).toHaveLength(1); + expect(rows[0].deleted_at).toBeNull(); + expect(rows[0].default_text).toBe(fulfilled[0].value.defaultText); + expect( + await prisma.auditLog.count({ + where: { store_id: storeId, action: 'CREATE' }, + }), + ).toBe(1); + }, + ); + }); }); describe('sellerDeleteProductCustomTextToken', () => {