From da371f109518fb98a9c3847638fc85d7d6cbe86e Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Tue, 6 Oct 2026 08:29:17 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=EC=A7=80=EC=9A=B4=20=EC=BB=A4?= =?UTF-8?q?=EC=8A=A4=ED=85=80=20=EB=AC=B8=EA=B5=AC=20=EC=8A=AC=EB=A1=AF=20?= =?UTF-8?q?=ED=82=A4=20=EC=9E=AC=EC=82=AC=EC=9A=A9=20=EC=8B=9C=20=EB=B3=B5?= =?UTF-8?q?=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 판매자 앱 상품 관리 구현 중 발견. sellerDeleteProductCustomTextToken으로 슬롯을 지운 뒤 같은 tokenKey로 sellerUpsertProductCustomTextToken(tokenId 생략)을 부르면 soft-delete 행이 uk_product_custom_text_token(template_id, token_key)에 걸려 P2002 → 500. - repository upsertCustomTextToken 등록 경로: 같은 (template, tokenKey)의 삭제 슬롯이 있으면 그 행(같은 id)을 복구하며 입력값으로 갱신, 없으면 생성. 관리자 createOrRestoreTag·카테고리와 같은 방식. - 등록 경로는 템플릿 행을 FOR UPDATE로 잠가 같은 템플릿의 슬롯 생성을 직렬화. 잠금 없이는 같은 삭제 슬롯을 동시에 복구한 2건이 모두 성공한다(반증 확인). - unique 충돌(활성 키 중복·경쟁, 수정으로 삭제 슬롯 키로 변경)은 CUSTOM_TEXT_TOKEN_KEY_TAKEN(409)로 좁힘. 수정 경로를 삭제 키로 바꾸는 경우 오류로 두는 것은 관리자 태그·카테고리 이름 변경과 같은 정책. - 에러 카탈로그에 CUSTOM_TEXT_TOKEN_KEY_TAKEN 추가(파라미터 없는 고정 메시지라 RENDER_PARAMS 불요). - SDL은 sellerUpsertProductCustomTextToken description에 복구·충돌 동작만 추가. - 감사 로그는 기존 그대로 등록 경로 CREATE(복구도 CREATE, tokenId는 복구된 id). - 회귀 테스트 7건(real DB): 삭제 후 같은 키 재등록 → 같은 id 복구·필드 갱신·감사 / 활성 키 중복 → 409·무변경 / 수정으로 활성·삭제 키로 변경 → 409(2) / 다른 템플릿의 같은 키 무관 / 동시 등록 2건(새 키·삭제 키) → 1 성공 1 409(2). 복구 분기를 지우면 첫 케이스가 409로 실패(반증 확인). --- src/common/errors/error-catalog.ts | 4 + src/features/product/product-seller.graphql | 2 + .../repositories/product.repository.ts | 48 +++- ...uct-seller-custom-template.service.spec.ts | 219 ++++++++++++++++++ 4 files changed, 264 insertions(+), 9 deletions(-) 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..744a9ff4 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! 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', () => { From b371546a2de6c26af5ee753365a3b303a4e14338 Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Tue, 6 Oct 2026 08:29:48 +0900 Subject: [PATCH 2/2] =?UTF-8?q?docs:=20=EC=BB=A4=EC=8A=A4=ED=85=80=20?= =?UTF-8?q?=EB=AC=B8=EA=B5=AC=20=EC=8A=AC=EB=A1=AF=20=EC=A2=8C=ED=91=9C=20?= =?UTF-8?q?=EB=B9=84=EC=9C=A8=20=EA=B3=84=EC=95=BD=20=EB=AA=85=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 판매자 앱이 슬롯 좌표를 베이스 이미지 한 변을 10000으로 본 정수 비율로 저장한다 (SCALE = 10_000, x·y는 왼쪽 위 모서리, 가로·세로 모두 같은 한 변 기준). SDL description에 이 계약이 없어 클라이언트마다 단위를 추측해야 했음. - SellerCustomTextToken·SellerUpsertProductCustomTextTokenInput의 posX·posY·width·height description에 기준(왼쪽 위 모서리, 한 변 10000)과 범위(위치 0~10000, 크기 1~10000)를 명시. width·height는 기존 @Min(1) 검증이 있어 1부터로 적음. - 서버는 범위를 검사하지 않는다는 점을 posX에 함께 적음. 범위 검증 추가는 이번 범위 밖. - 코드·DTO 변경 없음. --- src/features/product/product-seller.graphql | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/features/product/product-seller.graphql b/src/features/product/product-seller.graphql index 744a9ff4..37eeb965 100644 --- a/src/features/product/product-seller.graphql +++ b/src/features/product/product-seller.graphql @@ -311,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 } @@ -864,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 }