feat(backend): 명함 받기 API — 코드로 미리보기·보관함 저장·삭제

다른 회원의 펫 명함을 코드(QR)로 받아 보관함에 두는 기능이다.

- PUT /pet-cards/:id/share-mode: 공유 시트를 열 때 앱이 공유 모양(간략히·상세히)을 알린다.
  받는 사람에게는 그 모양의 정보만 간다 — 간략히로 공유했는데 병원·건강 정보가 새지 않게. 기본은 간략히
- GET /shared-pet-cards/:code (+ /images/:kind): 받기 전 미리보기. 사진은 코드를 아는 회원에게 그 명함 사진만
- POST·GET·DELETE /received-cards: 보관함. 받는 순간의 사본을 저장하고 사진도 받는 사람 소유로 복사한다
  (보낸 사람이 아이를 지우거나 사진을 바꿔도 받은 명함은 그대로)
- 코드로 찾는 엔드포인트는 분당 30회로 막는다 (코드 추측 방지)
- CARD_002(코드에 맞는 명함 없음)·CARD_003(내 명함) 추가, 프론트·Flutter 사전 재생성

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 19:29:17 +09:00
parent 5331362215
commit f045e1599f
18 changed files with 1035 additions and 17 deletions
+10
View File
@@ -88,6 +88,16 @@ const DOMAIN_ERROR_CODES = {
status: HttpStatus.CONFLICT,
message: '펫 명함은 최대 10장까지 만들 수 있어요.',
},
// 명함 받기 — 코드에 맞는 명함이 없다
CARD_002: {
status: HttpStatus.NOT_FOUND,
message: '명함을 찾을 수 없어요. 명함 코드를 다시 확인해 주세요.',
},
// 명함 받기 — 내 명함은 받을 수 없다
CARD_003: {
status: HttpStatus.BAD_REQUEST,
message: '내 명함은 받을 수 없어요.',
},
} as const satisfies Record<string, ErrorCodeDefinition>;
export const ERROR_CODES = {
@@ -0,0 +1,55 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddReceivedCards1791714286898 implements MigrationInterface {
name = 'AddReceivedCards1791714286898';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "received_cards" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "owner_id" uuid NOT NULL, "source_card_id" uuid, "code" character varying(8) NOT NULL, "mode" character varying(6) NOT NULL, "sender_nickname" character varying(20), "sender_photo_file_id" uuid, "pet" jsonb NOT NULL, "pet_photo_file_id" uuid, "style" jsonb NOT NULL, "background_file_id" uuid, "note" character varying(200), "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_43872dae929b5dd22d6b81e744d" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_3b8508528d21833ebec7c3e7b1" ON "received_cards" ("owner_id") `,
);
await queryRunner.query(
`ALTER TABLE "pet_cards" ADD "share_mode" character varying(6) NOT NULL DEFAULT 'simple'`,
);
await queryRunner.query(
`ALTER TABLE "received_cards" ADD CONSTRAINT "FK_3b8508528d21833ebec7c3e7b1b" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "received_cards" ADD CONSTRAINT "FK_65e8b70115d2de13ce0380b8ade" FOREIGN KEY ("source_card_id") REFERENCES "pet_cards"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "received_cards" ADD CONSTRAINT "FK_ea06e251a9ed53e63e66ef5bb65" FOREIGN KEY ("sender_photo_file_id") REFERENCES "files"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "received_cards" ADD CONSTRAINT "FK_21733fe1b0dfce3e27726b322e6" FOREIGN KEY ("pet_photo_file_id") REFERENCES "files"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "received_cards" ADD CONSTRAINT "FK_549a5fe176831cb7cebb29f9e22" FOREIGN KEY ("background_file_id") REFERENCES "files"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "received_cards" DROP CONSTRAINT "FK_549a5fe176831cb7cebb29f9e22"`,
);
await queryRunner.query(
`ALTER TABLE "received_cards" DROP CONSTRAINT "FK_21733fe1b0dfce3e27726b322e6"`,
);
await queryRunner.query(
`ALTER TABLE "received_cards" DROP CONSTRAINT "FK_ea06e251a9ed53e63e66ef5bb65"`,
);
await queryRunner.query(
`ALTER TABLE "received_cards" DROP CONSTRAINT "FK_65e8b70115d2de13ce0380b8ade"`,
);
await queryRunner.query(
`ALTER TABLE "received_cards" DROP CONSTRAINT "FK_3b8508528d21833ebec7c3e7b1b"`,
);
await queryRunner.query(`ALTER TABLE "pet_cards" DROP COLUMN "share_mode"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_3b8508528d21833ebec7c3e7b1"`,
);
await queryRunner.query(`DROP TABLE "received_cards"`);
}
}
@@ -0,0 +1,51 @@
import type { Pet } from '@/modules/pet/entities/pet.entity';
import { petSnapshot } from './card-snapshot';
const pet = {
name: '콩이',
species: '강아지',
breed: '말티즈',
gender: 'male',
birthDate: '2021-09-14',
weight: 3.2,
weightUnit: 'kg',
microchip: '410123456789012',
tint: '#FF9B6A',
neutered: true,
bloodType: 'DEA 1.1',
vet: '드림 동물병원',
memo: '간식 좋아함',
allergies: ['닭고기'],
conditions: [],
watchConditions: ['슬개골'],
} as unknown as Pet;
describe('petSnapshot', () => {
it('간략히로 공유하면 건강 정보·병원·메모를 넘기지 않는다', () => {
const snapshot = petSnapshot(pet, 'simple');
expect(snapshot).toEqual({
name: '콩이',
species: '강아지',
breed: '말티즈',
gender: 'male',
birthDate: '2021-09-14',
weight: 3.2,
weightUnit: 'kg',
microchip: '410123456789012',
tint: '#FF9B6A',
});
expect(JSON.stringify(snapshot)).not.toContain('드림 동물병원');
});
it('상세히로 공유하면 상세 명함의 정보까지 넘긴다', () => {
expect(petSnapshot(pet, 'detail')).toMatchObject({
neutered: true,
bloodType: 'DEA 1.1',
vet: '드림 동물병원',
memo: '간식 좋아함',
allergies: ['닭고기'],
watchConditions: ['슬개골'],
});
});
});
@@ -0,0 +1,51 @@
// 공유할 명함 정보 만들기 — 부수효과 없는 순수 함수
import type { Pet } from '@/modules/pet/entities/pet.entity';
import type { CardShareMode, PetCard } from './entities/pet-card.entity';
import type {
SharedCardStyle,
SharedPetSnapshot,
} from './entities/received-card.entity';
/**
* 공유한 모양에 맞춰 아이 정보를 잘라 낸다.
* 간략히 명함에 보이지 않는 정보(건강·병원·메모)는 상세히로 공유했을 때만 넘긴다 — 받는 사람에게 더 보여 주지 않는다.
*/
export function petSnapshot(pet: Pet, mode: CardShareMode): SharedPetSnapshot {
const basics: SharedPetSnapshot = {
name: pet.name,
species: pet.species,
breed: pet.breed,
gender: pet.gender,
birthDate: pet.birthDate,
weight: pet.weight,
weightUnit: pet.weightUnit,
microchip: pet.microchip,
tint: pet.tint,
};
if (mode === 'simple') return basics;
return {
...basics,
neutered: pet.neutered,
bloodType: pet.bloodType,
vet: pet.vet,
memo: pet.memo,
allergies: pet.allergies,
conditions: pet.conditions,
watchConditions: pet.watchConditions,
};
}
/** 명함의 꾸미기 값 — 배경 사진은 따로 다룬다 */
export function cardStyleSnapshot(card: PetCard): SharedCardStyle {
return {
tint: card.tint,
tint2: card.tint2,
nameBg: card.nameBg,
nameBgAsset: card.nameBgAsset,
textLight: card.textLight,
scrim: card.scrim,
scrimTone: card.scrimTone,
};
}
@@ -0,0 +1,138 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import {
IsIn,
IsOptional,
IsString,
Matches,
MaxLength,
} from 'class-validator';
import { CARD_SHARE_MODES, SCRIM_TONES } from '../entities/pet-card.entity';
import type { CardShareMode, ScrimTone } from '../entities/pet-card.entity';
import { RECEIVED_NOTE_MAX_LENGTH } from '../entities/received-card.entity';
import type {
ReceivedCard,
SharedCardStyle,
SharedPetSnapshot,
} from '../entities/received-card.entity';
/** 명함 코드 "0000-000" */
export const CARD_CODE_PATTERN = /^\d{4}-\d{3}$/;
/** 공유한 모양 정하기 — 공유 시트를 열 때 앱이 보낸다 */
export class ShareModeDto {
@ApiProperty({ enum: CARD_SHARE_MODES, example: 'detail' })
@IsIn(CARD_SHARE_MODES, { message: '공유 모양이 올바르지 않습니다.' })
mode!: CardShareMode;
}
/** 명함 받기 — 코드와 나의 메모 */
export class ReceiveCardDto {
@ApiProperty({ description: '명함 코드 (0000-000)', example: '0428-117' })
@Transform(({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() : value,
)
@Matches(CARD_CODE_PATTERN, { message: '명함 코드가 올바르지 않습니다.' })
code!: string;
@ApiPropertyOptional({
description: `나의 메모 (최대 ${RECEIVED_NOTE_MAX_LENGTH}자)`,
nullable: true,
type: String,
})
@IsOptional()
@Transform(({ value }: { value: unknown }) => {
if (typeof value !== 'string') return value;
const trimmed = value.trim();
return trimmed === '' ? null : trimmed;
})
@IsString()
@MaxLength(RECEIVED_NOTE_MAX_LENGTH, {
message: `메모는 ${RECEIVED_NOTE_MAX_LENGTH}자 이하로 입력해 주세요.`,
})
note?: string | null;
}
/** 공유된 아이 정보 — 간략히면 건강 정보·병원·메모가 없다 */
export class SharedPetSnapshotDto implements SharedPetSnapshot {
@ApiProperty() name!: string;
@ApiProperty() species!: string;
@ApiProperty() breed!: string;
@ApiProperty({ example: 'male' }) gender!: string;
@ApiProperty({ example: '2021-09-14' }) birthDate!: string;
@ApiProperty() weight!: number;
@ApiProperty({ example: 'kg' }) weightUnit!: string;
@ApiProperty({ nullable: true, type: String }) microchip!: string | null;
@ApiProperty({ example: '#FF9B6A' }) tint!: string;
@ApiPropertyOptional() neutered?: boolean;
@ApiPropertyOptional({ nullable: true, type: String }) bloodType?:
| string
| null;
@ApiPropertyOptional({ nullable: true, type: String }) vet?: string | null;
@ApiPropertyOptional({ nullable: true, type: String }) memo?: string | null;
@ApiPropertyOptional({ type: [String] }) allergies?: string[];
@ApiPropertyOptional({ type: [String] }) conditions?: string[];
@ApiPropertyOptional({ type: [String] }) watchConditions?: string[];
}
/** 공유된 꾸미기 값 */
export class SharedCardStyleDto implements SharedCardStyle {
@ApiProperty() tint!: string;
@ApiProperty() tint2!: string;
@ApiProperty({ nullable: true, type: String }) nameBg!: string | null;
@ApiProperty({ nullable: true, type: String }) nameBgAsset!: string | null;
@ApiProperty() textLight!: boolean;
@ApiProperty() scrim!: boolean;
@ApiProperty({ enum: SCRIM_TONES }) scrimTone!: ScrimTone;
}
/**
* 받기 전 미리보기 — 사진은 GET /shared-pet-cards/{code}/images/{pet|sender|background} 로 받는다
* (아직 내 보관함에 없어 파일 id 를 주지 않는다).
*/
export class SharedCardPreviewDto {
@ApiProperty({ example: '0428-117' }) code!: string;
@ApiProperty({ enum: CARD_SHARE_MODES }) mode!: CardShareMode;
@ApiProperty({ nullable: true, type: String }) senderNickname!: string | null;
@ApiProperty() hasSenderPhoto!: boolean;
@ApiProperty({ type: SharedPetSnapshotDto }) pet!: SharedPetSnapshotDto;
@ApiProperty() hasPetPhoto!: boolean;
@ApiProperty({ type: SharedCardStyleDto }) style!: SharedCardStyleDto;
@ApiProperty() hasBackgroundPhoto!: boolean;
}
/** 보관함의 받은 명함 — 사진은 내 소유 사본이라 GET /api/files/{id} 로 받는다 */
export class ReceivedCardResponseDto {
@ApiProperty() id!: string;
@ApiProperty({ example: '0428-117' }) code!: string;
@ApiProperty({ enum: CARD_SHARE_MODES }) mode!: CardShareMode;
@ApiProperty({ nullable: true, type: String }) senderNickname!: string | null;
@ApiProperty({ nullable: true, type: String }) senderPhotoFileId!:
| string
| null;
@ApiProperty({ type: SharedPetSnapshotDto }) pet!: SharedPetSnapshotDto;
@ApiProperty({ nullable: true, type: String }) petPhotoFileId!: string | null;
@ApiProperty({ type: SharedCardStyleDto }) style!: SharedCardStyleDto;
@ApiProperty({ nullable: true, type: String }) backgroundFileId!:
| string
| null;
@ApiProperty({ nullable: true, type: String }) note!: string | null;
@ApiProperty({ description: '받은 때 (ISO 8601 UTC)' }) receivedAt!: string;
static from(card: ReceivedCard): ReceivedCardResponseDto {
return {
id: card.id,
code: card.code,
mode: card.mode,
senderNickname: card.senderNickname,
senderPhotoFileId: card.senderPhotoFileId,
pet: card.pet,
petPhotoFileId: card.petPhotoFileId,
style: card.style,
backgroundFileId: card.backgroundFileId,
note: card.note,
receivedAt: card.createdAt.toISOString(),
};
}
}
@@ -16,6 +16,10 @@ import { User } from '@/modules/user/entities/user.entity';
/** 한 사람이 만들 수 있는 펫 명함 수 — 앱의 kMaxPetCards */
export const MAX_PET_CARDS = 10;
/** 명함을 공유한 모양 — 간략히(이름·종·생일 등만) / 상세히(건강 정보·병원·메모까지) (앱의 CardViewMode) */
export const CARD_SHARE_MODES = ['simple', 'detail'] as const;
export type CardShareMode = (typeof CARD_SHARE_MODES)[number];
/** 글자 뒤에 덮는 막의 색 (앱의 ScrimTone) */
export const SCRIM_TONES = ['light', 'dark'] as const;
export type ScrimTone = (typeof SCRIM_TONES)[number];
@@ -93,6 +97,13 @@ export class PetCard {
@Column({ type: 'boolean', default: false })
isRepresentative!: boolean;
/**
* 공유한 모양 — 명함을 받은 사람에게 이 모양에 담긴 정보만 넘긴다.
* 공유 시트를 열 때 앱이 정한다. 한 번도 공유하지 않았으면 정보가 적은 간략히.
*/
@Column({ type: 'varchar', length: 6, default: 'simple' })
shareMode!: CardShareMode;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@@ -0,0 +1,126 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { StoredFile } from '@/modules/upload/entities/stored-file.entity';
import { User } from '@/modules/user/entities/user.entity';
import { PetCard } from './pet-card.entity';
import type { CardShareMode, ScrimTone } from './pet-card.entity';
/** 받은 명함에 남기는 나의 메모 최대 길이 (시안 maxLength 200) */
export const RECEIVED_NOTE_MAX_LENGTH = 200;
/**
* 받은 명함에 담는 아이 정보 — 받을 때 공유한 모양에 맞춰 잘라 낸 사본.
* 간략히는 이름·종·품종·성별·생일·몸무게·등록번호만, 상세히는 건강 정보·병원·메모까지.
*/
export interface SharedPetSnapshot {
name: string;
species: string;
breed: string;
gender: string;
birthDate: string;
weight: number;
weightUnit: string;
microchip: string | null;
/** 아이의 고유색 — 사진이 없을 때 아바타 바탕 */
tint: string;
neutered?: boolean;
bloodType?: string | null;
vet?: string | null;
memo?: string | null;
allergies?: string[];
conditions?: string[];
watchConditions?: string[];
}
/** 받은 명함의 꾸미기 값 — 배경 사진은 별도 컬럼(받는 사람 소유 사본) */
export interface SharedCardStyle {
tint: string;
tint2: string;
nameBg: string | null;
nameBgAsset: string | null;
textLight: boolean;
scrim: boolean;
scrimTone: ScrimTone;
}
/**
* 받은 명함(보관함) — 받는 순간의 사본이다. 보낸 사람이 아이 정보를 바꾸거나 명함을 지워도 그대로 남는다.
* 사진도 받는 사람 소유로 복사해 둔다 (원본이 지워져도 보이도록).
*/
@Entity('received_cards')
export class ReceivedCard {
@PrimaryGeneratedColumn('uuid')
id!: string;
/** 받은 사람 — 탈퇴하면 함께 지운다 */
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'owner_id' })
owner?: User;
@Index()
@Column({ type: 'uuid' })
ownerId!: string;
/** 원래 명함 — 보낸 사람이 지우면 비운다 (사본은 남는다) */
@ManyToOne(() => PetCard, { nullable: true, onDelete: 'SET NULL' })
@JoinColumn({ name: 'source_card_id' })
sourceCard?: PetCard | null;
@Column({ type: 'uuid', nullable: true })
sourceCardId!: string | null;
/** 받을 때의 명함 코드 */
@Column({ type: 'varchar', length: 8 })
code!: string;
/** 공유된 모양 — 보관함에서도 이 모양으로 보여 준다 */
@Column({ type: 'varchar', length: 6 })
mode!: CardShareMode;
/** 보낸 사람의 닉네임 (받은 때 기준) */
@Column({ type: 'varchar', length: 20, nullable: true })
senderNickname!: string | null;
@ManyToOne(() => StoredFile, { nullable: true, onDelete: 'NO ACTION' })
@JoinColumn({ name: 'sender_photo_file_id' })
senderPhotoFile?: StoredFile | null;
@Column({ type: 'uuid', nullable: true })
senderPhotoFileId!: string | null;
@Column({ type: 'jsonb' })
pet!: SharedPetSnapshot;
@ManyToOne(() => StoredFile, { nullable: true, onDelete: 'NO ACTION' })
@JoinColumn({ name: 'pet_photo_file_id' })
petPhotoFile?: StoredFile | null;
@Column({ type: 'uuid', nullable: true })
petPhotoFileId!: string | null;
@Column({ type: 'jsonb' })
style!: SharedCardStyle;
@ManyToOne(() => StoredFile, { nullable: true, onDelete: 'NO ACTION' })
@JoinColumn({ name: 'background_file_id' })
backgroundFile?: StoredFile | null;
@Column({ type: 'uuid', nullable: true })
backgroundFileId!: string | null;
/** 받은 사람이 남긴 메모 */
@Column({ type: 'varchar', length: RECEIVED_NOTE_MAX_LENGTH, nullable: true })
note!: string | null;
/** 받은 때 — 보관함 순서와 "받은 날짜" */
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
}
@@ -4,15 +4,30 @@ 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 { ReceivedCard } from './entities/received-card.entity';
import { PetCardService } from './pet-card.service';
import {
PetCardsController,
RepresentativeCardController,
} from './pet-cards.controller';
import { ReceivedCardService } from './received-card.service';
import {
ReceivedCardsController,
SharedPetCardsController,
} from './received-cards.controller';
@Module({
imports: [TypeOrmModule.forFeature([PetCard]), PetModule, UploadModule],
controllers: [PetCardsController, RepresentativeCardController],
providers: [PetCardService],
imports: [
TypeOrmModule.forFeature([PetCard, ReceivedCard]),
PetModule,
UploadModule,
],
controllers: [
PetCardsController,
RepresentativeCardController,
SharedPetCardsController,
ReceivedCardsController,
],
providers: [PetCardService, ReceivedCardService],
})
export class PetCardModule {}
@@ -23,6 +23,7 @@ import {
toStyleColumns,
} from './dto/pet-card.dto';
import { MAX_PET_CARDS, PetCard } from './entities/pet-card.entity';
import type { CardShareMode } from './entities/pet-card.entity';
/** 명함 코드가 겹치면 다시 뽑는 횟수 — 천만 개 중에서 뽑으므로 거의 겹치지 않는다 */
const CODE_RETRY = 5;
@@ -132,6 +133,21 @@ export class PetCardService implements OnModuleInit, PetLifecycleListener {
});
}
/** 공유 모양 정하기 — 공유 시트를 열 때 앱이 보낸다. 받는 사람에게는 이 모양의 정보만 간다 */
async setShareMode(
ownerId: string,
cardId: string,
mode: CardShareMode,
): Promise<void> {
const result = await this.cards.update(
{ id: cardId, ownerId },
{ shareMode: mode },
);
if (!result.affected) {
throw new BusinessException('RES_001', { context: { cardId } });
}
}
/** 대표 명함 지정 풀기 — 앱은 그 아이의 첫 명함을 보여 준다 */
async clearRepresentative(ownerId: string, petId: string): Promise<void> {
await this.pets.findOwned(ownerId, petId);
@@ -36,6 +36,7 @@ import {
SetRepresentativeDto,
UpdatePetCardDto,
} from './dto/pet-card.dto';
import { ShareModeDto } from './dto/received-card.dto';
import { PetCardService } from './pet-card.service';
// 펫 명함 — 라우팅과 입출력만 담당, 비즈니스 로직은 모두 service 위임
@@ -86,6 +87,24 @@ export class PetCardsController {
return this.cards.update(user.userId, id, dto);
}
@Put(':id/share-mode')
@HttpCode(HttpStatus.NO_CONTENT)
@SkipTransform() // 204 — 본문이 없다
@ApiOperation({
summary: '공유 모양 정하기',
description:
'공유 시트를 열 때 보낸다. 명함을 받는 사람에게는 이 모양(간략히·상세히)의 정보만 간다',
})
@ApiNoContentResponse({ description: '저장 완료' })
@ApiErrorResponse('RES_001', '명함을 찾을 수 없음')
async setShareMode(
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: ShareModeDto,
): Promise<void> {
await this.cards.setShareMode(user.userId, id, dto.mode);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@SkipTransform() // 204 — 본문이 없다
@@ -0,0 +1,129 @@
import { Repository } from 'typeorm';
import { UploadService } from '@/modules/upload/upload.service';
import { PetCard } from './entities/pet-card.entity';
import { ReceivedCard } from './entities/received-card.entity';
import { ReceivedCardService } from './received-card.service';
const SENDER = 'sender-1';
const VIEWER = 'viewer-1';
function card(overrides: Partial<PetCard> = {}): PetCard {
return {
id: 'card-1',
ownerId: SENDER,
code: '0428-117',
shareMode: 'simple',
tint: '#FF9B6A',
tint2: '#FFC59B',
nameBg: null,
nameBgAsset: null,
nameBgFileId: 'bg-1',
textLight: false,
scrim: false,
scrimTone: 'light',
owner: { nickname: '초코아빠', photoFileId: 'sender-photo' },
pet: {
name: '초코',
species: '강아지',
breed: '닥스훈트',
gender: 'male',
birthDate: '2021-05-14',
weight: 7,
weightUnit: 'kg',
microchip: null,
tint: '#C9794A',
photoFileId: 'pet-photo',
vet: '드림 동물병원',
},
...overrides,
} as unknown as PetCard;
}
describe('ReceivedCardService', () => {
const cards = { findOne: jest.fn() };
const received = {
save: jest.fn(),
create: jest.fn((v: Partial<ReceivedCard>) => v),
};
const uploads = {
copyFor: jest.fn((id: string | null) =>
Promise.resolve(id === null ? null : `copy-of-${id}`),
),
releaseIfUnused: jest.fn(),
};
const service = new ReceivedCardService(
cards as unknown as Repository<PetCard>,
received as unknown as Repository<ReceivedCard>,
uploads as unknown as UploadService,
);
beforeEach(() => jest.clearAllMocks());
it('형식이 틀린 코드는 DB 를 보지 않고 CARD_002', async () => {
await expect(service.preview(VIEWER, 'abc')).rejects.toMatchObject({
code: 'CARD_002',
});
expect(cards.findOne).not.toHaveBeenCalled();
});
it('내 명함은 받을 수 없다 (CARD_003)', async () => {
cards.findOne.mockResolvedValue(card());
await expect(service.preview(SENDER, '0428-117')).rejects.toMatchObject({
code: 'CARD_003',
});
});
it('미리보기는 공유한 모양의 정보만 담고 사진 id 는 주지 않는다', async () => {
cards.findOne.mockResolvedValue(card());
const preview = await service.preview(VIEWER, '0428-117');
expect(preview.mode).toBe('simple');
expect(preview.pet).not.toHaveProperty('vet');
expect(preview).toMatchObject({
senderNickname: '초코아빠',
hasSenderPhoto: true,
hasPetPhoto: true,
hasBackgroundPhoto: true,
});
expect(JSON.stringify(preview)).not.toContain('pet-photo');
});
it('받으면 사진을 내 소유로 복사해 사본을 저장한다', async () => {
cards.findOne.mockResolvedValue(card());
received.save.mockImplementation((v: Partial<ReceivedCard>) =>
Promise.resolve({ ...v, id: 'r1', createdAt: new Date() }),
);
const result = await service.receive(VIEWER, {
code: '0428-117',
note: '산책 친구',
});
expect(uploads.copyFor).toHaveBeenCalledWith('sender-photo', VIEWER);
expect(uploads.copyFor).toHaveBeenCalledWith('pet-photo', VIEWER);
expect(uploads.copyFor).toHaveBeenCalledWith('bg-1', VIEWER);
expect(result).toMatchObject({
petPhotoFileId: 'copy-of-pet-photo',
senderPhotoFileId: 'copy-of-sender-photo',
backgroundFileId: 'copy-of-bg-1',
note: '산책 친구',
});
});
it('저장하지 못하면 복사한 사진을 지운다', async () => {
cards.findOne.mockResolvedValue(card());
received.save.mockRejectedValue(new Error('db down'));
await expect(service.receive(VIEWER, { code: '0428-117' })).rejects.toThrow(
'db down',
);
expect(uploads.releaseIfUnused).toHaveBeenCalledWith([
'copy-of-sender-photo',
'copy-of-pet-photo',
'copy-of-bg-1',
]);
});
});
@@ -0,0 +1,176 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { 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 { FileDownload, UploadService } from '@/modules/upload/upload.service';
import { cardStyleSnapshot, petSnapshot } from './card-snapshot';
import {
CARD_CODE_PATTERN,
ReceiveCardDto,
ReceivedCardResponseDto,
SharedCardPreviewDto,
} from './dto/received-card.dto';
import { PetCard } from './entities/pet-card.entity';
import { ReceivedCard } from './entities/received-card.entity';
/** 공유된 명함에서 내려받을 수 있는 사진 */
export const SHARED_IMAGE_KINDS = ['pet', 'sender', 'background'] as const;
export type SharedImageKind = (typeof SHARED_IMAGE_KINDS)[number];
// 명함 받기 — 코드로 다른 회원의 명함을 미리 보고, 보관함에 사본으로 저장한다
@Injectable()
export class ReceivedCardService {
private readonly logger = new Logger(ReceivedCardService.name);
constructor(
@InjectRepository(PetCard)
private readonly cards: Repository<PetCard>,
@InjectRepository(ReceivedCard)
private readonly received: Repository<ReceivedCard>,
private readonly uploads: UploadService,
) {}
/** 받기 전 미리보기 — 아직 보관하지 않는다 */
async preview(viewerId: string, code: string): Promise<SharedCardPreviewDto> {
const card = await this.findShared(viewerId, code);
const pet = card.pet!;
return {
code: card.code,
mode: card.shareMode,
senderNickname: card.owner?.nickname ?? null,
hasSenderPhoto: card.owner?.photoFileId != null,
pet: petSnapshot(pet, card.shareMode),
hasPetPhoto: pet.photoFileId !== null,
style: cardStyleSnapshot(card),
hasBackgroundPhoto: card.nameBgFileId !== null,
};
}
/** 미리보기 사진 — 코드를 아는 회원에게만, 그 명함에 보이는 사진만 */
async previewImage(
viewerId: string,
code: string,
kind: SharedImageKind,
): Promise<FileDownload> {
const card = await this.findShared(viewerId, code);
const fileId = sharedFileId(card, kind);
if (fileId === null) {
throw new BusinessException('RES_001', { context: { code, kind } });
}
return this.uploads.downloadUnchecked(fileId);
}
/** 보관함에 저장 — 받는 순간의 사본을 만들고 사진도 내 소유로 복사한다 */
async receive(
viewerId: string,
dto: ReceiveCardDto,
): Promise<ReceivedCardResponseDto> {
const card = await this.findShared(viewerId, dto.code);
const pet = card.pet!;
const copies = await Promise.all([
this.uploads.copyFor(card.owner?.photoFileId ?? null, viewerId),
this.uploads.copyFor(pet.photoFileId, viewerId),
this.uploads.copyFor(card.nameBgFileId, viewerId),
]);
const [senderPhotoFileId, petPhotoFileId, backgroundFileId] = copies;
try {
const saved = await this.received.save(
this.received.create({
ownerId: viewerId,
sourceCardId: card.id,
code: card.code,
mode: card.shareMode,
senderNickname: card.owner?.nickname ?? null,
senderPhotoFileId,
pet: petSnapshot(pet, card.shareMode),
petPhotoFileId,
style: cardStyleSnapshot(card),
backgroundFileId,
note: dto.note ?? null,
}),
);
this.logger.log(
`명함 받기: ${saved.id} (명함 ${card.id} → 회원 ${viewerId})`,
);
return ReceivedCardResponseDto.from(saved);
} catch (error) {
// 저장하지 못했으면 복사한 사진도 지운다
await this.uploads.releaseIfUnused(copies);
throw error;
}
}
/** 보관함 (받은 순) */
async list(
ownerId: string,
query: CursorPaginationQueryDto,
): Promise<PaginatedResult<ReceivedCardResponseDto>> {
const qb = this.received
.createQueryBuilder('received')
.where('received.owner_id = :ownerId', { ownerId });
const page = await keysetPaginate(
qb,
[
{
column: 'received.created_at',
cast: 'timestamptz',
read: (r) => r.createdAt.toISOString(),
},
{ column: 'received.id', cast: 'uuid', read: (r) => r.id },
],
query,
);
return mapPage(page, (r) => ReceivedCardResponseDto.from(r));
}
/** 보관함에서 삭제 — 복사해 둔 사진도 정리한다 */
async remove(ownerId: string, receivedId: string): Promise<void> {
const card = await this.received.findOneBy({ id: receivedId, ownerId });
if (!card) {
throw new BusinessException('RES_001', { context: { receivedId } });
}
await this.received.delete({ id: card.id });
await this.uploads.releaseIfUnused([
card.senderPhotoFileId,
card.petPhotoFileId,
card.backgroundFileId,
]);
}
/** 코드로 공유된 명함 찾기 — 없거나 형식이 틀리면 CARD_002, 내 명함이면 CARD_003 */
private async findShared(viewerId: string, code: string): Promise<PetCard> {
const card = CARD_CODE_PATTERN.test(code)
? await this.cards.findOne({
where: { code },
relations: { pet: true, owner: true },
})
: null;
if (!card?.pet) {
throw new BusinessException('CARD_002', { context: { code } });
}
if (card.ownerId === viewerId) {
throw new BusinessException('CARD_003', { context: { code } });
}
return card;
}
}
/** 공유된 명함에서 [kind] 사진의 파일 id */
function sharedFileId(card: PetCard, kind: SharedImageKind): string | null {
switch (kind) {
case 'pet':
return card.pet?.photoFileId ?? null;
case 'sender':
return card.owner?.photoFileId ?? null;
case 'background':
return card.nameBgFileId;
}
}
@@ -0,0 +1,162 @@
import {
Body,
Controller,
Delete,
Get,
Header,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Post,
Query,
StreamableFile,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiNoContentResponse,
ApiOkResponse,
ApiOperation,
ApiParam,
ApiTags,
} from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
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 { BusinessException } from '@/common/errors/business.exception';
import {
FILE_CACHE_CONTROL,
toStreamableFile,
} from '@/modules/upload/file-response';
import {
ReceiveCardDto,
ReceivedCardResponseDto,
SharedCardPreviewDto,
} from './dto/received-card.dto';
import {
ReceivedCardService,
SHARED_IMAGE_KINDS,
} from './received-card.service';
import type { SharedImageKind } from './received-card.service';
/**
* 명함 코드로 찾는 엔드포인트는 코드 추측의 표적이 된다 — 전역보다 엄격하게 막는다.
* 분당 30회 — 사진 세 장을 포함해 명함 몇 장을 받는 데는 충분하다.
*/
const CODE_LOOKUP_THROTTLE = { default: { ttl: 60_000, limit: 30 } };
// 공유된 명함 — 코드를 아는 회원이 받기 전에 미리 본다
@ApiTags('PetCard')
@ApiBearerAuth()
@Throttle(CODE_LOOKUP_THROTTLE)
@ApiErrorResponse('AUTH_001', '로그인 필요')
@Controller('shared-pet-cards')
export class SharedPetCardsController {
constructor(private readonly received: ReceivedCardService) {}
@Get(':code')
@ApiOperation({
summary: '명함 미리보기 (받기 전)',
description: '보낸 사람이 공유한 모양(간략히·상세히)의 정보만 온다',
})
@ApiParam({ name: 'code', example: '0428-117' })
@ApiStandardResponse(SharedCardPreviewDto, { description: '공유된 명함' })
@ApiErrorResponse('CARD_002', '코드에 맞는 명함이 없음')
@ApiErrorResponse('CARD_003', '내 명함')
preview(
@CurrentUser() user: AuthUser,
@Param('code') code: string,
): Promise<SharedCardPreviewDto> {
return this.received.preview(user.userId, code);
}
@Get(':code/images/:kind')
@SkipTransform() // 파일 본문 — { success, data } 로 감싸면 깨진다
@Header('Cache-Control', FILE_CACHE_CONTROL)
@ApiOperation({ summary: '미리보기 사진 (아이·보낸 사람·배경)' })
@ApiParam({ name: 'kind', enum: SHARED_IMAGE_KINDS })
@ApiOkResponse({ description: '사진 본문' })
@ApiErrorResponse('CARD_002', '코드에 맞는 명함이 없음')
@ApiErrorResponse('RES_001', '그 사진이 없음')
async image(
@CurrentUser() user: AuthUser,
@Param('code') code: string,
@Param('kind') kind: string,
): Promise<StreamableFile> {
if (!isSharedImageKind(kind)) {
throw new BusinessException('RES_001', { context: { kind } });
}
return toStreamableFile(
await this.received.previewImage(user.userId, code, kind),
);
}
}
// 받은 명함 보관함
@ApiTags('PetCard')
@ApiBearerAuth()
@ApiErrorResponse('AUTH_001', '로그인 필요')
@Controller('received-cards')
export class ReceivedCardsController {
constructor(private readonly received: ReceivedCardService) {}
@Get()
@ApiOperation({ summary: '받은 명함 목록 (받은 순, 커서 페이지네이션)' })
@ApiPaginatedResponse(ReceivedCardResponseDto, '받은 명함 목록')
@ApiErrorResponse('VAL_001', '페이지네이션 파라미터 오류')
list(
@CurrentUser() user: AuthUser,
@Query() query: CursorPaginationQueryDto,
): Promise<PaginatedResult<ReceivedCardResponseDto>> {
return this.received.list(user.userId, query);
}
@Post()
@Throttle(CODE_LOOKUP_THROTTLE)
@ApiOperation({
summary: '명함 받기 (보관함에 저장)',
description:
'받는 순간의 사본을 저장한다 — 보낸 사람이 정보를 바꾸거나 명함을 지워도 그대로 남는다',
})
@ApiStandardResponse(ReceivedCardResponseDto, {
status: 201,
description: '보관한 명함',
})
@ApiErrorResponse('CARD_002', '코드에 맞는 명함이 없음')
@ApiErrorResponse('CARD_003', '내 명함')
@ApiErrorResponse('VAL_001', '입력값 검증 실패')
receive(
@CurrentUser() user: AuthUser,
@Body() dto: ReceiveCardDto,
): Promise<ReceivedCardResponseDto> {
return this.received.receive(user.userId, 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<void> {
await this.received.remove(user.userId, id);
}
}
function isSharedImageKind(kind: string): kind is SharedImageKind {
return (SHARED_IMAGE_KINDS as readonly string[]).includes(kind);
}
@@ -0,0 +1,19 @@
import { StreamableFile } from '@nestjs/common';
import type { FileDownload } from './upload.service';
/** 파일은 id 별로 내용이 바뀌지 않으므로 오래 캐시해도 된다 (로그인한 사람만 받으므로 private) */
export const FILE_CACHE_CONTROL = 'private, max-age=31536000, immutable';
/**
* 파일 본문 응답 — 업로드 디렉터리를 정적으로 서빙하지 않는다.
* 형식은 서버 기록으로 정하고, 브라우저가 열어 실행하지 않도록 attachment 로 내려준다 (.claude/rules/security.md).
* 핸들러에는 @SkipTransform() 과 @Header('Cache-Control', ...) 를 함께 붙인다.
*/
export function toStreamableFile(file: FileDownload): StreamableFile {
return new StreamableFile(file.stream, {
type: file.mimeType,
length: file.size,
disposition: `attachment; filename="${file.downloadName}"`,
});
}
@@ -28,11 +28,9 @@ import type { AuthUser } from '@/common/decorators/current-user.decorator';
import { SkipTransform } from '@/common/decorators/skip-transform.decorator';
import { BusinessException } from '@/common/errors/business.exception';
import { UploadResponseDto } from './dto/upload-response.dto';
import { FILE_CACHE_CONTROL, toStreamableFile } from './file-response';
import { UploadService } from './upload.service';
/** 파일은 id 별로 내용이 바뀌지 않으므로 오래 캐시해도 된다 (로그인한 본인만 받으므로 private) */
const FILE_CACHE_CONTROL = 'private, max-age=31536000, immutable';
// 파일 업로드·내려받기 — 새 업로드 엔드포인트는 이 패턴을 복제한다.
// 검증(형식·크기)과 안전한 파일명 생성은 UploadModule 의 multer 설정이,
// 내용(시그니처) 검사와 소유자 기록은 UploadService 가 담당한다.
@@ -86,14 +84,6 @@ export class UploadController {
@CurrentUser() user: AuthUser,
@Param('id', ParseUUIDPipe) id: string,
): Promise<StreamableFile> {
const file = await this.uploadService.download(id, user.userId);
// 업로드 디렉터리를 정적으로 서빙하지 않는다 — 형식은 서버 기록으로 정하고,
// 브라우저가 열어 실행하지 않도록 attachment 로 내려준다 (.claude/rules/security.md)
return new StreamableFile(file.stream, {
type: file.mimeType,
length: file.size,
disposition: `attachment; filename="${file.downloadName}"`,
});
return toStreamableFile(await this.uploadService.download(id, user.userId));
}
}
+48 -2
View File
@@ -1,8 +1,9 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { randomUUID } from 'crypto';
import { createReadStream } from 'fs';
import { open, unlink } from 'fs/promises';
import { join } from 'path';
import { copyFile, open, unlink } from 'fs/promises';
import { extname, join } from 'path';
import { Readable } from 'stream';
import { Repository } from 'typeorm';
@@ -70,7 +71,52 @@ export class UploadService {
if (!file) {
throw new BusinessException('RES_001', { context: { fileId } });
}
return this.toDownload(file);
}
/**
* 소유자를 따지지 않고 내려받는다 — 호출한 쪽이 볼 권한을 이미 확인한 경우만 쓴다
* (예: 명함 코드를 아는 사람에게 그 명함의 사진을 보여 줄 때).
*/
async downloadUnchecked(fileId: string): Promise<FileDownload> {
const file = await this.files.findOneBy({ id: fileId });
if (!file) {
throw new BusinessException('RES_001', { context: { fileId } });
}
return this.toDownload(file);
}
/**
* 파일을 [ownerId] 의 것으로 복사한다 — 명함을 받을 때처럼 원본이 지워져도 남아야 하는 경우.
* 없는 파일(null 포함)이면 null.
*/
async copyFor(
fileId: string | null,
ownerId: string,
): Promise<string | null> {
if (fileId === null) return null;
const source = await this.files.findOneBy({ id: fileId });
if (!source) return null;
// 저장 파일명은 새로 만든다 — 확장자는 원본(서버가 검증해 만든 이름)을 따른다
const storedName = `${randomUUID()}${extname(source.storedName)}`;
await copyFile(
join(UPLOAD_DIR, source.storedName),
join(UPLOAD_DIR, storedName),
);
const copy = await this.files.save(
this.files.create({
ownerId,
storedName,
originalName: source.originalName,
mimeType: source.mimeType,
size: source.size,
}),
);
return copy.id;
}
private toDownload(file: StoredFile): FileDownload {
return {
stream: createReadStream(join(UPLOAD_DIR, file.storedName)),
mimeType: file.mimeType,
@@ -14,6 +14,8 @@ const Map<String, String> kErrorCodeMessages = {
'DIARY_001': '다이어리는 최대 6권까지 만들 수 있어요.',
'DIARY_002': '오늘 기록은 이미 남겼어요. 내일 다시 작성할 수 있어요.',
'CARD_001': '펫 명함은 최대 10장까지 만들 수 있어요.',
'CARD_002': '명함을 찾을 수 없어요. 명함 코드를 다시 확인해 주세요.',
'CARD_003': '내 명함은 받을 수 없어요.',
};
/// 매핑 실패 시 사용하는 폴백 코드
@@ -14,6 +14,8 @@ export const ERROR_CODE_MESSAGES = {
DIARY_001: '다이어리는 최대 6권까지 만들 수 있어요.',
DIARY_002: '오늘 기록은 이미 남겼어요. 내일 다시 작성할 수 있어요.',
CARD_001: '펫 명함은 최대 10장까지 만들 수 있어요.',
CARD_002: '명함을 찾을 수 없어요. 명함 코드를 다시 확인해 주세요.',
CARD_003: '내 명함은 받을 수 없어요.',
} as const
export type ErrorCode = keyof typeof ERROR_CODE_MESSAGES