generated from ttipo/project-base
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:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user