diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 56bb825..36c5ab8 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -13,6 +13,7 @@ import { AuthModule } from '@/modules/auth/auth.module'; import { DiaryModule } from '@/modules/diary/diary.module'; import { HealthModule } from '@/modules/health/health.module'; import { PetModule } from '@/modules/pet/pet.module'; +import { PetCardModule } from '@/modules/pet-card/pet-card.module'; import { ScheduleModule } from '@/modules/schedule/schedule.module'; import { UploadModule } from '@/modules/upload/upload.module'; import { UserModule } from '@/modules/user/user.module'; @@ -70,6 +71,7 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수 PetModule, ScheduleModule, DiaryModule, + PetCardModule, ], providers: [ ShutdownLogger, diff --git a/backend/src/common/errors/error-codes.ts b/backend/src/common/errors/error-codes.ts index f26d906..8e7c99c 100644 --- a/backend/src/common/errors/error-codes.ts +++ b/backend/src/common/errors/error-codes.ts @@ -83,6 +83,11 @@ const DOMAIN_ERROR_CODES = { status: HttpStatus.CONFLICT, message: '오늘 기록은 이미 남겼어요. 내일 다시 작성할 수 있어요.', }, + // 펫 명함 — 한 사람이 최대 10장 + CARD_001: { + status: HttpStatus.CONFLICT, + message: '펫 명함은 최대 10장까지 만들 수 있어요.', + }, } as const satisfies Record; export const ERROR_CODES = { diff --git a/backend/src/migrations/1791707889244-AddPetCards.ts b/backend/src/migrations/1791707889244-AddPetCards.ts new file mode 100644 index 0000000..b2735f9 --- /dev/null +++ b/backend/src/migrations/1791707889244-AddPetCards.ts @@ -0,0 +1,45 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddPetCards1791707889244 implements MigrationInterface { + name = 'AddPetCards1791707889244'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "pet_cards" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "owner_id" uuid NOT NULL, "pet_id" uuid NOT NULL, "code" character varying(8) NOT NULL, "tint" character varying(7) NOT NULL, "tint2" character varying(7) NOT NULL, "name_bg" character varying(7), "name_bg_asset" character varying(120), "name_bg_file_id" uuid, "text_light" boolean NOT NULL DEFAULT false, "scrim" boolean NOT NULL DEFAULT false, "scrim_tone" character varying(5) NOT NULL DEFAULT 'light', "is_representative" boolean NOT NULL DEFAULT false, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "UQ_b0cdb9b6d8843b8ccdd8189475e" UNIQUE ("code"), CONSTRAINT "PK_cb97f59edc0aab4f315acd5c1fa" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_71ffe780c984a78a864a07465f" ON "pet_cards" ("owner_id") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "UQ_pet_cards_representative" ON "pet_cards" ("pet_id") WHERE "is_representative"`, + ); + await queryRunner.query( + `ALTER TABLE "pet_cards" ADD CONSTRAINT "FK_71ffe780c984a78a864a07465f8" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "pet_cards" ADD CONSTRAINT "FK_5d2fc9ac2b34f34dfaca776fc7f" FOREIGN KEY ("pet_id") REFERENCES "pets"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "pet_cards" ADD CONSTRAINT "FK_672dc0c78f4a9ffde6239f1db86" FOREIGN KEY ("name_bg_file_id") REFERENCES "files"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "pet_cards" DROP CONSTRAINT "FK_672dc0c78f4a9ffde6239f1db86"`, + ); + await queryRunner.query( + `ALTER TABLE "pet_cards" DROP CONSTRAINT "FK_5d2fc9ac2b34f34dfaca776fc7f"`, + ); + await queryRunner.query( + `ALTER TABLE "pet_cards" DROP CONSTRAINT "FK_71ffe780c984a78a864a07465f8"`, + ); + await queryRunner.query( + `DROP INDEX "public"."UQ_pet_cards_representative"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_71ffe780c984a78a864a07465f"`, + ); + await queryRunner.query(`DROP TABLE "pet_cards"`); + } +} diff --git a/backend/src/modules/pet-card/card-code.spec.ts b/backend/src/modules/pet-card/card-code.spec.ts new file mode 100644 index 0000000..478f1aa --- /dev/null +++ b/backend/src/modules/pet-card/card-code.spec.ts @@ -0,0 +1,14 @@ +import { formatCardCode, randomCardCode } from './card-code'; + +describe('명함 코드', () => { + it('7자리를 0000-000 형식으로 쓴다', () => { + expect(formatCardCode(4_281_170)).toBe('4281-170'); + expect(formatCardCode(42)).toBe('0000-042'); + }); + + it('무작위 코드도 같은 형식이다', () => { + for (let i = 0; i < 20; i += 1) { + expect(randomCardCode()).toMatch(/^\d{4}-\d{3}$/); + } + }); +}); diff --git a/backend/src/modules/pet-card/card-code.ts b/backend/src/modules/pet-card/card-code.ts new file mode 100644 index 0000000..5a784d4 --- /dev/null +++ b/backend/src/modules/pet-card/card-code.ts @@ -0,0 +1,17 @@ +import { randomInt } from 'crypto'; + +/** 명함 코드 자릿수 — "0000-000" (앱이 보여 주는 형식) */ +const CODE_DIGITS = 7; +const CODE_SPACE = 10 ** CODE_DIGITS; +const CODE_HEAD = 4; + +/** 0 ~ 9,999,999 → "0000-000" */ +export function formatCardCode(value: number): string { + const digits = String(value % CODE_SPACE).padStart(CODE_DIGITS, '0'); + return `${digits.slice(0, CODE_HEAD)}-${digits.slice(CODE_HEAD)}`; +} + +/** 새 명함 코드 — 추측하기 어렵게 무작위로 뽑는다 (겹치면 호출한 쪽이 다시 뽑는다) */ +export function randomCardCode(): string { + return formatCardCode(randomInt(0, CODE_SPACE)); +} diff --git a/backend/src/modules/pet-card/dto/pet-card.dto.ts b/backend/src/modules/pet-card/dto/pet-card.dto.ts new file mode 100644 index 0000000..d04a04c --- /dev/null +++ b/backend/src/modules/pet-card/dto/pet-card.dto.ts @@ -0,0 +1,168 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + IsBoolean, + IsIn, + IsOptional, + IsUUID, + Matches, + ValidateNested, +} from 'class-validator'; + +import { IsHexColor } from '@/common/validators'; +import { SCRIM_TONES } from '../entities/pet-card.entity'; +import type { PetCard, ScrimTone } from '../entities/pet-card.entity'; + +/** 앱에 들어 있는 배경 그림 경로 — 경로 탈출·다른 형식을 막는다 */ +const ASSET_PATH_PATTERN = + /^assets\/[A-Za-z0-9_-]+(\/[A-Za-z0-9_-]+)*\.(png|jpg|jpeg|webp)$/; + +/** 명함 꾸미기 값 (앱의 CardStyle) — 배경은 색·그림·사진 중 하나만 */ +export class CardStyleDto { + @ApiProperty({ + description: '상단 그라데이션 첫 색 (#RRGGBB)', + example: '#FF9B6A', + }) + @IsHexColor() + tint!: string; + + @ApiProperty({ + description: '상단 그라데이션 둘째 색 (#RRGGBB)', + example: '#FFC59B', + }) + @IsHexColor() + tint2!: string; + + @ApiPropertyOptional({ + description: '배경색 (#RRGGBB) — 없으면 흰색', + nullable: true, + type: String, + }) + @IsOptional() + @IsHexColor() + nameBg?: string | null; + + @ApiPropertyOptional({ + description: '앱에 들어 있는 배경 그림 경로 (assets/…)', + nullable: true, + type: String, + }) + @IsOptional() + @Matches(ASSET_PATH_PATTERN, { + message: '배경 그림 정보가 올바르지 않습니다.', + }) + nameBgAsset?: string | null; + + @ApiPropertyOptional({ + description: '배경 사진 파일 ID (POST /api/files 로 올린 이미지)', + nullable: true, + type: String, + }) + @IsOptional() + @IsUUID('4', { message: '사진 정보가 올바르지 않습니다.' }) + nameBgFileId?: string | null; + + @ApiProperty({ description: '밝은 글자 (어두운 배경용)', example: false }) + @IsBoolean() + textLight!: boolean; + + @ApiProperty({ description: '글자 뒤 막 덮기', example: false }) + @IsBoolean() + scrim!: boolean; + + @ApiProperty({ enum: SCRIM_TONES, example: 'light' }) + @IsIn(SCRIM_TONES, { message: '막 색이 올바르지 않습니다.' }) + scrimTone!: ScrimTone; +} + +/** 새 명함 */ +export class CreatePetCardDto { + @ApiProperty({ description: '명함을 만들 아이 ID' }) + @IsUUID('4', { message: '아이 정보가 올바르지 않습니다.' }) + petId!: string; + + @ApiProperty({ type: CardStyleDto }) + @ValidateNested() + @Type(() => CardStyleDto) + style!: CardStyleDto; +} + +/** 명함 꾸미기 바꾸기 — 꾸미기 값을 통째로 바꾼다 */ +export class UpdatePetCardDto { + @ApiProperty({ type: CardStyleDto }) + @ValidateNested() + @Type(() => CardStyleDto) + style!: CardStyleDto; +} + +/** 대표 명함 지정 */ +export class SetRepresentativeDto { + @ApiProperty({ description: '대표로 둘 명함 ID (그 아이의 명함이어야 한다)' }) + @IsUUID('4', { message: '명함 정보가 올바르지 않습니다.' }) + cardId!: string; +} + +/** 명함 꾸미기 응답 */ +export class CardStyleResponseDto { + @ApiProperty() tint!: string; + @ApiProperty() tint2!: string; + @ApiProperty({ nullable: true, type: String }) nameBg!: string | null; + @ApiProperty({ nullable: true, type: String }) nameBgAsset!: string | null; + @ApiProperty({ nullable: true, type: String }) nameBgFileId!: string | null; + @ApiProperty() textLight!: boolean; + @ApiProperty() scrim!: boolean; + @ApiProperty({ enum: SCRIM_TONES }) scrimTone!: ScrimTone; +} + +/** 펫 명함 응답 (앱의 PetCard) */ +export class PetCardResponseDto { + @ApiProperty() id!: string; + @ApiProperty() petId!: string; + + @ApiProperty({ description: '명함 코드', example: '0428-117' }) + code!: string; + + @ApiProperty({ type: CardStyleResponseDto }) + style!: CardStyleResponseDto; + + @ApiProperty({ description: '아이의 대표 명함인지' }) + isRepresentative!: boolean; + + static from(card: PetCard): PetCardResponseDto { + return { + id: card.id, + petId: card.petId, + code: card.code, + style: { + tint: card.tint, + tint2: card.tint2, + nameBg: card.nameBg, + nameBgAsset: card.nameBgAsset, + nameBgFileId: card.nameBgFileId, + textLight: card.textLight, + scrim: card.scrim, + scrimTone: card.scrimTone, + }, + isRepresentative: card.isRepresentative, + }; + } +} + +/** + * 꾸미기 DTO → 컬럼 값. 배경은 하나만 남긴다 — 사진 > 그림 > 색 순으로 우선한다 + * (앱도 하나를 고르면 나머지를 비운다). + */ +export function toStyleColumns(style: CardStyleDto) { + const file = style.nameBgFileId ?? null; + const asset = file ? null : (style.nameBgAsset ?? null); + return { + tint: style.tint, + tint2: style.tint2, + nameBg: file || asset ? null : (style.nameBg ?? null), + nameBgAsset: asset, + nameBgFileId: file, + textLight: style.textLight, + scrim: style.scrim, + scrimTone: style.scrimTone, + }; +} diff --git a/backend/src/modules/pet-card/entities/pet-card.entity.ts b/backend/src/modules/pet-card/entities/pet-card.entity.ts new file mode 100644 index 0000000..0f884a9 --- /dev/null +++ b/backend/src/modules/pet-card/entities/pet-card.entity.ts @@ -0,0 +1,101 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +import { Pet } from '@/modules/pet/entities/pet.entity'; +import { StoredFile } from '@/modules/upload/entities/stored-file.entity'; +import { User } from '@/modules/user/entities/user.entity'; + +/** 한 사람이 만들 수 있는 펫 명함 수 — 앱의 kMaxPetCards */ +export const MAX_PET_CARDS = 10; + +/** 글자 뒤에 덮는 막의 색 (앱의 ScrimTone) */ +export const SCRIM_TONES = ['light', 'dark'] as const; +export type ScrimTone = (typeof SCRIM_TONES)[number]; + +/** + * 펫 명함 — 어느 아이의 명함인지 + 꾸미기 값(앱의 CardStyle). + * 이름·품종 등 내용은 아이 정보를 그대로 쓰고 모양만 저장한다. + * 배경은 색(nameBg) · 앱에 들어 있는 그림(nameBgAsset) · 올린 사진(nameBgFileId) 중 하나만 쓴다. + */ +@Entity('pet_cards') +// 아이마다 대표 명함은 하나 +@Index('UQ_pet_cards_representative', ['petId'], { + unique: true, + where: '"is_representative"', +}) +export class PetCard { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'owner_id' }) + owner?: User; + + @Index() + @Column({ type: 'uuid' }) + ownerId!: string; + + /** 아이를 지우면 그 아이의 명함도 지운다 */ + @ManyToOne(() => Pet, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'pet_id' }) + pet?: Pet; + + @Column({ type: 'uuid' }) + petId!: string; + + /** 명함 코드 "0000-000" — 서버가 발급한다. 명함 받기에서 이 코드로 찾는다 */ + @Column({ type: 'varchar', length: 8, unique: true }) + code!: string; + + /** 카드 상단 그라데이션 두 색 `#RRGGBB` */ + @Column({ type: 'varchar', length: 7 }) + tint!: string; + + @Column({ type: 'varchar', length: 7 }) + tint2!: string; + + /** 배경색 — null 이면 흰색 */ + @Column({ type: 'varchar', length: 7, nullable: true }) + nameBg!: string | null; + + /** 앱에 들어 있는 배경 그림 경로 (assets/…) */ + @Column({ type: 'varchar', length: 120, nullable: true }) + nameBgAsset!: string | null; + + /** 올린 배경 사진 — 참조 중인 파일은 지울 수 없게 둔다 (UploadService.releaseIfUnused) */ + @ManyToOne(() => StoredFile, { nullable: true, onDelete: 'NO ACTION' }) + @JoinColumn({ name: 'name_bg_file_id' }) + nameBgFile?: StoredFile | null; + + @Column({ type: 'uuid', nullable: true }) + nameBgFileId!: string | null; + + /** 밝은 글자 (어두운 배경용) */ + @Column({ type: 'boolean', default: false }) + textLight!: boolean; + + /** 글자 뒤에 은은한 막을 덮을지 */ + @Column({ type: 'boolean', default: false }) + scrim!: boolean; + + @Column({ type: 'varchar', length: 5, default: 'light' }) + scrimTone!: ScrimTone; + + /** 아이의 대표 명함인지 */ + @Column({ type: 'boolean', default: false }) + isRepresentative!: boolean; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt!: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt!: Date; +} diff --git a/backend/src/modules/pet-card/pet-card.module.ts b/backend/src/modules/pet-card/pet-card.module.ts new file mode 100644 index 0000000..939c3f0 --- /dev/null +++ b/backend/src/modules/pet-card/pet-card.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { PetModule } from '@/modules/pet/pet.module'; +import { UploadModule } from '@/modules/upload/upload.module'; +import { PetCard } from './entities/pet-card.entity'; +import { PetCardService } from './pet-card.service'; +import { + PetCardsController, + RepresentativeCardController, +} from './pet-cards.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([PetCard]), PetModule, UploadModule], + controllers: [PetCardsController, RepresentativeCardController], + providers: [PetCardService], +}) +export class PetCardModule {} diff --git a/backend/src/modules/pet-card/pet-card.service.spec.ts b/backend/src/modules/pet-card/pet-card.service.spec.ts new file mode 100644 index 0000000..fe77d58 --- /dev/null +++ b/backend/src/modules/pet-card/pet-card.service.spec.ts @@ -0,0 +1,98 @@ +import { DataSource, EntityManager, Repository } from 'typeorm'; + +import { PetService } from '@/modules/pet/pet.service'; +import { UploadService } from '@/modules/upload/upload.service'; +import { CardStyleDto, toStyleColumns } from './dto/pet-card.dto'; +import { PetCard } from './entities/pet-card.entity'; +import { PetCardService } from './pet-card.service'; + +const style = (overrides: Partial = {}): CardStyleDto => ({ + tint: '#FF9B6A', + tint2: '#FFC59B', + textLight: false, + scrim: false, + scrimTone: 'light', + ...overrides, +}); + +describe('toStyleColumns', () => { + it('배경은 하나만 남긴다 — 사진 > 그림 > 색', () => { + expect( + toStyleColumns( + style({ + nameBg: '#FFFFFF', + nameBgAsset: 'assets/a.png', + nameBgFileId: 'f', + }), + ), + ).toMatchObject({ nameBg: null, nameBgAsset: null, nameBgFileId: 'f' }); + expect( + toStyleColumns(style({ nameBg: '#FFFFFF', nameBgAsset: 'assets/a.png' })), + ).toMatchObject({ nameBg: null, nameBgAsset: 'assets/a.png' }); + expect(toStyleColumns(style({ nameBg: '#FFFFFF' }))).toMatchObject({ + nameBg: '#FFFFFF', + nameBgAsset: null, + nameBgFileId: null, + }); + }); +}); + +describe('PetCardService', () => { + const cards = { findOneBy: jest.fn(), update: jest.fn() }; + const manager = { update: jest.fn(), find: jest.fn() }; + const dataSource = { + transaction: jest.fn((work: (m: EntityManager) => Promise) => + work(manager as unknown as EntityManager), + ), + }; + const pets = { registerListener: jest.fn(), findOwned: jest.fn() }; + const uploads = { assertOwnedImage: jest.fn(), releaseIfUnused: jest.fn() }; + const service = new PetCardService( + cards as unknown as Repository, + dataSource as unknown as DataSource, + pets as unknown as PetService, + uploads as unknown as UploadService, + ); + + beforeEach(() => jest.clearAllMocks()); + + it('다른 아이의 명함은 대표로 정할 수 없다 (VAL_001)', async () => { + cards.findOneBy.mockResolvedValue({ id: 'c1', petId: 'pet-b' }); + + await expect( + service.setRepresentative('o', 'pet-a', 'c1'), + ).rejects.toMatchObject({ code: 'VAL_001' }); + expect(dataSource.transaction).not.toHaveBeenCalled(); + }); + + it('대표를 바꾸면 그 아이의 기존 대표를 먼저 푼다', async () => { + cards.findOneBy.mockResolvedValue({ id: 'c1', petId: 'pet-a' }); + + await service.setRepresentative('o', 'pet-a', 'c1'); + + expect(manager.update).toHaveBeenNthCalledWith( + 1, + PetCard, + { petId: 'pet-a', isRepresentative: true }, + { isRepresentative: false }, + ); + expect(manager.update).toHaveBeenNthCalledWith( + 2, + PetCard, + { id: 'c1' }, + { isRepresentative: true }, + ); + }); + + it('아이를 지울 때 그 아이 명함의 배경 사진을 정리 대상으로 돌려준다', async () => { + manager.find.mockResolvedValue([{ nameBgFileId: 'bg-1' }]); + + await expect( + service.onPetDeleting({ + ownerId: 'o', + petId: 'pet-a', + manager: manager as unknown as EntityManager, + }), + ).resolves.toEqual(['bg-1']); + }); +}); diff --git a/backend/src/modules/pet-card/pet-card.service.ts b/backend/src/modules/pet-card/pet-card.service.ts new file mode 100644 index 0000000..02876ee --- /dev/null +++ b/backend/src/modules/pet-card/pet-card.service.ts @@ -0,0 +1,194 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; +import { DataSource, IsNull, Not, Repository } from 'typeorm'; + +import { keysetPaginate, mapPage } from '@/common/dto/keyset-pagination'; +import { + CursorPaginationQueryDto, + PaginatedResult, +} from '@/common/dto/pagination.dto'; +import { BusinessException } from '@/common/errors/business.exception'; +import { + PetLifecycleEvent, + PetLifecycleListener, +} from '@/modules/pet/pet-lifecycle'; +import { PetService } from '@/modules/pet/pet.service'; +import { UploadService } from '@/modules/upload/upload.service'; +import { isUniqueViolation } from '@/shared/utils/db-error'; +import { randomCardCode } from './card-code'; +import { + CreatePetCardDto, + PetCardResponseDto, + UpdatePetCardDto, + toStyleColumns, +} from './dto/pet-card.dto'; +import { MAX_PET_CARDS, PetCard } from './entities/pet-card.entity'; + +/** 명함 코드가 겹치면 다시 뽑는 횟수 — 천만 개 중에서 뽑으므로 거의 겹치지 않는다 */ +const CODE_RETRY = 5; + +// 펫 명함 — 만들기·꾸미기·삭제·대표 명함 +@Injectable() +export class PetCardService implements OnModuleInit, PetLifecycleListener { + constructor( + @InjectRepository(PetCard) + private readonly cards: Repository, + @InjectDataSource() + private readonly dataSource: DataSource, + private readonly pets: PetService, + private readonly uploads: UploadService, + ) {} + + onModuleInit(): void { + this.pets.registerListener(this); + } + + /** 내 명함 목록 (만든 순) — 추억 보관함의 아이 명함도 함께 온다 */ + async list( + ownerId: string, + query: CursorPaginationQueryDto, + ): Promise> { + const qb = this.cards + .createQueryBuilder('card') + .where('card.owner_id = :ownerId', { ownerId }); + const page = await keysetPaginate( + qb, + [ + { + column: 'card.created_at', + cast: 'timestamptz', + read: (c) => c.createdAt.toISOString(), + }, + { column: 'card.id', cast: 'uuid', read: (c) => c.id }, + ], + query, + ); + return mapPage(page, (c) => PetCardResponseDto.from(c)); + } + + /** 새 명함 — 최대 10장 (CARD_001). 코드는 서버가 발급한다 */ + async create( + ownerId: string, + dto: CreatePetCardDto, + ): Promise { + await this.pets.findOwned(ownerId, dto.petId); + await this.uploads.assertOwnedImage(dto.style.nameBgFileId, ownerId); + + for (let attempt = 1; ; attempt += 1) { + try { + return await this.insertCard(ownerId, dto); + } catch (error) { + // 코드가 겹쳤으면 다시 뽑는다 (그 밖의 유니크 위반은 없다) + if (!isUniqueViolation(error) || attempt >= CODE_RETRY) throw error; + } + } + } + + async update( + ownerId: string, + cardId: string, + dto: UpdatePetCardDto, + ): Promise { + const card = await this.findOwnedCard(ownerId, cardId); + await this.uploads.assertOwnedImage(dto.style.nameBgFileId, ownerId); + + const previousBg = card.nameBgFileId; + const saved = await this.cards.save( + this.cards.merge(card, toStyleColumns(dto.style)), + ); + if (previousBg !== saved.nameBgFileId) { + await this.uploads.releaseIfUnused([previousBg]); + } + return PetCardResponseDto.from(saved); + } + + /** 명함 삭제 — 대표 명함이었으면 대표 지정도 함께 사라진다 */ + async remove(ownerId: string, cardId: string): Promise { + const card = await this.findOwnedCard(ownerId, cardId); + await this.cards.delete({ id: card.id }); + await this.uploads.releaseIfUnused([card.nameBgFileId]); + } + + /** 아이의 대표 명함 정하기 — 그 아이의 명함이어야 한다 */ + async setRepresentative( + ownerId: string, + petId: string, + cardId: string, + ): Promise { + const card = await this.findOwnedCard(ownerId, cardId); + if (card.petId !== petId) { + throw new BusinessException('VAL_001', { + context: { reason: 'card-of-other-pet', petId, cardId }, + }); + } + + await this.dataSource.transaction(async (manager) => { + await manager.update( + PetCard, + { petId, isRepresentative: true }, + { isRepresentative: false }, + ); + await manager.update(PetCard, { id: cardId }, { isRepresentative: true }); + }); + } + + /** 대표 명함 지정 풀기 — 앱은 그 아이의 첫 명함을 보여 준다 */ + async clearRepresentative(ownerId: string, petId: string): Promise { + await this.pets.findOwned(ownerId, petId); + await this.cards.update( + { petId, ownerId, isRepresentative: true }, + { isRepresentative: false }, + ); + } + + /** 아이를 지우면 명함은 FK 로 함께 지워진다 — 배경 사진은 커밋 뒤 정리하도록 돌려준다 */ + async onPetDeleting({ + petId, + manager, + }: PetLifecycleEvent): Promise> { + const withPhoto = await manager.find(PetCard, { + where: { petId, nameBgFileId: Not(IsNull()) }, + select: { nameBgFileId: true }, + }); + return withPhoto.map((c) => c.nameBgFileId); + } + + private async insertCard( + ownerId: string, + dto: CreatePetCardDto, + ): Promise { + return this.dataSource.transaction(async (manager) => { + // 동시에 만들어 한도를 넘지 않도록 회원의 명함을 잠그고 센다 + const existing = await manager.find(PetCard, { + where: { ownerId }, + select: { id: true }, + lock: { mode: 'pessimistic_write' }, + }); + if (existing.length >= MAX_PET_CARDS) { + throw new BusinessException('CARD_001', { + context: { count: existing.length }, + }); + } + + const card = await manager.save( + manager.create(PetCard, { + ownerId, + petId: dto.petId, + code: randomCardCode(), + ...toStyleColumns(dto.style), + isRepresentative: false, + }), + ); + return PetCardResponseDto.from(card); + }); + } + + private async findOwnedCard( + ownerId: string, + cardId: string, + ): Promise { + const card = await this.cards.findOneBy({ id: cardId, ownerId }); + if (!card) throw new BusinessException('RES_001', { context: { cardId } }); + return card; + } +} diff --git a/backend/src/modules/pet-card/pet-cards.controller.ts b/backend/src/modules/pet-card/pet-cards.controller.ts new file mode 100644 index 0000000..c523219 --- /dev/null +++ b/backend/src/modules/pet-card/pet-cards.controller.ts @@ -0,0 +1,138 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Post, + Put, + Query, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiNoContentResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; + +import { + ApiErrorResponse, + ApiPaginatedResponse, + ApiStandardResponse, +} from '@/common/decorators/api-standard-response.decorator'; +import { CurrentUser } from '@/common/decorators/current-user.decorator'; +import type { AuthUser } from '@/common/decorators/current-user.decorator'; +import { SkipTransform } from '@/common/decorators/skip-transform.decorator'; +import { + CursorPaginationQueryDto, + PaginatedResult, +} from '@/common/dto/pagination.dto'; +import { + CreatePetCardDto, + PetCardResponseDto, + SetRepresentativeDto, + UpdatePetCardDto, +} from './dto/pet-card.dto'; +import { PetCardService } from './pet-card.service'; + +// 펫 명함 — 라우팅과 입출력만 담당, 비즈니스 로직은 모두 service 위임 +@ApiTags('PetCard') +@ApiBearerAuth() +@ApiErrorResponse('AUTH_001', '로그인 필요') +@Controller('pet-cards') +export class PetCardsController { + constructor(private readonly cards: PetCardService) {} + + @Get() + @ApiOperation({ summary: '내 명함 목록 (만든 순, 커서 페이지네이션)' }) + @ApiPaginatedResponse(PetCardResponseDto, '명함 목록') + @ApiErrorResponse('VAL_001', '페이지네이션 파라미터 오류') + list( + @CurrentUser() user: AuthUser, + @Query() query: CursorPaginationQueryDto, + ): Promise> { + return this.cards.list(user.userId, query); + } + + @Post() + @ApiOperation({ summary: '새 명함 (최대 10장, 코드는 서버가 발급)' }) + @ApiStandardResponse(PetCardResponseDto, { + status: 201, + description: '만든 명함', + }) + @ApiErrorResponse('CARD_001', '명함이 가득 참 (최대 10장)') + @ApiErrorResponse('RES_001', '아이를 찾을 수 없음') + @ApiErrorResponse('VAL_001', '입력값 검증 실패') + create( + @CurrentUser() user: AuthUser, + @Body() dto: CreatePetCardDto, + ): Promise { + return this.cards.create(user.userId, dto); + } + + @Put(':id') + @ApiOperation({ summary: '명함 꾸미기 바꾸기' }) + @ApiStandardResponse(PetCardResponseDto, { description: '바꾼 명함' }) + @ApiErrorResponse('RES_001', '명함을 찾을 수 없음') + @ApiErrorResponse('VAL_001', '입력값 검증 실패') + update( + @CurrentUser() user: AuthUser, + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdatePetCardDto, + ): Promise { + return this.cards.update(user.userId, id, dto); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @SkipTransform() // 204 — 본문이 없다 + @ApiOperation({ summary: '명함 삭제' }) + @ApiNoContentResponse({ description: '삭제 완료' }) + @ApiErrorResponse('RES_001', '명함을 찾을 수 없음') + async remove( + @CurrentUser() user: AuthUser, + @Param('id', ParseUUIDPipe) id: string, + ): Promise { + await this.cards.remove(user.userId, id); + } +} + +// 아이의 대표 명함 — 아이의 하위 리소스로 둔다 +@ApiTags('PetCard') +@ApiBearerAuth() +@ApiErrorResponse('AUTH_001', '로그인 필요') +@Controller('pets') +export class RepresentativeCardController { + constructor(private readonly cards: PetCardService) {} + + @Put(':petId/representative-card') + @HttpCode(HttpStatus.NO_CONTENT) + @SkipTransform() // 204 — 본문이 없다 + @ApiOperation({ summary: '대표 명함 정하기' }) + @ApiNoContentResponse({ description: '대표 명함으로 정함' }) + @ApiErrorResponse('RES_001', '명함을 찾을 수 없음') + @ApiErrorResponse('VAL_001', '그 아이의 명함이 아님') + async set( + @CurrentUser() user: AuthUser, + @Param('petId', ParseUUIDPipe) petId: string, + @Body() dto: SetRepresentativeDto, + ): Promise { + await this.cards.setRepresentative(user.userId, petId, dto.cardId); + } + + @Delete(':petId/representative-card') + @HttpCode(HttpStatus.NO_CONTENT) + @SkipTransform() // 204 — 본문이 없다 + @ApiOperation({ summary: '대표 명함 지정 풀기' }) + @ApiNoContentResponse({ description: '지정을 풀었음' }) + @ApiErrorResponse('RES_001', '아이를 찾을 수 없음') + async clear( + @CurrentUser() user: AuthUser, + @Param('petId', ParseUUIDPipe) petId: string, + ): Promise { + await this.cards.clearRepresentative(user.userId, petId); + } +} diff --git a/flutter/lib/core/api/error_lexicon.g.dart b/flutter/lib/core/api/error_lexicon.g.dart index 6cbe568..d27e14e 100644 --- a/flutter/lib/core/api/error_lexicon.g.dart +++ b/flutter/lib/core/api/error_lexicon.g.dart @@ -13,6 +13,7 @@ const Map kErrorCodeMessages = { 'FILE_002': '파일 크기가 허용 범위를 초과했습니다.', 'DIARY_001': '다이어리는 최대 6권까지 만들 수 있어요.', 'DIARY_002': '오늘 기록은 이미 남겼어요. 내일 다시 작성할 수 있어요.', + 'CARD_001': '펫 명함은 최대 10장까지 만들 수 있어요.', }; /// 매핑 실패 시 사용하는 폴백 코드 diff --git a/frontend/src/shared/constants/error-codes.generated.ts b/frontend/src/shared/constants/error-codes.generated.ts index 9cf42b2..6fe83d5 100644 --- a/frontend/src/shared/constants/error-codes.generated.ts +++ b/frontend/src/shared/constants/error-codes.generated.ts @@ -13,6 +13,7 @@ export const ERROR_CODE_MESSAGES = { FILE_002: '파일 크기가 허용 범위를 초과했습니다.', DIARY_001: '다이어리는 최대 6권까지 만들 수 있어요.', DIARY_002: '오늘 기록은 이미 남겼어요. 내일 다시 작성할 수 있어요.', + CARD_001: '펫 명함은 최대 10장까지 만들 수 있어요.', } as const export type ErrorCode = keyof typeof ERROR_CODE_MESSAGES