diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 36c5ab8..0d63de2 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -3,11 +3,19 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { ConfigModule } from '@nestjs/config'; // 도메인 일정 모듈(modules/schedule)과 이름이 겹쳐 크론 모듈을 별칭으로 부른다 import { ScheduleModule as CronScheduleModule } from '@nestjs/schedule'; -import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; +import { ThrottlerModule } from '@nestjs/throttler'; import { CacheModule } from '@nestjs/cache-manager'; import { APP_GUARD } from '@nestjs/core'; +import { + INVITE_THROTTLE, + INVITE_THROTTLER, +} from '@/common/decorators/invite-code-lookup.decorator'; import { JwtAuthGuard } from '@/common/guards/jwt-auth.guard'; +import { + InviteThrottlerGuard, + IpThrottlerGuard, +} from '@/common/guards/throttler.guards'; import { RequestIdMiddleware } from '@/common/middleware/request-id.middleware'; import { AuthModule } from '@/modules/auth/auth.module'; import { DiaryModule } from '@/modules/diary/diary.module'; @@ -43,8 +51,10 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수 // 전역 Rate Limiter — API 남용/브루트포스 방지 (프로젝트의 유일한 rate limiting 수단) // 한도 초과 시 429 → 전역 필터가 REQ_001 로 매핑한다. // 라우트 단위 조정은 @Throttle({ default: { ttl, limit } }), 제외는 @SkipThrottle(). + // 'invite' 는 코드로 찾는 엔드포인트(@InviteCodeLookup)만 회원별로 한 통에서 센다 (common/guards/throttler.guards.ts) ThrottlerModule.forRoot([ { + name: 'default', // compose 가 미정의 변수를 빈 문자열로 주입하므로 parsePositiveInt 로 방어한다 ttl: parsePositiveInt( process.env.THROTTLE_TTL_MS, @@ -55,6 +65,7 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수 THROTTLE_DEFAULT_LIMIT, ), }, + { name: INVITE_THROTTLER, ...INVITE_THROTTLE }, ]), // DB 접속 설정은 database.config.ts 하나만 수정한다 (CLI 와 공유) TypeOrmModule.forRootAsync({ @@ -75,9 +86,10 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수 ], providers: [ ShutdownLogger, + // 가드는 등록한 순서대로 돈다 — IP 한도 → 인증 → 코드 조회 한도(회원별) { provide: APP_GUARD, - useClass: ThrottlerGuard, + useClass: IpThrottlerGuard, }, // 전역 인증 — 모든 라우트가 기본으로 로그인을 요구한다. 예외는 @Public() 으로 연다. // (rate limit 이 먼저 돌아 토큰 없는 대량 요청도 한도에 걸린다) @@ -85,6 +97,11 @@ const THROTTLE_DEFAULT_LIMIT = 100; // 구간당 IP별 최대 요청 수 provide: APP_GUARD, useClass: JwtAuthGuard, }, + // 코드 조회 한도는 회원을 알아야 하므로 인증 다음에 둔다 + { + provide: APP_GUARD, + useClass: InviteThrottlerGuard, + }, ], }) export class AppModule implements NestModule { diff --git a/backend/src/common/decorators/invite-code-lookup.decorator.ts b/backend/src/common/decorators/invite-code-lookup.decorator.ts new file mode 100644 index 0000000..69cd4c0 --- /dev/null +++ b/backend/src/common/decorators/invite-code-lookup.decorator.ts @@ -0,0 +1,17 @@ +import { SetMetadata } from '@nestjs/common'; + +/** + * 코드로 남의 것을 찾는 엔드포인트의 한도 — 명함 코드·다이어리 초대 코드·일정 연동 코드 모두 **한 통**에서 센다. + * 엔드포인트마다 따로 세면 여러 곳을 돌아가며 코드를 짐작할 수 있다. + * 회원별(로그인 전이면 IP별) 분당 30회 — 사진 몇 장을 포함해 명함 몇 장을 받거나 코드를 몇 번 잘못 입력해도 충분하다. + */ +export const INVITE_THROTTLER = 'invite'; +export const INVITE_THROTTLE = { ttl: 60_000, limit: 30 } as const; + +export const INVITE_CODE_LOOKUP_KEY = 'inviteCodeLookup'; + +/** + * 코드로 찾는 엔드포인트에 붙인다 — InviteThrottlerGuard 가 회원별로 함께 센다 (common/guards/throttler.guards.ts). + * 컨트롤러에 붙이면 그 안의 모든 라우트에 적용된다. + */ +export const InviteCodeLookup = () => SetMetadata(INVITE_CODE_LOOKUP_KEY, true); diff --git a/backend/src/common/errors/error-codes.ts b/backend/src/common/errors/error-codes.ts index d9398ad..e34e924 100644 --- a/backend/src/common/errors/error-codes.ts +++ b/backend/src/common/errors/error-codes.ts @@ -139,6 +139,16 @@ const DOMAIN_ERROR_CODES = { status: HttpStatus.BAD_REQUEST, message: '내 명함은 받을 수 없어요.', }, + // 명함 받기 — 같은 명함이 이미 보관함에 있다 + CARD_004: { + status: HttpStatus.CONFLICT, + message: '이미 보관함에 있는 명함이에요.', + }, + // 명함 받기 — 보관함은 최대 100장 + CARD_005: { + status: HttpStatus.CONFLICT, + message: '보관함이 가득 찼어요. (최대 100장)', + }, } as const satisfies Record; export const ERROR_CODES = { diff --git a/backend/src/common/guards/throttler.guards.ts b/backend/src/common/guards/throttler.guards.ts new file mode 100644 index 0000000..15ab60f --- /dev/null +++ b/backend/src/common/guards/throttler.guards.ts @@ -0,0 +1,73 @@ +import { ExecutionContext, Injectable } from '@nestjs/common'; +import { ThrottlerGuard } from '@nestjs/throttler'; + +import { + INVITE_CODE_LOOKUP_KEY, + INVITE_THROTTLER, +} from '@/common/decorators/invite-code-lookup.decorator'; + +/** + * Rate limit 가드 둘 — 같은 ThrottlerModule 설정(app.module.ts)을 나눠 쓴다. + * throttler 는 등록된 이름 있는 한도를 모든 라우트에 적용하므로 가드마다 자기 한도만 남긴다. + * + * 1. IpThrottlerGuard — 인증보다 먼저, IP 별로 모든 요청을 센다 (토큰 없는 대량 요청도 막는다) + * 2. InviteThrottlerGuard — 인증 다음, 코드로 찾는 엔드포인트(@InviteCodeLookup)만 회원별로 한 통에서 센다 + */ + +/** 기본 한도 — IP 별, 라우트별. 'invite' 한도는 여기서 세지 않는다 */ +@Injectable() +export class IpThrottlerGuard extends ThrottlerGuard { + override async onModuleInit(): Promise { + await super.onModuleInit(); + this.throttlers = this.throttlers.filter( + (t) => t.name !== INVITE_THROTTLER, + ); + } +} + +/** 요청한 쪽 — 로그인했으면 회원, 아니면 IP */ +export function inviteTrackerOf(req: { + user?: { userId?: string }; + ip?: string; +}): string { + const userId = req.user?.userId; + return userId ? `user:${userId}` : `ip:${req.ip ?? 'unknown'}`; +} + +/** 엔드포인트와 무관한 한 통 — 같은 회원이면 어느 코드 엔드포인트든 같은 칸에서 센다 */ +export function inviteBucketKey(name: string, tracker: string): string { + return `${name}:${tracker}`; +} + +/** 코드 조회 한도 — @InviteCodeLookup 이 붙은 라우트만, 회원별로 한 통에서 센다 */ +@Injectable() +export class InviteThrottlerGuard extends ThrottlerGuard { + override async onModuleInit(): Promise { + await super.onModuleInit(); + this.throttlers = this.throttlers.filter( + (t) => t.name === INVITE_THROTTLER, + ); + } + + protected override shouldSkip(context: ExecutionContext): Promise { + const marked = this.reflector.getAllAndOverride( + INVITE_CODE_LOOKUP_KEY, + [context.getHandler(), context.getClass()], + ); + return Promise.resolve(!marked); + } + + protected override getTracker(req: Record): Promise { + return Promise.resolve( + inviteTrackerOf(req as { user?: { userId?: string }; ip?: string }), + ); + } + + protected override generateKey( + _context: ExecutionContext, + tracker: string, + name: string, + ): string { + return inviteBucketKey(name, tracker); + } +} diff --git a/backend/src/common/validators/index.ts b/backend/src/common/validators/index.ts index 2cf63e6..c48a253 100644 --- a/backend/src/common/validators/index.ts +++ b/backend/src/common/validators/index.ts @@ -1,4 +1,5 @@ import { applyDecorators } from '@nestjs/common'; +import { Transform } from 'class-transformer'; import { ArrayMaxSize, ArrayMinSize, @@ -61,11 +62,23 @@ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; /** - * 대상 목록 — 각 값은 "self" 이거나 아이 id(UUID). 기본은 1개 이상(일정), 다이어리는 0개도 된다. + * 대상 id 를 저장 형식으로 맞춘다 — UUID 는 소문자로. DB 의 uuid 는 소문자로 돌려주므로 + * 대문자로 저장되면 target_ids 의 array_remove · ::text = ANY 비교가 어긋난다 (아이를 지워도 대상에서 안 빠진다). + */ +export function normalizeTargetIds(value: unknown): unknown { + if (!Array.isArray(value)) return value; + return value.map((v: unknown) => + typeof v === 'string' && UUID_PATTERN.test(v) ? v.toLowerCase() : v, + ); +} + +/** + * 대상 목록 — 각 값은 "self" 이거나 아이 id(UUID, 소문자로 맞춘다). 기본은 1개 이상(일정), 다이어리는 0개도 된다. * 형식만 본다 — 내 아이인지는 서비스가 PetService.assertOwnedTargets 로 확인한다. */ export function IsTargetIdList(maxItems: number, minItems = 1) { return applyDecorators( + Transform(({ value }: { value: unknown }) => normalizeTargetIds(value)), IsArray({ message: '대상을 골라 주세요.' }), ArrayMinSize(minItems, { message: '대상을 골라 주세요.' }), ArrayMaxSize(maxItems, { message: `대상은 최대 ${maxItems}개까지예요.` }), diff --git a/backend/src/common/validators/target-ids.spec.ts b/backend/src/common/validators/target-ids.spec.ts new file mode 100644 index 0000000..132f237 --- /dev/null +++ b/backend/src/common/validators/target-ids.spec.ts @@ -0,0 +1,32 @@ +import { plainToInstance } from 'class-transformer'; +import { validateSync } from 'class-validator'; + +import { IsTargetIdList, normalizeTargetIds } from './index'; + +class TargetsDto { + @IsTargetIdList(5) + targetIds!: string[]; +} + +const UPPER = 'A1B2C3D4-E5F6-4A7B-8C9D-0E1F2A3B4C5D'; + +describe('대상 id', () => { + it('UUID 는 소문자로 맞추고 self 는 그대로 둔다', () => { + expect(normalizeTargetIds([UPPER, 'self'])).toEqual([ + UPPER.toLowerCase(), + 'self', + ]); + }); + + it('배열이 아니거나 UUID 가 아닌 값은 손대지 않는다 (검증이 거른다)', () => { + expect(normalizeTargetIds('self')).toBe('self'); + expect(normalizeTargetIds(['Not-A-Uuid', 3])).toEqual(['Not-A-Uuid', 3]); + }); + + it('요청 DTO 로 들어오면 검증 전에 소문자로 바뀐다', () => { + const dto = plainToInstance(TargetsDto, { targetIds: [UPPER, 'self'] }); + + expect(validateSync(dto)).toHaveLength(0); + expect(dto.targetIds).toEqual([UPPER.toLowerCase(), 'self']); + }); +}); diff --git a/backend/src/migrations/1791727449415-AddCardShareLimits.ts b/backend/src/migrations/1791727449415-AddCardShareLimits.ts new file mode 100644 index 0000000..330fbbc --- /dev/null +++ b/backend/src/migrations/1791727449415-AddCardShareLimits.ts @@ -0,0 +1,42 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * 명함 공유 안전장치. + * - pet_cards.share_mode_set_at: 공유 모양을 정한 때 — 상세히는 그때부터 7일만 상세히로 내보낸다 (card-snapshot.ts) + * - 받은 명함은 같은 명함을 한 번만 (owner_id, source_card_id) — 원래 명함이 지워진 사본(NULL)은 제외 + * + * 인덱스를 만들기 전에, 이미 같은 명함을 두 번 이상 받아 둔 사본은 가장 먼저 받은 것만 원래 명함과 잇고 + * 나머지는 원래 명함과의 연결만 끊는다(source_card_id = NULL) — 사본·사진은 지우지 않는다. + * 되돌려도 끊은 연결은 돌아오지 않는다 (원래 명함이 지워진 사본과 같은 상태로 남는다). + */ +export class AddCardShareLimits1791727449415 implements MigrationInterface { + name = 'AddCardShareLimits1791727449415'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "pet_cards" ADD "share_mode_set_at" TIMESTAMP WITH TIME ZONE`, + ); + await queryRunner.query( + `UPDATE "received_cards" r SET "source_card_id" = NULL + WHERE r."source_card_id" IS NOT NULL + AND EXISTS ( + SELECT 1 FROM "received_cards" o + WHERE o."owner_id" = r."owner_id" + AND o."source_card_id" = r."source_card_id" + AND (o."created_at", o."id") < (r."created_at", r."id") + )`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "UQ_received_cards_owner_source" ON "received_cards" ("owner_id", "source_card_id") WHERE "source_card_id" IS NOT NULL`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX "public"."UQ_received_cards_owner_source"`, + ); + await queryRunner.query( + `ALTER TABLE "pet_cards" DROP COLUMN "share_mode_set_at"`, + ); + } +} diff --git a/backend/src/migrations/1791727449416-BackfillCalendarInviteCodes.ts b/backend/src/migrations/1791727449416-BackfillCalendarInviteCodes.ts new file mode 100644 index 0000000..1547fcb --- /dev/null +++ b/backend/src/migrations/1791727449416-BackfillCalendarInviteCodes.ts @@ -0,0 +1,47 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * 일정 연동 초대 코드를 모든 회원에게 — 예전에는 연동 화면을 처음 열 때(GET) 만들었다. + * 이제는 가입할 때 만들고 GET 은 아무것도 쓰지 않으므로, 코드가 없는 기존 회원에게 채운 뒤 NOT NULL 로 둔다. + * + * 형식은 앱 코드와 같다 — `RUP-` + 헷갈리는 글자(0·O·1·I)를 뺀 32자 중 6자 (modules/user/calendar-invite-code.ts). + * 무작위 값은 gen_random_uuid() 의 바이트(앞 6바이트는 버전 표시가 없는 순수 난수)에서 얻는다 — + * random() 은 예측할 수 있어 초대 코드에 쓰지 않는다. 256 은 32 의 배수라 글자마다 고르게 나온다. + */ +export class BackfillCalendarInviteCodes1791727449416 implements MigrationInterface { + name = 'BackfillCalendarInviteCodes1791727449416'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DO $$ + DECLARE + alphabet CONSTANT text := 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + target record; + candidate text; + BEGIN + FOR target IN SELECT "id" FROM "users" WHERE "calendar_invite_code" IS NULL LOOP + LOOP + SELECT 'RUP-' || string_agg(substr(alphabet, 1 + get_byte(r.bytes, i) % 32, 1), '' ORDER BY i) + INTO candidate + FROM (SELECT uuid_send(gen_random_uuid()) AS bytes) AS r, + generate_series(0, 5) AS i; + EXIT WHEN NOT EXISTS ( + SELECT 1 FROM "users" WHERE "calendar_invite_code" = candidate + ); + END LOOP; + UPDATE "users" SET "calendar_invite_code" = candidate WHERE "id" = target."id"; + END LOOP; + END + $$`); + await queryRunner.query( + `ALTER TABLE "users" ALTER COLUMN "calendar_invite_code" SET NOT NULL`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + // 채운 코드는 그대로 둔다 — 예전 코드도 비어 있으면 처음 열 때 만들었으므로 값이 있어도 문제없다 + await queryRunner.query( + `ALTER TABLE "users" ALTER COLUMN "calendar_invite_code" DROP NOT NULL`, + ); + } +} diff --git a/backend/src/migrations/1791727662580-AddFileAndAuthorIndexes.ts b/backend/src/migrations/1791727662580-AddFileAndAuthorIndexes.ts new file mode 100644 index 0000000..bcc782c --- /dev/null +++ b/backend/src/migrations/1791727662580-AddFileAndAuthorIndexes.ts @@ -0,0 +1,82 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * FK 컬럼 인덱스 — 파일을 가리키는 *_file_id 와 diary_posts·diary_comments 의 author_id, received_cards.source_card_id. + * 파일을 지울 때(releaseIfUnused·쓰이지 않는 파일 정리) Postgres 가 NO ACTION FK 를 확인하느라 이 컬럼들을 훑고, + * 회원·명함을 지울 때 CASCADE/SET NULL 도 같은 컬럼으로 찾는다 — 인덱스가 없으면 표 전체를 읽는다. 인덱스만 더한다. + */ +export class AddFileAndAuthorIndexes1791727662580 implements MigrationInterface { + name = 'AddFileAndAuthorIndexes1791727662580'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE INDEX "IDX_e4313545e4345b359e66bdbebd" ON "users" ("photo_file_id") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_816320a5aa9d3cafbdddcdde33" ON "pets" ("photo_file_id") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_672dc0c78f4a9ffde6239f1db8" ON "pet_cards" ("name_bg_file_id") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_65e8b70115d2de13ce0380b8ad" ON "received_cards" ("source_card_id") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_ea06e251a9ed53e63e66ef5bb6" ON "received_cards" ("sender_photo_file_id") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_21733fe1b0dfce3e27726b322e" ON "received_cards" ("pet_photo_file_id") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_549a5fe176831cb7cebb29f9e2" ON "received_cards" ("background_file_id") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_49e7cfa49fed5dd22a4e8c25da" ON "diaries" ("cover_file_id") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_c25aa8312dc21996eb00531bb6" ON "diary_posts" ("author_id") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_07e1d0110b6bf6db265e996388" ON "diary_posts" ("photo_file_id") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_ac7b686ceee2547592e05072dc" ON "diary_comments" ("author_id") `, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX "public"."IDX_ac7b686ceee2547592e05072dc"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_07e1d0110b6bf6db265e996388"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_c25aa8312dc21996eb00531bb6"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_49e7cfa49fed5dd22a4e8c25da"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_549a5fe176831cb7cebb29f9e2"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_21733fe1b0dfce3e27726b322e"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_ea06e251a9ed53e63e66ef5bb6"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_65e8b70115d2de13ce0380b8ad"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_672dc0c78f4a9ffde6239f1db8"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_816320a5aa9d3cafbdddcdde33"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_e4313545e4345b359e66bdbebd"`, + ); + } +} diff --git a/backend/src/modules/auth/auth.config.spec.ts b/backend/src/modules/auth/auth.config.spec.ts index 8bb765e..02a1c75 100644 --- a/backend/src/modules/auth/auth.config.spec.ts +++ b/backend/src/modules/auth/auth.config.spec.ts @@ -19,20 +19,38 @@ describe('인증 설정', () => { ); }); - it('JWT_SECRET 이 비어 있으면 개발용 키로 대신한다 (운영은 env 검증이 막는다)', () => { - expect(resolveJwtSecret({ JWT_SECRET: 'real-secret' })).toBe('real-secret'); - expect(resolveJwtSecret({ JWT_SECRET: ' ' })).toMatch(/dev-only/); + it('JWT_SECRET 이 비어 있으면 개발·테스트에서만 개발용 키로 대신한다', () => { + expect( + resolveJwtSecret({ NODE_ENV: 'production', JWT_SECRET: 'real-secret' }), + ).toBe('real-secret'); + expect( + resolveJwtSecret({ NODE_ENV: 'development', JWT_SECRET: ' ' }), + ).toMatch(/dev-only/); + expect(resolveJwtSecret({ NODE_ENV: 'test' })).toMatch(/dev-only/); }); - it('개발용 로그인은 운영에서 항상 꺼진다', () => { + it('개발·테스트 밖(staging·production·미설정)에서 JWT_SECRET 이 없으면 기동을 멈춘다', () => { + for (const env of [ + { NODE_ENV: 'production' }, + { NODE_ENV: 'staging' }, + { NODE_ENV: 'Development' }, + {}, + ]) { + expect(() => resolveJwtSecret(env)).toThrow(/JWT_SECRET/); + } + }); + + it('개발용 로그인은 개발·테스트에서만 열린다 (allowlist)', () => { expect(isDevLoginEnabled({ NODE_ENV: 'development' })).toBe(true); expect(isDevLoginEnabled({ NODE_ENV: 'test' })).toBe(true); expect( isDevLoginEnabled({ NODE_ENV: 'development', AUTH_DEV_LOGIN: 'false' }), ).toBe(false); - expect( - isDevLoginEnabled({ NODE_ENV: 'production', AUTH_DEV_LOGIN: 'true' }), - ).toBe(false); + for (const nodeEnv of ['production', 'staging', 'prod', '', undefined]) { + expect( + isDevLoginEnabled({ NODE_ENV: nodeEnv, AUTH_DEV_LOGIN: 'true' }), + ).toBe(false); + } }); it('소셜 설정은 비어 있으면 끈 것으로 본다', () => { diff --git a/backend/src/modules/auth/auth.config.ts b/backend/src/modules/auth/auth.config.ts index 110c953..0d240f7 100644 --- a/backend/src/modules/auth/auth.config.ts +++ b/backend/src/modules/auth/auth.config.ts @@ -1,3 +1,4 @@ +import { isDevNodeEnv } from '@/shared/config/node-env'; import { parseDurationMs } from '@/shared/utils/duration'; /** @@ -15,8 +16,9 @@ const DEFAULT_ACCESS_TTL_MS = 3_600_000; // 1시간 const DEFAULT_REFRESH_TTL_MS = 1_209_600_000; // 14일 /** - * 개발·테스트 전용 서명 키 — JWT_SECRET 을 비워 둔 로컬·CI 에서만 쓴다. - * 운영은 env.validation 이 JWT_SECRET 을 필수로 요구하므로 이 값으로 기동할 수 없다. + * 개발·테스트 전용 서명 키 — NODE_ENV 가 development·test 이고 JWT_SECRET 을 비워 둔 로컬·CI 에서만 쓴다. + * 그 밖의 환경(staging·production·미설정 …)은 env.validation 이 JWT_SECRET 을 필수로 요구해 기동을 막고, + * 혹시 검증을 거치지 않고 불려도 아래 resolveJwtSecret 이 이 키를 내주지 않는다. */ const DEV_ONLY_JWT_SECRET = 'rup-dev-only-jwt-secret-not-for-production'; @@ -26,7 +28,13 @@ function readEnv(env: Env, key: string): string | undefined { } export function resolveJwtSecret(env: Env = process.env): string { - return readEnv(env, 'JWT_SECRET') ?? DEV_ONLY_JWT_SECRET; + const secret = readEnv(env, 'JWT_SECRET'); + if (secret !== undefined) return secret; + if (isDevNodeEnv(env.NODE_ENV)) return DEV_ONLY_JWT_SECRET; + // env.validation 이 먼저 막는다 — 여기까지 왔다면 설정 경로가 잘못된 것이므로 기동을 멈춘다 + throw new Error( + 'JWT_SECRET 가 설정되지 않았습니다. (development·test 밖에서는 필수)', + ); } export function accessTokenTtlMs(env: Env = process.env): number { @@ -39,10 +47,11 @@ export function refreshTokenTtlMs(env: Env = process.env): number { /** * 개발용 로그인(소셜 앱 키 없이 기기 키로 가입) 허용 여부. - * 운영에서는 어떤 값을 주어도 꺼진다. 개발·테스트에서는 AUTH_DEV_LOGIN=false 로 끌 수 있다. + * NODE_ENV 가 development·test 일 때만 열린다 — 그 밖(staging·production·미설정 …)에서는 어떤 값을 주어도 닫힌다. + * 개발·테스트에서도 AUTH_DEV_LOGIN=false 로 끌 수 있다. */ export function isDevLoginEnabled(env: Env = process.env): boolean { - if (env.NODE_ENV === 'production') return false; + if (!isDevNodeEnv(env.NODE_ENV)) return false; return readEnv(env, 'AUTH_DEV_LOGIN') !== 'false'; } diff --git a/backend/src/modules/diary/diary-invitations.controller.ts b/backend/src/modules/diary/diary-invitations.controller.ts index cdf6d72..fd18170 100644 --- a/backend/src/modules/diary/diary-invitations.controller.ts +++ b/backend/src/modules/diary/diary-invitations.controller.ts @@ -5,8 +5,8 @@ import { ApiParam, ApiTags, } from '@nestjs/swagger'; -import { Throttle } from '@nestjs/throttler'; +import { InviteCodeLookup } from '@/common/decorators/invite-code-lookup.decorator'; import { ApiErrorResponse, ApiStandardResponse, @@ -20,16 +20,10 @@ import { JoinDiaryDto, } from './dto/diary.dto'; -/** - * 초대 코드로 찾는 엔드포인트는 코드 추측의 표적이 된다 — 전역보다 엄격하게 막는다. - * 분당 30회 — 코드를 몇 번 잘못 입력해도 충분하다. - */ -const INVITE_LOOKUP_THROTTLE = { default: { ttl: 60_000, limit: 30 } }; - // 공유 다이어리 초대 — 코드를 받은 회원이 참여하기 전에 미리 본다 @ApiTags('Diary') @ApiBearerAuth() -@Throttle(INVITE_LOOKUP_THROTTLE) +@InviteCodeLookup() @ApiErrorResponse('AUTH_001', '로그인 필요') @Controller('diary-invitations') export class DiaryInvitationsController { @@ -60,7 +54,7 @@ export class DiaryMembershipsController { constructor(private readonly diary: DiaryService) {} @Post() - @Throttle(INVITE_LOOKUP_THROTTLE) + @InviteCodeLookup() @ApiOperation({ summary: '초대 코드로 다이어리 참여', description: diff --git a/backend/src/modules/diary/diary.module.ts b/backend/src/modules/diary/diary.module.ts index 290f95c..59158c9 100644 --- a/backend/src/modules/diary/diary.module.ts +++ b/backend/src/modules/diary/diary.module.ts @@ -4,6 +4,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { PetModule } from '@/modules/pet/pet.module'; import { UploadModule } from '@/modules/upload/upload.module'; import { User } from '@/modules/user/entities/user.entity'; +import { UserModule } from '@/modules/user/user.module'; import { DiariesController } from './diaries.controller'; import { DiaryCommentsController } from './diary-comments.controller'; import { DiaryFileAccess } from './diary-file-access'; @@ -31,6 +32,7 @@ import { ]), PetModule, UploadModule, + UserModule, ], controllers: [ DiariesController, diff --git a/backend/src/modules/diary/diary.service.ts b/backend/src/modules/diary/diary.service.ts index c7d3133..c0027fa 100644 --- a/backend/src/modules/diary/diary.service.ts +++ b/backend/src/modules/diary/diary.service.ts @@ -25,10 +25,16 @@ import { import { PetService } from '@/modules/pet/pet.service'; import { UploadService } from '@/modules/upload/upload.service'; import { User } from '@/modules/user/entities/user.entity'; +import { + UserLifecycleEvent, + UserLifecycleListener, +} from '@/modules/user/user-lifecycle'; +import { UserService } from '@/modules/user/user.service'; import { isUniqueViolation, retryOnUniqueViolation, } from '@/shared/utils/db-error'; +import { LOCK_SCOPE, lockFor } from '@/shared/utils/db-lock'; import { CommentDto, CreateDiaryDto, @@ -90,7 +96,9 @@ const withFreshInviteCode = (run: () => Promise): Promise => * 다이어리에 함께하는 사람(diary_members, 주인 포함)만 읽고 쓴다 — 그 밖의 사람에게는 없는 것과 똑같이 RES_001. */ @Injectable() -export class DiaryService implements OnModuleInit, PetLifecycleListener { +export class DiaryService + implements OnModuleInit, PetLifecycleListener, UserLifecycleListener +{ private readonly logger = new Logger(DiaryService.name); constructor( @@ -108,10 +116,12 @@ export class DiaryService implements OnModuleInit, PetLifecycleListener { private readonly dataSource: DataSource, private readonly pets: PetService, private readonly uploads: UploadService, + private readonly userService: UserService, ) {} onModuleInit(): void { this.pets.registerListener(this); + this.userService.registerListener(this); } // ── 다이어리 ─────────────────────────────────────────── @@ -526,6 +536,26 @@ export class DiaryService implements OnModuleInit, PetLifecycleListener { await this.comments.delete({ id: comment.id }); } + // ── 회원 탈퇴 ───────────────────────────────────────── + /** + * 주인이 탈퇴하면 다이어리가 기록째 지워진다 — 함께하는 사람이 올린 기록 사진(그 사람 소유)은 + * 더 쓰이지 않게 되므로 커밋 뒤 정리하도록 돌려준다. 주인 자신의 사진은 files CASCADE 로 함께 지워진다. + */ + async onUserDeleting({ + userId, + manager, + }: UserLifecycleEvent): Promise> { + const photos = await manager.find(DiaryPost, { + where: { + diary: { ownerId: userId }, + authorId: Not(userId), + photoFileId: Not(IsNull()), + }, + select: { id: true, photoFileId: true }, + }); + return photos.map((p) => p.photoFileId); + } + // ── 아이 삭제 ───────────────────────────────────────── /** 아이를 지우면 다이어리 대상에서만 뗀다 — 다이어리와 기록은 남긴다 (앱의 dropTarget) */ async onPetDeleting({ @@ -543,16 +573,16 @@ export class DiaryService implements OnModuleInit, PetLifecycleListener { // ── 책장 ─────────────────────────────────────────────── /** * 책장에 한 권 더 꽂을 수 있는지 — 내가 함께하는 다이어리를 센다(추억 보관함의 내 아이만 담은 다이어리는 빼고). - * 동시에 만들거나 참여해 한도를 넘지 않도록 내 참여 행을 잠그고 센다. + * 동시에 만들거나 참여해 한도를 넘지 않도록 회원 단위로 잠그고 센다 — 행 잠금과 달리 아직 한 권도 없을 때도 잠긴다. */ private async assertShelfRoom( manager: EntityManager, userId: string, ): Promise { + await lockFor(manager, LOCK_SCOPE.diaryShelf, userId); const memberships = await manager.find(DiaryMember, { where: { userId }, select: { diaryId: true, userId: true }, - lock: { mode: 'pessimistic_write' }, }); const diaryIds = memberships.map((m) => m.diaryId); const diaries = diff --git a/backend/src/modules/diary/entities/diary.entity.ts b/backend/src/modules/diary/entities/diary.entity.ts index 316524c..deaaf82 100644 --- a/backend/src/modules/diary/entities/diary.entity.ts +++ b/backend/src/modules/diary/entities/diary.entity.ts @@ -77,6 +77,7 @@ export class Diary { @JoinColumn({ name: 'cover_file_id' }) coverFile?: StoredFile | null; + @Index() @Column({ type: 'uuid', nullable: true }) coverFileId!: string | null; @@ -153,6 +154,7 @@ export class DiaryPost { @JoinColumn({ name: 'author_id' }) author?: User; + @Index() @Column({ type: 'uuid' }) authorId!: string; @@ -170,6 +172,7 @@ export class DiaryPost { @JoinColumn({ name: 'photo_file_id' }) photoFile?: StoredFile | null; + @Index() @Column({ type: 'uuid', nullable: true }) photoFileId!: string | null; @@ -199,6 +202,7 @@ export class DiaryComment { @JoinColumn({ name: 'author_id' }) author?: User; + @Index() @Column({ type: 'uuid' }) authorId!: string; diff --git a/backend/src/modules/pet-card/card-snapshot.spec.ts b/backend/src/modules/pet-card/card-snapshot.spec.ts index ee64417..e1d4e59 100644 --- a/backend/src/modules/pet-card/card-snapshot.spec.ts +++ b/backend/src/modules/pet-card/card-snapshot.spec.ts @@ -1,5 +1,9 @@ import type { Pet } from '@/modules/pet/entities/pet.entity'; -import { petSnapshot } from './card-snapshot'; +import { + DETAIL_SHARE_TTL_MS, + effectiveShareMode, + petSnapshot, +} from './card-snapshot'; const pet = { name: '콩이', @@ -49,3 +53,35 @@ describe('petSnapshot', () => { }); }); }); + +describe('내보낼 공유 모양', () => { + const now = new Date('2026-10-20T00:00:00Z'); + const setAt = (msAgo: number) => new Date(now.getTime() - msAgo); + + it('간략히는 언제나 간략히', () => { + expect( + effectiveShareMode({ shareMode: 'simple', shareModeSetAt: null }, now), + ).toBe('simple'); + }); + + it('상세히는 정한 때부터 7일까지만 상세히, 그 뒤는 간략히', () => { + expect( + effectiveShareMode( + { shareMode: 'detail', shareModeSetAt: setAt(DETAIL_SHARE_TTL_MS) }, + now, + ), + ).toBe('detail'); + expect( + effectiveShareMode( + { shareMode: 'detail', shareModeSetAt: setAt(DETAIL_SHARE_TTL_MS + 1) }, + now, + ), + ).toBe('simple'); + }); + + it('정한 때를 모르는 상세히(규칙이 생기기 전)는 지난 것으로 본다', () => { + expect( + effectiveShareMode({ shareMode: 'detail', shareModeSetAt: null }, now), + ).toBe('simple'); + }); +}); diff --git a/backend/src/modules/pet-card/card-snapshot.ts b/backend/src/modules/pet-card/card-snapshot.ts index 046aa45..f0b99cc 100644 --- a/backend/src/modules/pet-card/card-snapshot.ts +++ b/backend/src/modules/pet-card/card-snapshot.ts @@ -7,6 +7,26 @@ import type { SharedPetSnapshot, } from './entities/received-card.entity'; +/** 상세히 공유가 유효한 기간 — 공유 모양을 정한 때부터 7일 */ +export const DETAIL_SHARE_TTL_MS = 7 * 24 * 60 * 60 * 1000; + +/** + * 받는 사람에게 실제로 내보낼 모양. + * 상세히(건강·병원·메모 포함)는 공유 시트를 연 뒤 7일 동안만 상세히로 내보내고, 그 뒤로는 간략히로 내보낸다 — + * 오래전에 퍼진 명함 코드로 건강 정보가 계속 새지 않게 한다. 다시 공유 시트를 열면(공유 모양을 다시 정하면) 7일이 새로 시작한다. + * 정한 때를 모르는 상세히(이 규칙이 생기기 전의 명함)는 이미 지난 것으로 본다. + * 미리보기와 보관함 저장(사본) 모두 이 모양을 쓴다. + */ +export function effectiveShareMode( + card: Pick, + now: Date, +): CardShareMode { + if (card.shareMode !== 'detail') return card.shareMode; + if (card.shareModeSetAt === null) return 'simple'; + const age = now.getTime() - card.shareModeSetAt.getTime(); + return age > DETAIL_SHARE_TTL_MS ? 'simple' : 'detail'; +} + /** * 공유한 모양에 맞춰 아이 정보를 잘라 낸다. * 간략히 명함에 보이지 않는 정보(건강·병원·메모)는 상세히로 공유했을 때만 넘긴다 — 받는 사람에게 더 보여 주지 않는다. diff --git a/backend/src/modules/pet-card/entities/pet-card.entity.ts b/backend/src/modules/pet-card/entities/pet-card.entity.ts index f1aebaa..cf00f3e 100644 --- a/backend/src/modules/pet-card/entities/pet-card.entity.ts +++ b/backend/src/modules/pet-card/entities/pet-card.entity.ts @@ -79,6 +79,7 @@ export class PetCard { @JoinColumn({ name: 'name_bg_file_id' }) nameBgFile?: StoredFile | null; + @Index() @Column({ type: 'uuid', nullable: true }) nameBgFileId!: string | null; @@ -104,6 +105,13 @@ export class PetCard { @Column({ type: 'varchar', length: 6, default: 'simple' }) shareMode!: CardShareMode; + /** + * 공유 모양을 마지막으로 정한 때 — 상세히 공유는 이때부터 7일만 상세히로 내보낸다 + * (card-snapshot.ts 의 effectiveShareMode). 이 값이 생기기 전의 명함은 null. + */ + @Column({ type: 'timestamptz', nullable: true }) + shareModeSetAt!: Date | null; + @CreateDateColumn({ type: 'timestamptz' }) createdAt!: Date; diff --git a/backend/src/modules/pet-card/entities/received-card.entity.ts b/backend/src/modules/pet-card/entities/received-card.entity.ts index 0fcc3a8..2d2c37d 100644 --- a/backend/src/modules/pet-card/entities/received-card.entity.ts +++ b/backend/src/modules/pet-card/entities/received-card.entity.ts @@ -16,6 +16,9 @@ import type { CardShareMode, ScrimTone } from './pet-card.entity'; /** 받은 명함에 남기는 나의 메모 최대 길이 (시안 maxLength 200) */ export const RECEIVED_NOTE_MAX_LENGTH = 200; +/** 보관함에 둘 수 있는 받은 명함 수 — 넘으면 CARD_005 */ +export const MAX_RECEIVED_CARDS = 100; + /** * 받은 명함에 담는 아이 정보 — 받을 때 공유한 모양에 맞춰 잘라 낸 사본. * 간략히는 이름·종·품종·성별·생일·몸무게·등록번호만, 상세히는 건강 정보·병원·메모까지. @@ -54,8 +57,13 @@ export interface SharedCardStyle { /** * 받은 명함(보관함) — 받는 순간의 사본이다. 보낸 사람이 아이 정보를 바꾸거나 명함을 지워도 그대로 남는다. * 사진도 받는 사람 소유로 복사해 둔다 (원본이 지워져도 보이도록). + * 같은 명함은 보관함에 한 번만 둔다 — 원래 명함이 지워져 source_card_id 가 빈 사본은 세지 않는다 (CARD_004). */ @Entity('received_cards') +@Index('UQ_received_cards_owner_source', ['ownerId', 'sourceCardId'], { + unique: true, + where: '"source_card_id" IS NOT NULL', +}) export class ReceivedCard { @PrimaryGeneratedColumn('uuid') id!: string; @@ -74,6 +82,7 @@ export class ReceivedCard { @JoinColumn({ name: 'source_card_id' }) sourceCard?: PetCard | null; + @Index() @Column({ type: 'uuid', nullable: true }) sourceCardId!: string | null; @@ -93,6 +102,7 @@ export class ReceivedCard { @JoinColumn({ name: 'sender_photo_file_id' }) senderPhotoFile?: StoredFile | null; + @Index() @Column({ type: 'uuid', nullable: true }) senderPhotoFileId!: string | null; @@ -103,6 +113,7 @@ export class ReceivedCard { @JoinColumn({ name: 'pet_photo_file_id' }) petPhotoFile?: StoredFile | null; + @Index() @Column({ type: 'uuid', nullable: true }) petPhotoFileId!: string | null; @@ -113,6 +124,7 @@ export class ReceivedCard { @JoinColumn({ name: 'background_file_id' }) backgroundFile?: StoredFile | null; + @Index() @Column({ type: 'uuid', nullable: true }) backgroundFileId!: string | null; diff --git a/backend/src/modules/pet-card/pet-card.service.ts b/backend/src/modules/pet-card/pet-card.service.ts index 2a6d81c..4493145 100644 --- a/backend/src/modules/pet-card/pet-card.service.ts +++ b/backend/src/modules/pet-card/pet-card.service.ts @@ -15,6 +15,7 @@ import { import { PetService } from '@/modules/pet/pet.service'; import { UploadService } from '@/modules/upload/upload.service'; import { isUniqueViolation } from '@/shared/utils/db-error'; +import { LOCK_SCOPE, lockFor } from '@/shared/utils/db-lock'; import { randomCardCode } from './card-code'; import { CreatePetCardDto, @@ -133,7 +134,10 @@ export class PetCardService implements OnModuleInit, PetLifecycleListener { }); } - /** 공유 모양 정하기 — 공유 시트를 열 때 앱이 보낸다. 받는 사람에게는 이 모양의 정보만 간다 */ + /** + * 공유 모양 정하기 — 공유 시트를 열 때 앱이 보낸다. 받는 사람에게는 이 모양의 정보만 간다. + * 정한 때도 남긴다 — 상세히는 그때부터 7일만 상세히로 내보낸다 (card-snapshot.ts 의 effectiveShareMode) + */ async setShareMode( ownerId: string, cardId: string, @@ -141,7 +145,7 @@ export class PetCardService implements OnModuleInit, PetLifecycleListener { ): Promise { const result = await this.cards.update( { id: cardId, ownerId }, - { shareMode: mode }, + { shareMode: mode, shareModeSetAt: new Date() }, ); if (!result.affected) { throw new BusinessException('RES_001', { context: { cardId } }); @@ -174,16 +178,11 @@ export class PetCardService implements OnModuleInit, PetLifecycleListener { 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 }, - }); + // 동시에 만들어 한도를 넘지 않도록 회원 단위로 잠그고 센다 (명함이 하나도 없을 때도 잠긴다) + await lockFor(manager, LOCK_SCOPE.petCards, ownerId); + const count = await manager.countBy(PetCard, { ownerId }); + if (count >= MAX_PET_CARDS) { + throw new BusinessException('CARD_001', { context: { count } }); } const card = await manager.save( diff --git a/backend/src/modules/pet-card/received-card.service.spec.ts b/backend/src/modules/pet-card/received-card.service.spec.ts index 41d5039..9b431ea 100644 --- a/backend/src/modules/pet-card/received-card.service.spec.ts +++ b/backend/src/modules/pet-card/received-card.service.spec.ts @@ -1,4 +1,4 @@ -import { Repository } from 'typeorm'; +import { DataSource, QueryFailedError, Repository } from 'typeorm'; import { UploadService } from '@/modules/upload/upload.service'; import { PetCard } from './entities/pet-card.entity'; @@ -14,6 +14,7 @@ function card(overrides: Partial = {}): PetCard { ownerId: SENDER, code: '0428-117', shareMode: 'simple', + shareModeSetAt: null, tint: '#FF9B6A', tint2: '#FFC59B', nameBg: null, @@ -52,13 +53,32 @@ describe('ReceivedCardService', () => { ), releaseIfUnused: jest.fn(), }; + // 트랜잭션 안팎에서 같은 가짜 manager 를 쓴다 (잠금은 query 로 온다) + const manager = { + existsBy: jest.fn(), + countBy: jest.fn(), + query: jest.fn(), + create: jest.fn((_entity: unknown, v: Partial) => v), + save: jest.fn(), + }; + const dataSource = { + manager, + transaction: jest.fn((run: (m: typeof manager) => Promise) => + run(manager), + ), + }; const service = new ReceivedCardService( cards as unknown as Repository, received as unknown as Repository, + dataSource as unknown as DataSource, uploads as unknown as UploadService, ); - beforeEach(() => jest.clearAllMocks()); + beforeEach(() => { + jest.clearAllMocks(); + manager.existsBy.mockResolvedValue(false); + manager.countBy.mockResolvedValue(0); + }); it('형식이 틀린 코드는 DB 를 보지 않고 CARD_002', async () => { await expect(service.preview(VIEWER, 'abc')).rejects.toMatchObject({ @@ -93,7 +113,7 @@ describe('ReceivedCardService', () => { it('받으면 사진을 내 소유로 복사해 사본을 저장한다', async () => { cards.findOne.mockResolvedValue(card()); - received.save.mockImplementation((v: Partial) => + manager.save.mockImplementation((v: Partial) => Promise.resolve({ ...v, id: 'r1', createdAt: new Date() }), ); @@ -115,7 +135,7 @@ describe('ReceivedCardService', () => { it('저장하지 못하면 복사한 사진을 지운다', async () => { cards.findOne.mockResolvedValue(card()); - received.save.mockRejectedValue(new Error('db down')); + manager.save.mockRejectedValue(new Error('db down')); await expect(service.receive(VIEWER, { code: '0428-117' })).rejects.toThrow( 'db down', @@ -126,4 +146,85 @@ describe('ReceivedCardService', () => { 'copy-of-bg-1', ]); }); + + it('사진 복사가 하나라도 실패하면 이미 만든 사본을 지운다', async () => { + cards.findOne.mockResolvedValue(card()); + uploads.copyFor.mockImplementation((id: string | null) => + id === 'pet-photo' + ? Promise.reject(new Error('disk full')) + : Promise.resolve(id === null ? null : `copy-of-${id}`), + ); + + await expect(service.receive(VIEWER, { code: '0428-117' })).rejects.toThrow( + 'disk full', + ); + expect(uploads.releaseIfUnused).toHaveBeenCalledWith([ + 'copy-of-sender-photo', + null, + 'copy-of-bg-1', + ]); + expect(manager.save).not.toHaveBeenCalled(); + }); + + it('이미 보관함에 있는 명함은 사진을 복사하지 않고 CARD_004', async () => { + cards.findOne.mockResolvedValue(card()); + manager.existsBy.mockResolvedValue(true); + + await expect( + service.receive(VIEWER, { code: '0428-117' }), + ).rejects.toMatchObject({ code: 'CARD_004' }); + expect(uploads.copyFor).not.toHaveBeenCalled(); + }); + + it('보관함이 가득 차면(100장) CARD_005', async () => { + cards.findOne.mockResolvedValue(card()); + manager.countBy.mockResolvedValue(100); + + await expect( + service.receive(VIEWER, { code: '0428-117' }), + ).rejects.toMatchObject({ code: 'CARD_005' }); + }); + + it('동시에 같은 명함을 받아 유니크 제약에 걸리면 사본을 지우고 CARD_004', async () => { + cards.findOne.mockResolvedValue(card()); + uploads.copyFor.mockImplementation((id: string | null) => + Promise.resolve(id === null ? null : `copy-of-${id}`), + ); + manager.save.mockRejectedValue( + new QueryFailedError( + 'INSERT', + [], + Object.assign(new Error('duplicate'), { code: '23505' }), + ), + ); + + await expect( + service.receive(VIEWER, { code: '0428-117' }), + ).rejects.toMatchObject({ code: 'CARD_004' }); + expect(uploads.releaseIfUnused).toHaveBeenCalledWith([ + 'copy-of-sender-photo', + 'copy-of-pet-photo', + 'copy-of-bg-1', + ]); + }); + + it('상세히 공유는 정한 지 7일이 지나면 간략히로 보인다', async () => { + const day = 24 * 60 * 60 * 1000; + cards.findOne.mockResolvedValue( + card({ shareMode: 'detail', shareModeSetAt: new Date(Date.now() - day) }), + ); + const fresh = await service.preview(VIEWER, '0428-117'); + expect(fresh.mode).toBe('detail'); + expect(fresh.pet).toHaveProperty('vet', '드림 동물병원'); + + cards.findOne.mockResolvedValue( + card({ + shareMode: 'detail', + shareModeSetAt: new Date(Date.now() - 8 * day), + }), + ); + const stale = await service.preview(VIEWER, '0428-117'); + expect(stale.mode).toBe('simple'); + expect(stale.pet).not.toHaveProperty('vet'); + }); }); diff --git a/backend/src/modules/pet-card/received-card.service.ts b/backend/src/modules/pet-card/received-card.service.ts index f9a0dc7..ae9e59b 100644 --- a/backend/src/modules/pet-card/received-card.service.ts +++ b/backend/src/modules/pet-card/received-card.service.ts @@ -1,6 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; +import { DataSource, EntityManager, Repository } from 'typeorm'; import { keysetPaginate, mapPage } from '@/common/dto/keyset-pagination'; import { @@ -9,7 +9,13 @@ import { } 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 { isUniqueViolation } from '@/shared/utils/db-error'; +import { LOCK_SCOPE, lockFor } from '@/shared/utils/db-lock'; +import { + cardStyleSnapshot, + effectiveShareMode, + petSnapshot, +} from './card-snapshot'; import { CARD_CODE_PATTERN, ReceiveCardDto, @@ -17,7 +23,10 @@ import { SharedCardPreviewDto, } from './dto/received-card.dto'; import { PetCard } from './entities/pet-card.entity'; -import { ReceivedCard } from './entities/received-card.entity'; +import { + MAX_RECEIVED_CARDS, + ReceivedCard, +} from './entities/received-card.entity'; /** 공유된 명함에서 내려받을 수 있는 사진 */ export const SHARED_IMAGE_KINDS = ['pet', 'sender', 'background'] as const; @@ -33,19 +42,22 @@ export class ReceivedCardService { private readonly cards: Repository, @InjectRepository(ReceivedCard) private readonly received: Repository, + @InjectDataSource() + private readonly dataSource: DataSource, private readonly uploads: UploadService, ) {} - /** 받기 전 미리보기 — 아직 보관하지 않는다 */ + /** 받기 전 미리보기 — 아직 보관하지 않는다. 상세히는 7일이 지나면 간략히로 보인다 */ async preview(viewerId: string, code: string): Promise { const card = await this.findShared(viewerId, code); const pet = card.pet!; + const mode = effectiveShareMode(card, new Date()); return { code: card.code, - mode: card.shareMode, + mode, senderNickname: card.owner?.nickname ?? null, hasSenderPhoto: card.owner?.photoFileId != null, - pet: petSnapshot(pet, card.shareMode), + pet: petSnapshot(pet, mode), hasPetPhoto: pet.photoFileId !== null, style: cardStyleSnapshot(card), hasBackgroundPhoto: card.nameBgFileId !== null, @@ -66,37 +78,53 @@ export class ReceivedCardService { return this.uploads.downloadUnchecked(fileId); } - /** 보관함에 저장 — 받는 순간의 사본을 만들고 사진도 내 소유로 복사한다 */ + /** + * 보관함에 저장 — 받는 순간의 사본을 만들고 사진도 내 소유로 복사한다. + * 같은 명함이 이미 있으면 CARD_004, 보관함이 가득 찼으면(100장) CARD_005. + */ async receive( viewerId: string, dto: ReceiveCardDto, ): Promise { const card = await this.findShared(viewerId, dto.code); const pet = card.pet!; + const mode = effectiveShareMode(card, new Date()); + // 사진을 복사하기 전에 먼저 걸러 본다 — 확정은 아래 트랜잭션에서 잠그고 다시 한다 + await this.assertRoom(this.dataSource.manager, viewerId, card.id); - 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; - + let copies: Array = []; 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, - }), - ); + // 하나가 실패해도 나머지 복사가 끝나기를 기다려 만들어진 사본을 모두 지울 수 있게 한다 + const settled = await Promise.allSettled([ + this.uploads.copyFor(card.owner?.photoFileId ?? null, viewerId), + this.uploads.copyFor(pet.photoFileId, viewerId), + this.uploads.copyFor(card.nameBgFileId, viewerId), + ]); + copies = settled.map((r) => (r.status === 'fulfilled' ? r.value : null)); + const failed = settled.find((r) => r.status === 'rejected'); + if (failed) throw failed.reason; + const [senderPhotoFileId, petPhotoFileId, backgroundFileId] = copies; + + const saved = await this.dataSource.transaction(async (manager) => { + // 동시에 받아 한도를 넘거나 같은 명함이 두 번 들어가지 않게 회원 단위로 잠그고 다시 센다 + await lockFor(manager, LOCK_SCOPE.receivedCards, viewerId); + await this.assertRoom(manager, viewerId, card.id); + return manager.save( + manager.create(ReceivedCard, { + ownerId: viewerId, + sourceCardId: card.id, + code: card.code, + mode, + senderNickname: card.owner?.nickname ?? null, + senderPhotoFileId, + pet: petSnapshot(pet, mode), + petPhotoFileId, + style: cardStyleSnapshot(card), + backgroundFileId, + note: dto.note ?? null, + }), + ); + }); this.logger.log( `명함 받기: ${saved.id} (명함 ${card.id} → 회원 ${viewerId})`, ); @@ -104,10 +132,32 @@ export class ReceivedCardService { } catch (error) { // 저장하지 못했으면 복사한 사진도 지운다 await this.uploads.releaseIfUnused(copies); + if (isUniqueViolation(error)) { + throw new BusinessException('CARD_004', { + context: { cardId: card.id }, + }); + } throw error; } } + /** 보관함에 더 넣을 수 있는지 — 같은 명함이 있으면 CARD_004, 가득 찼으면 CARD_005 */ + private async assertRoom( + manager: EntityManager, + ownerId: string, + sourceCardId: string, + ): Promise { + if (await manager.existsBy(ReceivedCard, { ownerId, sourceCardId })) { + throw new BusinessException('CARD_004', { + context: { cardId: sourceCardId }, + }); + } + const count = await manager.countBy(ReceivedCard, { ownerId }); + if (count >= MAX_RECEIVED_CARDS) { + throw new BusinessException('CARD_005', { context: { count } }); + } + } + /** 보관함 (받은 순) */ async list( ownerId: string, diff --git a/backend/src/modules/pet-card/received-cards.controller.ts b/backend/src/modules/pet-card/received-cards.controller.ts index aac4780..12f3b9d 100644 --- a/backend/src/modules/pet-card/received-cards.controller.ts +++ b/backend/src/modules/pet-card/received-cards.controller.ts @@ -20,8 +20,8 @@ import { ApiParam, ApiTags, } from '@nestjs/swagger'; -import { Throttle } from '@nestjs/throttler'; +import { InviteCodeLookup } from '@/common/decorators/invite-code-lookup.decorator'; import { ApiErrorResponse, ApiPaginatedResponse, @@ -50,16 +50,10 @@ import { } 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) +@InviteCodeLookup() @ApiErrorResponse('AUTH_001', '로그인 필요') @Controller('shared-pet-cards') export class SharedPetCardsController { @@ -123,7 +117,7 @@ export class ReceivedCardsController { } @Post() - @Throttle(CODE_LOOKUP_THROTTLE) + @InviteCodeLookup() @ApiOperation({ summary: '명함 받기 (보관함에 저장)', description: diff --git a/backend/src/modules/pet/entities/pet.entity.ts b/backend/src/modules/pet/entities/pet.entity.ts index 2714c7c..2b2a287 100644 --- a/backend/src/modules/pet/entities/pet.entity.ts +++ b/backend/src/modules/pet/entities/pet.entity.ts @@ -86,6 +86,7 @@ export class Pet { @JoinColumn({ name: 'photo_file_id' }) photoFile?: StoredFile | null; + @Index() @Column({ type: 'uuid', nullable: true }) photoFileId!: string | null; diff --git a/backend/src/modules/schedule/calendar-sharing.controller.ts b/backend/src/modules/schedule/calendar-sharing.controller.ts index 38a25c9..3704226 100644 --- a/backend/src/modules/schedule/calendar-sharing.controller.ts +++ b/backend/src/modules/schedule/calendar-sharing.controller.ts @@ -15,8 +15,8 @@ import { ApiParam, ApiTags, } from '@nestjs/swagger'; -import { Throttle } from '@nestjs/throttler'; +import { InviteCodeLookup } from '@/common/decorators/invite-code-lookup.decorator'; import { ApiErrorResponse, ApiStandardResponse, @@ -31,12 +31,6 @@ import { JoinCalendarDto, } from './dto/calendar-sharing.dto'; -/** - * 초대 코드로 찾는 엔드포인트는 코드 추측의 표적이 된다 — 전역보다 엄격하게 막는다. - * 분당 30회 — 코드를 몇 번 잘못 입력해도 충분하다. - */ -const INVITE_LOOKUP_THROTTLE = { default: { ttl: 60_000, limit: 30 } }; - // 일정 연동 — 내 연동 상태와 연동 해제. 라우팅과 입출력만 담당한다 @ApiTags('Schedule') @ApiBearerAuth() @@ -72,7 +66,7 @@ export class CalendarSharingController { // 일정 연동 초대 — 코드를 받은 회원이 연동하기 전에 미리 본다 @ApiTags('Schedule') @ApiBearerAuth() -@Throttle(INVITE_LOOKUP_THROTTLE) +@InviteCodeLookup() @ApiErrorResponse('AUTH_001', '로그인 필요') @Controller('calendar-invitations') export class CalendarInvitationsController { @@ -103,7 +97,7 @@ export class CalendarMembershipsController { constructor(private readonly sharing: CalendarSharingService) {} @Post() - @Throttle(INVITE_LOOKUP_THROTTLE) + @InviteCodeLookup() @ApiOperation({ summary: '초대 코드로 일정 연동', description: diff --git a/backend/src/modules/schedule/calendar-sharing.service.ts b/backend/src/modules/schedule/calendar-sharing.service.ts index fb880ef..46411a8 100644 --- a/backend/src/modules/schedule/calendar-sharing.service.ts +++ b/backend/src/modules/schedule/calendar-sharing.service.ts @@ -1,11 +1,12 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; -import { DataSource, EntityManager, IsNull, Repository } from 'typeorm'; +import { DataSource, EntityManager, Repository } from 'typeorm'; import { BusinessException } from '@/common/errors/business.exception'; +import { CALENDAR_INVITE_CODE } from '@/modules/user/calendar-invite-code'; import { User } from '@/modules/user/entities/user.entity'; import { retryOnUniqueViolation } from '@/shared/utils/db-error'; -import { CALENDAR_INVITE_CODE } from './calendar-invite-code'; +import { LOCK_SCOPE, lockFor } from '@/shared/utils/db-lock'; import { CalendarInvitationDto, CalendarSharingDto, @@ -20,19 +21,18 @@ import { /** 초대 코드가 겹쳤을 때 다시 뽑는 횟수 — 32^6 가지라 실제로 겹치는 일은 드물다 */ const CODE_RETRY = 5; -/** - * 연동을 바꾸는 일(참여·해제·혼자 남은 묶음 정리)을 한 줄로 세우는 트랜잭션 잠금 키. - * 참여·해제는 드물어 잠금 하나로 충분하다 — 두 사람이 같은 묶음에 동시에 들어와 인원을 넘거나, - * 묶음이 없는 사람에게 동시에 연동해 묶음이 둘 생기는 일을 막는다. - */ -const CALENDAR_SHARING_LOCK_KEY = 7_331_002; +/** 연동 변경은 전체를 한 줄로 세운다 — 참여·해제는 드물어 잠금 하나로 충분하다 */ +const SHARING_LOCK_KEY = 'all'; /** - * 일정 연동 — 최대 4명이 한 달력을 함께 본다. 사람마다 초대 코드가 하나 있고, + * 일정 연동 — 최대 4명이 한 달력을 함께 본다. 사람마다 초대 코드가 하나 있고(가입할 때 만든다), * 남의 코드를 입력하면 그 사람의 묶음에 들어간다(없으면 둘이 새 묶음). 해제하면 나만 빠진다. * + * 초대 코드는 한 번 쓰면 바뀐다 — 연동하면 코드 주인의 코드를, 해제하면 나간 사람의 코드를 새로 뽑는다. + * 예전에 받은 코드로 언제든 다시 들어오지 못하게 한다 (코드만 알면 승인 없이 달력 전체를 보고 고친다). + * * 회원 탈퇴는 FK(CASCADE)로 묶음에서 빠지므로 한 명만 남은 묶음이 생길 수 있다 — - * 그런 묶음은 연동되지 않은 것으로 보고, 손댈 때 지운다. + * 읽을 때는 연동되지 않은 것으로 보고, 지우는 것은 참여·해제할 때 한다 (GET 은 아무것도 쓰지 않는다). */ @Injectable() export class CalendarSharingService { @@ -51,18 +51,11 @@ export class CalendarSharingService { return group.length >= 2 ? group.map((m) => m.userId) : [userId]; } - /** 내 연동 상태 — 초대 코드가 없으면 이때 만든다 */ + /** 내 연동 상태 — 읽기만 한다 */ async getSharing(userId: string): Promise { - const me = await this.userWithInviteCode(userId); - let group = await this.groupOf(this.dataSource.manager, userId, true); - if (group.length === 1) { - // 혼자 남은 묶음 (함께하던 사람이 탈퇴) — 정리한다 - await this.dataSource.transaction(async (manager) => { - await this.lock(manager); - await this.linkedGroupOf(manager, userId); - }); - group = []; - } + const me = await this.users.findOneBy({ id: userId }); + if (!me) throw new BusinessException('RES_001', { context: { userId } }); + const group = await this.groupOf(this.dataSource.manager, userId, true); return CalendarSharingDto.from(userId, me, me.calendarInviteCode, group); } @@ -71,7 +64,7 @@ export class CalendarSharingService { userId: string, rawCode: string, ): Promise { - const owner = await this.findCodeOwner(rawCode); + const owner = await this.findCodeOwner(this.dataSource.manager, rawCode); const isMine = owner.id === userId; const theirs = await this.groupOf(this.dataSource.manager, owner.id); const linked = theirs.length >= 2; @@ -88,85 +81,95 @@ export class CalendarSharingService { * 초대 코드로 연동 — 이 순서로 확인한다: * 없는 코드(SCHED_001) → 내 코드(SCHED_003) → 이미 같은 묶음(SCHED_005) → * 내가 다른 사람과 연동 중(SCHED_004) → 상대 묶음이 가득 참(SCHED_002). - * 상대가 아직 아무와도 연동하지 않았으면 둘이 새 묶음을 만든다. + * 상대가 아직 아무와도 연동하지 않았으면 둘이 새 묶음을 만든다. 연동하면 코드 주인의 코드를 바꾼다. */ async join( userId: string, dto: JoinCalendarDto, ): Promise { - const owner = await this.findCodeOwner(dto.code); - if (owner.id === userId) { - throw new BusinessException('SCHED_003', { context: { userId } }); - } + const ownerId = await retryOnUniqueViolation( + () => + this.dataSource.transaction(async (manager) => { + await lockFor(manager, LOCK_SCOPE.calendarSharing, SHARING_LOCK_KEY); + // 코드는 잠근 뒤에 찾는다 — 먼저 들어온 사람이 이 코드를 쓰고 바꿨다면 SCHED_001 이 된다 + const owner = await this.findCodeOwner(manager, dto.code); + if (owner.id === userId) { + throw new BusinessException('SCHED_003', { context: { userId } }); + } - await this.dataSource.transaction(async (manager) => { - await this.lock(manager); - const mine = await this.linkedGroupOf(manager, userId); - const theirs = await this.linkedGroupOf(manager, owner.id); + const mine = await this.linkedGroupOf(manager, userId); + const theirs = await this.linkedGroupOf(manager, owner.id); + const myGroupId = mine.length > 0 ? mine[0].groupId : null; + const groupId = theirs.length > 0 ? theirs[0].groupId : null; + if (groupId !== null && myGroupId === groupId) { + throw new BusinessException('SCHED_005', { context: { groupId } }); + } + if (myGroupId !== null) { + throw new BusinessException('SCHED_004', { + context: { groupId: myGroupId }, + }); + } + if (theirs.length >= CALENDAR_GROUP_MAX_MEMBERS) { + throw new BusinessException('SCHED_002', { + context: { groupId, members: theirs.length }, + }); + } - const myGroupId = mine.length > 0 ? mine[0].groupId : null; - const groupId = theirs.length > 0 ? theirs[0].groupId : null; - if (groupId !== null && myGroupId === groupId) { - throw new BusinessException('SCHED_005', { context: { groupId } }); - } - if (myGroupId !== null) { - throw new BusinessException('SCHED_004', { - context: { groupId: myGroupId }, - }); - } - if (theirs.length >= CALENDAR_GROUP_MAX_MEMBERS) { - throw new BusinessException('SCHED_002', { - context: { groupId, members: theirs.length }, - }); - } + if (groupId !== null) { + await manager.insert(CalendarGroupMember, { groupId, userId }); + } else { + const group = await manager.save(manager.create(CalendarGroup, {})); + await manager.insert(CalendarGroupMember, { + groupId: group.id, + userId: owner.id, + }); + // 코드 주인이 먼저 든 것으로 둔다 — 기본값 now() 는 트랜잭션 시작 시각이라 둘이 같아진다 + await manager.insert(CalendarGroupMember, { + groupId: group.id, + userId, + joinedAt: () => 'clock_timestamp()', + }); + } + // 쓴 코드는 바꾼다 — 겹치면(유니크 위반) 처음부터 다시 한다 + await this.rotateCode(manager, owner.id); + return owner.id; + }), + CODE_RETRY, + ); - if (groupId !== null) { - await manager.insert(CalendarGroupMember, { groupId, userId }); - return; - } - const group = await manager.save(manager.create(CalendarGroup, {})); - await manager.insert(CalendarGroupMember, { - groupId: group.id, - userId: owner.id, - }); - // 코드 주인이 먼저 든 것으로 둔다 — 기본값 now() 는 트랜잭션 시작 시각이라 둘이 같아진다 - await manager.insert(CalendarGroupMember, { - groupId: group.id, - userId, - joinedAt: () => 'clock_timestamp()', - }); - }); - - this.logger.log(`일정 연동: 회원 ${userId} → 회원 ${owner.id} 의 묶음`); + this.logger.log(`일정 연동: 회원 ${userId} → 회원 ${ownerId} 의 묶음`); return this.getSharing(userId); } - /** 연동 해제 — 나만 빠진다(내 일정은 그대로). 한 명만 남으면 묶음을 지운다. 연동 중이 아니면 아무 일도 없다 */ + /** + * 연동 해제 — 나만 빠진다(내 일정은 그대로). 한 명만 남으면 묶음을 지운다. + * 빠졌으면 내 코드를 바꾼다 (예전 묶음 사람들이 갖고 있던 내 코드로 다시 묶이지 않게). + * 연동 중이 아니면 아무 일도 없다. + */ async leave(userId: string): Promise { - const left = await this.dataSource.transaction(async (manager) => { - await this.lock(manager); - const mine = await manager.findOneBy(CalendarGroupMember, { userId }); - if (!mine) return false; + const left = await retryOnUniqueViolation( + () => + this.dataSource.transaction(async (manager) => { + await lockFor(manager, LOCK_SCOPE.calendarSharing, SHARING_LOCK_KEY); + const mine = await manager.findOneBy(CalendarGroupMember, { userId }); + if (!mine) return false; - await manager.delete(CalendarGroupMember, { userId }); - const remaining = await manager.countBy(CalendarGroupMember, { - groupId: mine.groupId, - }); - if (remaining < 2) { - await manager.delete(CalendarGroup, { id: mine.groupId }); - } - return true; - }); + await manager.delete(CalendarGroupMember, { userId }); + const remaining = await manager.countBy(CalendarGroupMember, { + groupId: mine.groupId, + }); + if (remaining < 2) { + await manager.delete(CalendarGroup, { id: mine.groupId }); + } + await this.rotateCode(manager, userId); + return true; + }), + CODE_RETRY, + ); if (left) this.logger.log(`일정 연동 해제: 회원 ${userId}`); } // ── 도우미 ───────────────────────────────────────────── - private async lock(manager: EntityManager): Promise { - await manager.query('SELECT pg_advisory_xact_lock($1)', [ - CALENDAR_SHARING_LOCK_KEY, - ]); - } - /** [userId] 가 든 묶음의 사람들 (연동한 순서) — 묶음이 없으면 [] */ private async groupOf( manager: EntityManager, @@ -195,41 +198,31 @@ export class CalendarSharingService { return group; } + /** 새 초대 코드로 바꾼다 — 겹치면 유니크 위반이 나고, 호출한 쪽이 트랜잭션을 다시 한다 */ + private async rotateCode( + manager: EntityManager, + userId: string, + ): Promise { + await manager.update( + User, + { id: userId }, + { calendarInviteCode: CALENDAR_INVITE_CODE.random() }, + ); + } + /** 초대 코드의 주인 — 형식이 틀리거나 없는 코드는 SCHED_001 */ - private async findCodeOwner(rawCode: string): Promise { + private async findCodeOwner( + manager: EntityManager, + rawCode: string, + ): Promise { const code = CALENDAR_INVITE_CODE.normalize(rawCode); const owner = code === null ? null - : await this.users.findOneBy({ calendarInviteCode: code }); + : await manager.findOneBy(User, { calendarInviteCode: code }); if (!owner) { throw new BusinessException('SCHED_001', { context: { code: rawCode } }); } return owner; } - - /** 회원과 그 초대 코드 — 아직 없으면 만든다 (동시에 만들어도 먼저 들어간 값 하나만 남는다) */ - private async userWithInviteCode( - userId: string, - ): Promise { - const user = await this.users.findOneBy({ id: userId }); - if (!user) throw new BusinessException('RES_001', { context: { userId } }); - if (user.calendarInviteCode !== null) { - return { ...user, calendarInviteCode: user.calendarInviteCode }; - } - - await retryOnUniqueViolation( - () => - this.users.update( - { id: userId, calendarInviteCode: IsNull() }, - { calendarInviteCode: CALENDAR_INVITE_CODE.random() }, - ), - CODE_RETRY, - ); - const updated = await this.users.findOneBy({ id: userId }); - if (updated?.calendarInviteCode == null) { - throw new BusinessException('RES_001', { context: { userId } }); - } - return { ...updated, calendarInviteCode: updated.calendarInviteCode }; - } } diff --git a/backend/src/modules/upload/file-sweep.spec.ts b/backend/src/modules/upload/file-sweep.spec.ts new file mode 100644 index 0000000..2da9a71 --- /dev/null +++ b/backend/src/modules/upload/file-sweep.spec.ts @@ -0,0 +1,64 @@ +import { fileReferencesOf, orphanFilesSql } from './file-sweep'; +import type { EntityShape } from './file-sweep'; + +const entity = ( + tableName: string, + relations: Array<[target: string, column: string]>, +): EntityShape => ({ + tableName, + manyToOneRelations: relations.map(([target, column]) => ({ + inverseEntityMetadata: { tableName: target }, + joinColumns: [{ databaseName: column }], + })), +}); + +describe('쓰이지 않는 파일 찾기', () => { + const entities = [ + entity('users', [['files', 'photo_file_id']]), + entity('pets', [ + ['users', 'owner_id'], + ['files', 'photo_file_id'], + ]), + entity('received_cards', [ + ['users', 'owner_id'], + ['files', 'sender_photo_file_id'], + ['files', 'pet_photo_file_id'], + ]), + // 파일 표 자신의 owner_id 는 사용자를 가리킨다 — 파일 참조가 아니다 + entity('files', [['users', 'owner_id']]), + ]; + + it('파일 표를 가리키는 FK 컬럼만 모은다', () => { + expect(fileReferencesOf(entities, 'files')).toEqual([ + { table: 'users', column: 'photo_file_id' }, + { table: 'pets', column: 'photo_file_id' }, + { table: 'received_cards', column: 'sender_photo_file_id' }, + { table: 'received_cards', column: 'pet_photo_file_id' }, + ]); + }); + + it('오래됐고 어느 컬럼도 가리키지 않는 파일만, 오래된 것부터 정해진 개수까지 고른다', () => { + const sql = orphanFilesSql('files', fileReferencesOf(entities, 'files')); + + expect(sql).toContain('FROM "files" f'); + expect(sql).toContain('WHERE f.created_at < $1'); + expect(sql).toContain( + 'AND NOT EXISTS (SELECT 1 FROM "users" r WHERE r."photo_file_id" = f.id)', + ); + expect(sql).toContain( + 'AND NOT EXISTS (SELECT 1 FROM "received_cards" r WHERE r."pet_photo_file_id" = f.id)', + ); + expect(sql.match(/NOT EXISTS/g)).toHaveLength(4); + expect(sql).toMatch(/ORDER BY f\.created_at\s+LIMIT \$2$/); + }); + + it('참조가 없으면 오래된 파일을 모두 고른다', () => { + expect(orphanFilesSql('files', [])).not.toContain('NOT EXISTS'); + }); + + it('이름 형식이 이상하면 SQL 을 만들지 않는다', () => { + expect(() => + orphanFilesSql('files', [{ table: 'users"; DROP', column: 'x' }]), + ).toThrow(/쓸 수 없는 이름/); + }); +}); diff --git a/backend/src/modules/upload/file-sweep.ts b/backend/src/modules/upload/file-sweep.ts new file mode 100644 index 0000000..47d381b --- /dev/null +++ b/backend/src/modules/upload/file-sweep.ts @@ -0,0 +1,68 @@ +// 아무도 쓰지 않는 파일 찾기 — 부수효과 없는 순수 함수 (실제 정리는 FileSweeper 가 한다) + +/** 파일을 가리키는 FK 컬럼 하나 */ +export interface FileReference { + readonly table: string; + readonly column: string; +} + +/** fileReferencesOf 가 읽는 엔티티 정의 (TypeORM EntityMetadata 의 일부) */ +export interface EntityShape { + readonly tableName: string; + readonly manyToOneRelations: ReadonlyArray<{ + readonly inverseEntityMetadata: { readonly tableName: string }; + readonly joinColumns: ReadonlyArray<{ readonly databaseName: string }>; + }>; +} + +/** SQL 에 그대로 넣는 이름 — 엔티티 정의에서 왔더라도 형식을 확인한다 */ +const IDENTIFIER = /^[a-z_][a-z0-9_]*$/; + +/** + * 파일 표([filesTable])를 가리키는 FK 컬럼 — 엔티티 정의에서 찾는다. + * 새 엔티티가 사진을 가리켜도(ManyToOne → StoredFile) 여기에 따로 적지 않아도 정리 대상에서 빠진다. + */ +export function fileReferencesOf( + entities: readonly EntityShape[], + filesTable: string, +): FileReference[] { + const refs: FileReference[] = []; + for (const entity of entities) { + for (const relation of entity.manyToOneRelations) { + if (relation.inverseEntityMetadata.tableName !== filesTable) continue; + for (const column of relation.joinColumns) { + refs.push({ table: entity.tableName, column: column.databaseName }); + } + } + } + return refs; +} + +/** + * 오래됐고($1 보다 먼저 올린) 어느 FK 도 가리키지 않는 파일을 찾는 SQL — 한 번에 $2 개까지, 오래된 것부터. + * 고른 뒤 실제로 지울 때(UploadService.releaseIfUnused) FK 가 한 번 더 막으므로, 그사이 쓰이기 시작한 파일은 지워지지 않는다. + */ +export function orphanFilesSql( + filesTable: string, + refs: readonly FileReference[], +): string { + for (const name of [ + filesTable, + ...refs.flatMap((r) => [r.table, r.column]), + ]) { + if (!IDENTIFIER.test(name)) { + throw new Error(`파일 정리 SQL 에 쓸 수 없는 이름입니다: ${name}`); + } + } + const unreferenced = refs.map( + (r) => + `AND NOT EXISTS (SELECT 1 FROM "${r.table}" r WHERE r."${r.column}" = f.id)`, + ); + return [ + `SELECT f.id FROM "${filesTable}" f`, + 'WHERE f.created_at < $1', + ...unreferenced, + 'ORDER BY f.created_at', + 'LIMIT $2', + ].join('\n'); +} diff --git a/backend/src/modules/upload/file-sweeper.ts b/backend/src/modules/upload/file-sweeper.ts new file mode 100644 index 0000000..a92d7c2 --- /dev/null +++ b/backend/src/modules/upload/file-sweeper.ts @@ -0,0 +1,76 @@ +import { + Injectable, + Logger, + OnApplicationShutdown, + OnModuleInit, +} from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; + +import { StoredFile } from './entities/stored-file.entity'; +import { fileReferencesOf, orphanFilesSql } from './file-sweep'; +import { UploadService } from './upload.service'; + +/** 정리 주기 — 한 시간마다 */ +const SWEEP_INTERVAL_MS = 60 * 60 * 1000; +/** 올린 뒤 이만큼 지나도 아무도 쓰지 않는 파일만 지운다 — 사진을 올리고 저장하기 전의 파일을 지우지 않게 */ +const ORPHAN_AGE_MS = 24 * 60 * 60 * 1000; +/** 한 번에 지우는 최대 개수 — 남으면 다음 주기에 이어서 */ +const SWEEP_BATCH = 500; + +/** + * 쓰이지 않는 파일 정리 — 올렸지만 어디에도 붙이지 않은 사진, 다른 기록이 지워지며 남은 사진 등. + * 새 의존성 없이 setInterval 로 돈다. 종료할 때 타이머를 멈춘다 (graceful shutdown — shutdown.logger.ts 참고). + */ +@Injectable() +export class FileSweeper implements OnModuleInit, OnApplicationShutdown { + private readonly logger = new Logger(FileSweeper.name); + private timer: NodeJS.Timeout | null = null; + private running = false; + + constructor( + @InjectDataSource() + private readonly dataSource: DataSource, + private readonly uploads: UploadService, + ) {} + + onModuleInit(): void { + this.timer = setInterval(() => void this.sweep(), SWEEP_INTERVAL_MS); + // 타이머 때문에 프로세스가 끝나지 못하는 일이 없게 한다 + this.timer.unref(); + } + + onApplicationShutdown(): void { + if (this.timer !== null) clearInterval(this.timer); + this.timer = null; + } + + /** 한 번 정리한다 — 지운 파일 수. 앞의 정리가 아직 돌고 있으면 건너뛴다 */ + async sweep(now: Date = new Date()): Promise { + if (this.running) return 0; + this.running = true; + try { + const filesTable = this.dataSource.getMetadata(StoredFile).tableName; + const refs = fileReferencesOf( + this.dataSource.entityMetadatas, + filesTable, + ); + const rows = await this.dataSource.query<{ id: string }[]>( + orphanFilesSql(filesTable, refs), + [new Date(now.getTime() - ORPHAN_AGE_MS), SWEEP_BATCH], + ); + await this.uploads.releaseIfUnused(rows.map((r) => r.id)); + if (rows.length > 0) { + this.logger.log(`쓰이지 않는 파일 정리: ${rows.length}개`); + } + return rows.length; + } catch (error) { + this.logger.error( + `파일 정리 실패: ${error instanceof Error ? error.message : String(error)}`, + ); + return 0; + } finally { + this.running = false; + } + } +} diff --git a/backend/src/modules/upload/upload.module.ts b/backend/src/modules/upload/upload.module.ts index 77e511a..6a6e478 100644 --- a/backend/src/modules/upload/upload.module.ts +++ b/backend/src/modules/upload/upload.module.ts @@ -13,6 +13,7 @@ import { isAllowedFile, } from './upload.config'; import { StoredFile } from './entities/stored-file.entity'; +import { FileSweeper } from './file-sweeper'; import { UploadController } from './upload.controller'; import { UploadService } from './upload.service'; @@ -50,7 +51,7 @@ mkdirSync(UPLOAD_DIR, { recursive: true }); }), ], controllers: [UploadController], - providers: [UploadService], + providers: [UploadService, FileSweeper], exports: [UploadService], }) export class UploadModule {} diff --git a/backend/src/modules/schedule/calendar-invite-code.ts b/backend/src/modules/user/calendar-invite-code.ts similarity index 62% rename from backend/src/modules/schedule/calendar-invite-code.ts rename to backend/src/modules/user/calendar-invite-code.ts index 5cc06e7..afec544 100644 --- a/backend/src/modules/schedule/calendar-invite-code.ts +++ b/backend/src/modules/user/calendar-invite-code.ts @@ -2,6 +2,8 @@ import { inviteCodeFormat } from '@/shared/utils/invite-code'; /** * 일정 연동 초대 코드 `RUP-XXXXXX` (뒷부분 6자) — users.calendar_invite_code 의 길이(10)와 맞춘다. - * 코드만 알면 승인 없이 달력 전체를 함께 보고 고치게 되므로 다이어리와 같은 길이로 짐작하기 어렵게 둔다. + * 코드만 알면 승인 없이 달력 전체를 함께 보고 고치게 되므로 다이어리와 같은 길이로 짐작하기 어렵게 두고, + * 한 번 쓰이면(연동·해제) 새 코드로 바꾼다 (schedule/calendar-sharing.service.ts). + * 가입할 때 만든다 (UserService) — 연동 화면을 여는 GET 은 아무것도 쓰지 않는다. */ export const CALENDAR_INVITE_CODE = inviteCodeFormat(6); diff --git a/backend/src/modules/user/entities/user.entity.ts b/backend/src/modules/user/entities/user.entity.ts index 9ae391d..52a40ea 100644 --- a/backend/src/modules/user/entities/user.entity.ts +++ b/backend/src/modules/user/entities/user.entity.ts @@ -2,6 +2,7 @@ import { Column, CreateDateColumn, Entity, + Index, JoinColumn, ManyToOne, PrimaryGeneratedColumn, @@ -31,15 +32,16 @@ export class User { @JoinColumn({ name: 'photo_file_id' }) photoFile?: StoredFile | null; + @Index() @Column({ type: 'uuid', nullable: true }) photoFileId!: string | null; /** - * 일정 연동 초대 코드 `RUP-XXXXXX` — 사람마다 하나. 처음 연동 화면을 열 때 만든다 - * (CalendarSharingService). 길이는 schedule/calendar-invite-code.ts 의 형식과 맞춘다. + * 일정 연동 초대 코드 `RUP-XXXXXX` — 사람마다 하나. 가입할 때 만들고(UserService), + * 연동·해제에 쓰이면 새로 바꾼다 (CalendarSharingService). 형식은 ../calendar-invite-code.ts */ - @Column({ type: 'varchar', length: 10, nullable: true, unique: true }) - calendarInviteCode!: string | null; + @Column({ type: 'varchar', length: 10, unique: true }) + calendarInviteCode!: string; @CreateDateColumn({ type: 'timestamptz' }) createdAt!: Date; diff --git a/backend/src/modules/user/user-lifecycle.ts b/backend/src/modules/user/user-lifecycle.ts new file mode 100644 index 0000000..b56e26c --- /dev/null +++ b/backend/src/modules/user/user-lifecycle.ts @@ -0,0 +1,24 @@ +import { EntityManager } from 'typeorm'; + +/** 회원 탈퇴 — 다른 도메인이 자기 기록을 맞춘다 */ +export interface UserLifecycleEvent { + readonly userId: string; + /** 회원을 지우는 트랜잭션 — 지우기 전 상태를 이 안에서 읽는다 */ + readonly manager: EntityManager; +} + +/** + * 회원 탈퇴를 따라 정리해야 하는 도메인이 구현한다. + * 각 모듈이 onModuleInit 에서 UserService.registerListener 로 등록한다 — + * 회원 모듈이 다른 도메인을 알지 않아도 된다(의존 방향: 다른 도메인 → 회원). pet-lifecycle.ts 와 같은 방식이다. + */ +export interface UserLifecycleListener { + /** + * 회원을 지우기 직전. 회원의 기록이 CASCADE 로 지워지면서 **남의 파일**이 더는 쓰이지 않게 되면 + * (예: 내 다이어리에 함께하는 사람이 올린 사진) 그 파일 id 를 돌려준다 — 커밋한 뒤 정리한다. + * 회원 자신의 파일은 files.owner_id CASCADE 로 함께 지워지므로 돌려주지 않아도 된다. + */ + onUserDeleting( + event: UserLifecycleEvent, + ): Promise>; +} diff --git a/backend/src/modules/user/user.service.ts b/backend/src/modules/user/user.service.ts index 7739bb2..84c6970 100644 --- a/backend/src/modules/user/user.service.ts +++ b/backend/src/modules/user/user.service.ts @@ -5,6 +5,7 @@ import { DataSource, Repository } from 'typeorm'; import { BusinessException } from '@/common/errors/business.exception'; import { UploadService } from '@/modules/upload/upload.service'; import { isUniqueViolation } from '@/shared/utils/db-error'; +import { CALENDAR_INVITE_CODE } from './calendar-invite-code'; import { UpdateMeDto } from './dto/update-me.dto'; import { UserResponseDto } from './dto/user-response.dto'; import { @@ -12,6 +13,10 @@ import { VerifiedSocialAccount, } from './entities/social-account.entity'; import { NICKNAME_MAX_LENGTH, User } from './entities/user.entity'; +import { UserLifecycleListener } from './user-lifecycle'; + +/** 가입할 때 일정 연동 초대 코드가 겹치면 다시 뽑는 횟수 — 32^6 가지라 실제로 겹치는 일은 드물다 */ +const CODE_RETRY = 5; /** 가입·로그인 결과 */ export interface SignedInUser { @@ -23,6 +28,7 @@ export interface SignedInUser { @Injectable() export class UserService { private readonly logger = new Logger(UserService.name); + private readonly listeners: UserLifecycleListener[] = []; constructor( @InjectRepository(User) @@ -33,6 +39,11 @@ export class UserService { private readonly uploads: UploadService, ) {} + /** 회원 탈퇴를 따라 정리할 도메인을 등록한다 (user-lifecycle.ts) */ + registerListener(listener: UserLifecycleListener): void { + this.listeners.push(listener); + } + /** 액세스 토큰의 회원이 아직 있는지 — 탈퇴한 회원의 토큰은 만료 전이라도 거절한다 */ exists(userId: string): Promise { return this.users.existsBy({ id: userId }); @@ -45,35 +56,39 @@ export class UserService { const found = await this.findBySocial(account); if (found) return { user: found, isNewUser: false }; - try { - const user = await this.dataSource.transaction(async (manager) => { - const created = await manager.save( - manager.create(User, { - nickname: account.nickname?.slice(0, NICKNAME_MAX_LENGTH) || null, - photoFileId: null, - }), - ); - await manager.save( - manager.create(SocialAccount, { - userId: created.id, - provider: account.provider, - subject: account.subject, - }), - ); - return created; - }); + for (let attempt = 1; ; attempt += 1) { + try { + const user = await this.dataSource.transaction(async (manager) => { + const created = await manager.save( + manager.create(User, { + nickname: account.nickname?.slice(0, NICKNAME_MAX_LENGTH) || null, + photoFileId: null, + calendarInviteCode: CALENDAR_INVITE_CODE.random(), + }), + ); + await manager.save( + manager.create(SocialAccount, { + userId: created.id, + provider: account.provider, + subject: account.subject, + }), + ); + return created; + }); - this.logger.log(`회원 가입: ${user.id} (${account.provider})`); - return { - user: UserResponseDto.from(user, account.provider), - isNewUser: true, - }; - } catch (error) { - // 같은 계정으로 동시에 가입하면 유니크 제약에 걸린다 — 먼저 만들어진 회원으로 로그인한다 - if (!isUniqueViolation(error)) throw error; - const raced = await this.findBySocial(account); - if (!raced) throw error; - return { user: raced, isNewUser: false }; + this.logger.log(`회원 가입: ${user.id} (${account.provider})`); + return { + user: UserResponseDto.from(user, account.provider), + isNewUser: true, + }; + } catch (error) { + if (!isUniqueViolation(error)) throw error; + // 같은 계정으로 동시에 가입하면 유니크 제약에 걸린다 — 먼저 만들어진 회원으로 로그인한다 + const raced = await this.findBySocial(account); + if (raced) return { user: raced, isNewUser: false }; + // 아니면 일정 연동 초대 코드가 겹친 것 — 새 코드로 다시 가입시킨다 + if (attempt >= CODE_RETRY) throw error; + } } } @@ -109,15 +124,24 @@ export class UserService { /** * 회원탈퇴 — 회원과 그 회원의 모든 기록(아이·일정·다이어리·명함·파일 기록)을 지운다. * 기록은 FK(ON DELETE CASCADE)로 함께 지워지고, 실제 파일은 그 뒤에 디스크에서 정리한다. + * 함께 지워지는 기록이 남의 파일을 쓰고 있었다면(내 다이어리에 함께하는 사람이 올린 사진) 그 파일도 정리한다. */ async withdraw(userId: string): Promise { const storedNames = await this.uploads.storedNamesOf(userId); - const result = await this.users.delete({ id: userId }); - if (!result.affected) { - throw new BusinessException('RES_001', { context: { userId } }); - } + const othersFiles = await this.dataSource.transaction(async (manager) => { + const fileIds: Array = []; + for (const listener of this.listeners) { + fileIds.push(...(await listener.onUserDeleting({ userId, manager }))); + } + const result = await manager.delete(User, { id: userId }); + if (!result.affected) { + throw new BusinessException('RES_001', { context: { userId } }); + } + return fileIds; + }); for (const name of storedNames) await this.uploads.unlinkQuietly(name); + await this.uploads.releaseIfUnused(othersFiles); this.logger.log(`회원 탈퇴: ${userId} (파일 ${storedNames.length}개 정리)`); } diff --git a/backend/src/shared/config/env.validation.spec.ts b/backend/src/shared/config/env.validation.spec.ts index eaef90d..b1cbf9f 100644 --- a/backend/src/shared/config/env.validation.spec.ts +++ b/backend/src/shared/config/env.validation.spec.ts @@ -47,6 +47,44 @@ describe('validateEnv', () => { }); }); + describe('JWT_SECRET — development·test 밖에서는 필수 (allowlist)', () => { + it('development·test 에서는 비어 있어도 통과한다 (개발용 키를 쓴다)', () => { + expect(() => validateEnv({ NODE_ENV: 'development' })).not.toThrow(); + expect(() => validateEnv({ NODE_ENV: 'test' })).not.toThrow(); + }); + + it('staging·오타·미설정처럼 목록 밖의 환경은 JWT_SECRET 이 없으면 실패한다', () => { + for (const config of [ + { NODE_ENV: 'staging' }, + { NODE_ENV: 'prod' }, + { NODE_ENV: 'Development' }, + {}, + ]) { + expect(() => validateEnv(config)).toThrow( + /JWT_SECRET 가 설정되지 않았습니다/, + ); + } + }); + + it('목록 밖의 환경에서는 짧은 키·예시 값도 막는다', () => { + expect(() => + validateEnv({ NODE_ENV: 'staging', JWT_SECRET: 'short-secret' }), + ).toThrow(/JWT_SECRET 는 32자 이상/); + expect(() => + validateEnv({ + NODE_ENV: 'staging', + JWT_SECRET: 'change-me-please-change-me-please-123', + }), + ).toThrow(/JWT_SECRET 가 예시 값 그대로입니다/); + expect(() => + validateEnv({ + NODE_ENV: 'staging', + JWT_SECRET: 'a-staging-secret-that-is-long-enough-123', + }), + ).not.toThrow(); + }); + }); + describe('운영 환경', () => { it('정상 설정이면 통과한다', () => { expect(() => validateEnv(prod())).not.toThrow(); diff --git a/backend/src/shared/config/env.validation.ts b/backend/src/shared/config/env.validation.ts index a3ed9ed..43bf36a 100644 --- a/backend/src/shared/config/env.validation.ts +++ b/backend/src/shared/config/env.validation.ts @@ -7,6 +7,7 @@ * `ConfigModule.forRoot({ validate })` 에 연결되어 있다 (app.module.ts). */ +import { isDevNodeEnv } from '@/shared/config/node-env'; import { parseDurationMs } from '@/shared/utils/duration'; /** 운영 배포 전에 반드시 교체해야 하는 예시 값들 — `*.example` 에서 복사한 흔적 */ @@ -18,11 +19,9 @@ const REQUIRED_IN_PRODUCTION = [ 'DB_USER', 'DB_PASSWORD', 'DB_NAME', - // 비워 두면 개발용 고정 키로 서명하게 된다 — 운영에서는 기동을 막는다 (modules/auth/auth.config.ts) - 'JWT_SECRET', ] as const; -/** 운영 JWT 서명 키 최소 길이 — 짧은 키는 무차별 대입으로 풀린다 */ +/** JWT 서명 키 최소 길이 (development·test 밖) — 짧은 키는 무차별 대입으로 풀린다 */ const JWT_SECRET_MIN_LENGTH = 32; /** 값이 있다면 `15m`·`1h`·`14d` 형식이어야 하는 키 */ @@ -40,11 +39,7 @@ const POSITIVE_INT_KEYS = [ ] as const; /** 운영 환경에서 placeholder 로 남아 있으면 안 되는 키 */ -const NO_PLACEHOLDER_IN_PRODUCTION = [ - 'DB_PASSWORD', - 'REDIS_PASSWORD', - 'JWT_SECRET', -] as const; +const NO_PLACEHOLDER_IN_PRODUCTION = ['DB_PASSWORD', 'REDIS_PASSWORD'] as const; /** * 운영에서 **공개 주소**여야 하는 키. @@ -94,7 +89,8 @@ function looksNonPublic(value: string): boolean { export function validateEnv( config: Record, ): Record { - const isProduction = readString(config, 'NODE_ENV') === 'production'; + const nodeEnv = readString(config, 'NODE_ENV'); + const isProduction = nodeEnv === 'production'; const errors: string[] = []; // 1. 숫자여야 하는 값 — 비어 있으면 코드의 기본값을 쓰므로 통과시킨다 @@ -118,6 +114,25 @@ export function validateEnv( } } + // JWT 서명 키 — 개발용 키·개발용 로그인은 development·test 에서만 열린다 (shared/config/node-env.ts). + // 그 밖의 환경(staging·production·미설정 …)에서 비워 두면 개발용 고정 키로 서명하게 되므로 기동을 막는다 + if (!isDevNodeEnv(nodeEnv)) { + const jwtSecret = readString(config, 'JWT_SECRET'); + if (jwtSecret === undefined) { + errors.push( + 'JWT_SECRET 가 설정되지 않았습니다. (development·test 밖에서는 필수)', + ); + } else if (looksLikePlaceholder(jwtSecret)) { + errors.push( + 'JWT_SECRET 가 예시 값 그대로입니다. 운영용 값으로 교체해 주세요.', + ); + } else if (jwtSecret.length < JWT_SECRET_MIN_LENGTH) { + errors.push( + `JWT_SECRET 는 ${JWT_SECRET_MIN_LENGTH}자 이상이어야 합니다. (development·test 밖)`, + ); + } + } + if (isProduction) { // 2. 운영 필수값 — 없으면 기동 실패 for (const key of REQUIRED_IN_PRODUCTION) { @@ -136,13 +151,6 @@ export function validateEnv( } } - const jwtSecret = readString(config, 'JWT_SECRET'); - if (jwtSecret !== undefined && jwtSecret.length < JWT_SECRET_MIN_LENGTH) { - errors.push( - `JWT_SECRET 는 ${JWT_SECRET_MIN_LENGTH}자 이상이어야 합니다. (운영 환경)`, - ); - } - // 4. 공개 주소가 로컬 주소·예시 도메인으로 남은 채 배포되는 것을 막는다 const allowLocalhostUrl = readString(config, ALLOW_LOCALHOST_URL_KEY) === 'true'; diff --git a/backend/src/shared/config/node-env.ts b/backend/src/shared/config/node-env.ts new file mode 100644 index 0000000..9a0fe93 --- /dev/null +++ b/backend/src/shared/config/node-env.ts @@ -0,0 +1,10 @@ +/** + * 개발용 서명 키·개발용 로그인을 허용하는 실행 환경 (allowlist). + * 이 목록 밖의 값(production·staging·오타·미설정 등)은 모두 운영처럼 다룬다 — 실수로 열리지 않게 닫힌 쪽이 기본이다. + */ +export const DEV_NODE_ENVS: readonly string[] = ['development', 'test']; + +/** 개발·테스트 환경인지 — 앞뒤 공백은 무시한다 */ +export function isDevNodeEnv(nodeEnv: string | undefined): boolean { + return DEV_NODE_ENVS.includes(nodeEnv?.trim() ?? ''); +} diff --git a/backend/src/shared/utils/db-lock.ts b/backend/src/shared/utils/db-lock.ts new file mode 100644 index 0000000..aa4f194 --- /dev/null +++ b/backend/src/shared/utils/db-lock.ts @@ -0,0 +1,34 @@ +import type { EntityManager } from 'typeorm'; + +/** + * 트랜잭션 잠금(pg_advisory_xact_lock)의 용도 — 키가 서로 겹치지 않도록 한 곳에서 번호를 정한다. + * 잠금은 트랜잭션이 끝나면(커밋·롤백) 저절로 풀린다. + */ +export const LOCK_SCOPE = { + /** 다이어리 책장 한도 — 만들기·참여 (회원별) */ + diaryShelf: 1, + /** 펫 명함 한도 (회원별) */ + petCards: 2, + /** 받은 명함 보관함 한도·중복 (회원별) */ + receivedCards: 3, + /** 일정 연동 참여·해제 (전체 하나) */ + calendarSharing: 4, +} as const; + +export type LockScope = (typeof LOCK_SCOPE)[keyof typeof LOCK_SCOPE]; + +/** + * [scope] 안에서 [key] 하나에 대한 일을 한 줄로 세운다 — 예: 한 회원의 "세고 → 만들기"가 동시에 두 번 돌아 + * 한도를 넘지 않게. 행 잠금(FOR UPDATE)과 달리 셀 행이 하나도 없을 때도 잠긴다. + * [key] 는 hashtext 로 정수가 되므로 드물게 다른 키와 같은 잠금을 쓸 수 있다 — 그래도 순서만 늦어질 뿐 틀리지 않는다. + */ +export async function lockFor( + manager: EntityManager, + scope: LockScope, + key: string, +): Promise { + await manager.query('SELECT pg_advisory_xact_lock($1, hashtext($2))', [ + scope, + key, + ]); +} diff --git a/backend/src/shared/utils/invite-code.ts b/backend/src/shared/utils/invite-code.ts index 21f2c4d..15b398a 100644 --- a/backend/src/shared/utils/invite-code.ts +++ b/backend/src/shared/utils/invite-code.ts @@ -1,7 +1,7 @@ import { randomInt } from 'crypto'; // 초대 코드 `RUP-XXXX…` — 부수효과 없는 순수 함수 (무작위 값만 crypto 에서 얻는다). -// 다이어리(6자)·일정 연동(4자)처럼 뒷부분 길이만 다르게 쓴다. +// 종류마다 뒷부분 길이를 정해 쓴다 — 지금은 다이어리·일정 연동 모두 6자. /** 코드 앞머리 — 앱이 보여 주는 형식 */ const INVITE_CODE_PREFIX = 'RUP-'; diff --git a/backend/test/calendar-sharing.e2e-spec.ts b/backend/test/calendar-sharing.e2e-spec.ts index 3daab8f..24dbe96 100644 --- a/backend/test/calendar-sharing.e2e-spec.ts +++ b/backend/test/calendar-sharing.e2e-spec.ts @@ -55,7 +55,6 @@ describe('일정 연동 (e2e)', () => { let app: INestApplication; const tokens: Record = {}; const ids: Record = {}; - const codes: Record = {}; const people = ['a', 'b', 'c', 'd', 'e'] as const; type Person = (typeof people)[number]; @@ -95,6 +94,20 @@ describe('일정 연동 (e2e)', () => { .expect(200), ).items; const memberIds = (body: SharingBody) => body.members.map((m) => m.userId); + /** 지금의 초대 코드 — 연동·해제에 쓰이면 바뀌므로 매번 읽는다 */ + const codeOf = async (who: Person): Promise => + (await sharing(who)).inviteCode; + /** 쓴 코드는 한 번뿐 — 예전 코드로는 찾을 수 없고(SCHED_001) 새 코드가 생긴다 */ + const expectRotated = async (who: Person, previous: string) => { + const stale = await http() + .get(`/api/calendar-invitations/${previous}`) + .set(as(who)) + .expect(404); + expect(errorCode(stale)).toBe('SCHED_001'); + const current = await codeOf(who); + expect(current).toMatch(/^RUP-[A-Z2-9]{6}$/); + expect(current).not.toBe(previous); + }; beforeAll(async () => { app = await createE2eApp(); @@ -116,7 +129,7 @@ describe('일정 연동 (e2e)', () => { await app.close(); }); - it('초대 코드는 처음 열 때 만들어지고 그대로 유지된다', async () => { + it('초대 코드는 가입할 때 생기고, 읽기만 해서는 바뀌지 않는다', async () => { const first = await sharing('a'); const again = await sharing('a'); @@ -130,7 +143,6 @@ describe('일정 연동 (e2e)', () => { { userId: ids.a, nickname: 'a', photoFileId: null, isMe: true }, ], }); - for (const who of people) codes[who] = (await sharing(who)).inviteCode; }); it('A 가 자기 아이로 일정·반복 일정을 만든다', async () => { @@ -198,9 +210,10 @@ describe('일정 연동 (e2e)', () => { ruleId = data(rule).id; }); - it('B 는 코드를 미리 보고 연동한다 (대소문자 무관)', async () => { + it('B 는 코드를 미리 보고 연동한다 (대소문자 무관) — 쓴 코드는 바뀐다', async () => { + const aCode = await codeOf('a'); const preview = await http() - .get(`/api/calendar-invitations/${codes.a.toLowerCase()}`) + .get(`/api/calendar-invitations/${aCode.toLowerCase()}`) .set(as('b')) .expect(200); expect(data(preview)).toEqual({ @@ -211,13 +224,14 @@ describe('일정 연동 (e2e)', () => { isMember: false, }); - const joined = await join('b', codes.a).expect(201); + const joined = await join('b', aCode).expect(201); expect(data(joined).linked).toBe(true); expect(memberIds(data(joined))).toEqual([ids.b, ids.a]); expect(memberIds(await sharing('a'))).toEqual([ids.a, ids.b]); + await expectRotated('a', aCode); const after = await http() - .get(`/api/calendar-invitations/${codes.a}`) + .get(`/api/calendar-invitations/${await codeOf('a')}`) .set(as('b')) .expect(200); expect(data(after)).toMatchObject({ memberCount: 2, isMember: true }); @@ -310,28 +324,31 @@ describe('일정 연동 (e2e)', () => { }); it('C 도 A 의 코드로 들어와 셋이 된다', async () => { - const joined = await join('c', codes.a).expect(201); + const joined = await join('c', await codeOf('a')).expect(201); expect(memberIds(data(joined))).toEqual([ids.c, ids.a, ids.b]); }); it('이미 연동된 사람(SCHED_005)·내 코드(SCHED_003)는 거절한다', async () => { - for (const code of [codes.a, codes.c]) { - const again = await join('b', code).expect(409); + for (const owner of ['a', 'c'] as const) { + const again = await join('b', await codeOf(owner)).expect(409); expect(errorCode(again)).toBe('SCHED_005'); } - const own = await join('a', codes.a).expect(400); + const own = await join('a', await codeOf('a')).expect(400); expect(errorCode(own)).toBe('SCHED_003'); }); it('4명이 차면 SCHED_002, 다른 사람과 연동 중이면 SCHED_004', async () => { - await join('d', codes.a).expect(201); - const full = await join('e', codes.b).expect(409); + await join('d', await codeOf('a')).expect(201); + const full = await join('e', await codeOf('b')).expect(409); expect(errorCode(full)).toBe('SCHED_002'); - const busy = await join('b', codes.e).expect(409); + const busy = await join('b', await codeOf('e')).expect(409); expect(errorCode(busy)).toBe('SCHED_004'); + // 해제하면 나간 사람의 코드가 바뀐다 + const dCode = await codeOf('d'); await leave('d'); + await expectRotated('d', dCode); expect(memberIds(await sharing('a'))).toEqual([ids.a, ids.b, ids.c]); }); @@ -354,7 +371,9 @@ describe('일정 연동 (e2e)', () => { const [bEvent] = data(mine); expect((await eventsOf('a')).map((e) => e.id)).toContain(bEvent.id); + const bCode = await codeOf('b'); await leave('b'); + await expectRotated('b', bCode); const bSees = (await eventsOf('b')).map((e) => e.id); expect(bSees).toContain(bEvent.id); @@ -385,24 +404,31 @@ describe('일정 연동 (e2e)', () => { >('SELECT count(*) FROM calendar_group_members WHERE user_id = ANY($1)', [[ids.a, ids.c]]); expect(rows[0].count).toBe('0'); - // 연동 중이 아니어도 해제는 204 + // 연동 중이 아니어도 해제는 204 — 빠진 게 없으니 코드도 그대로 + const cCode = await codeOf('c'); await leave('c'); + expect(await codeOf('c')).toBe(cCode); }); - it('함께하던 사람이 탈퇴해 혼자 남으면 연동되지 않은 것으로 보고 정리한다', async () => { - await join('e', codes.d).expect(201); + it('함께하던 사람이 탈퇴해 혼자 남으면 연동되지 않은 것으로 보인다 — 정리는 해제할 때 (GET 은 쓰지 않는다)', async () => { + await join('e', await codeOf('d')).expect(201); await http().delete('/api/users/me').set(as('d')).expect(204); + const countRows = async () => + ( + await app + .get(DataSource) + .query< + { count: string }[] + >('SELECT count(*) FROM calendar_group_members WHERE user_id = $1', [ids.e]) + )[0].count; const e = await sharing('e'); expect(e.linked).toBe(false); expect(memberIds(e)).toEqual([ids.e]); + expect(await countRows()).toBe('1'); - const rows = await app - .get(DataSource) - .query< - { count: string }[] - >('SELECT count(*) FROM calendar_group_members WHERE user_id = $1', [ids.e]); - expect(rows[0].count).toBe('0'); + await leave('e'); + expect(await countRows()).toBe('0'); }); it('없는 코드나 형식이 틀린 코드는 SCHED_001', async () => { diff --git a/backend/test/jest-e2e.json b/backend/test/jest-e2e.json index 9062338..9468e3f 100644 --- a/backend/test/jest-e2e.json +++ b/backend/test/jest-e2e.json @@ -12,5 +12,6 @@ "testRegex": "test/.*\\.e2e-spec\\.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" - } + }, + "testTimeout": 30000 } diff --git a/backend/test/limits.e2e-spec.ts b/backend/test/limits.e2e-spec.ts new file mode 100644 index 0000000..36622f5 --- /dev/null +++ b/backend/test/limits.e2e-spec.ts @@ -0,0 +1,160 @@ +import { INestApplication } from '@nestjs/common'; +import request from 'supertest'; +import type { Response } from 'supertest'; +import { App } from 'supertest/types'; +import { DataSource } from 'typeorm'; + +import { FileSweeper } from '@/modules/upload/file-sweeper'; +import { createE2eApp, devLogin, uniqueDeviceKey } from './support/e2e-app'; + +/** + * 남용·찌꺼기 막기 — 코드 조회 한도(회원별 한 통), 탈퇴할 때 남의 사진 정리, 쓰이지 않는 파일 정리. + * 회원 여럿을 개발용 로그인으로 만들고 끝나면 모두 탈퇴한다. + */ + +/** 1×1 PNG — 업로드가 매직 바이트를 확인하므로 진짜 PNG 여야 한다 */ +const TINY_PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', + 'base64', +); + +/** 코드 조회 한도 (common/decorators/invite-code-lookup.decorator.ts) */ +const INVITE_LIMIT = 30; + +const data = (res: Response): T => (res.body as { data: T }).data; +const errorCode = (res: Response): string | undefined => + (res.body as { error?: { code?: string } }).error?.code; + +describe('한도·정리 (e2e)', () => { + let app: INestApplication; + const tokens: Record = {}; + const people = ['guesser', 'bystander', 'owner', 'member', 'uploader']; + const as = (who: string) => ({ Authorization: `Bearer ${tokens[who]}` }); + const http = () => request(app.getHttpServer()); + + const uploadPng = async (who: string): Promise => { + const res = await http() + .post('/api/files') + .set(as(who)) + .attach('file', TINY_PNG, { + filename: 'photo.png', + contentType: 'image/png', + }) + .expect(201); + return data<{ id: string }>(res).id; + }; + + beforeAll(async () => { + app = await createE2eApp(); + for (const who of people) { + tokens[who] = await devLogin(app, uniqueDeviceKey(`lim-${who}`)); + } + }); + + afterAll(async () => { + for (const who of people) { + await http().delete('/api/users/me').set(as(who)); + } + await app.close(); + }); + + it('코드로 찾는 엔드포인트는 회원별로 한 통에서 센다 — 넘으면 어느 엔드포인트든 REQ_001', async () => { + // 형식이 틀린 코드라 DB 의 실제 코드와 겹치지 않는다 — 실패해도 한도에는 센다 + const lookups = [ + () => http().get('/api/diary-invitations/RUP-000000'), + () => http().get('/api/calendar-invitations/RUP-000000'), + () => http().get('/api/shared-pet-cards/not-a-code'), + () => http().get('/api/shared-pet-cards/not-a-code/images/pet'), + () => http().post('/api/diary-memberships').send({ code: 'nope' }), + () => http().post('/api/calendar-memberships').send({ code: 'nope' }), + ]; + for (let i = 0; i < INVITE_LIMIT; i += 1) { + const res = await lookups[i % lookups.length]().set(as('guesser')); + expect(res.status).not.toBe(429); + } + + for (const lookup of [ + () => http().post('/api/received-cards').send({ code: '0000-000' }), + () => http().get('/api/calendar-invitations/RUP-000000'), + ]) { + const blocked = await lookup().set(as('guesser')).expect(429); + expect(errorCode(blocked)).toBe('REQ_001'); + } + + // 다른 회원은 자기 통을 쓴다 (같은 IP 라도) + const other = await http() + .get('/api/diary-invitations/RUP-000000') + .set(as('bystander')) + .expect(404); + expect(errorCode(other)).toBe('DIARY_003'); + }); + + it('다이어리 주인이 탈퇴하면 함께하는 사람이 그 다이어리에 올린 사진도 정리된다', async () => { + const created = await http() + .post('/api/diaries') + .set(as('owner')) + .send({ + name: '탈퇴 정리', + targetIds: ['self'], + ribbon: '#FF7500', + tint: '#FF8A5B', + shared: true, + }) + .expect(201); + const diary = data<{ id: string; inviteCode: string }>(created); + await http() + .post('/api/diary-memberships') + .set(as('member')) + .send({ code: diary.inviteCode }) + .expect(201); + + const postPhotoId = await uploadPng('member'); + const profilePhotoId = await uploadPng('member'); + await http() + .patch('/api/users/me') + .set(as('member')) + .send({ photoFileId: profilePhotoId }) + .expect(200); + await http() + .post(`/api/diaries/${diary.id}/posts`) + .set(as('member')) + .send({ date: '2026-10-11', text: '사진', photoFileId: postPhotoId }) + .expect(201); + + await http().delete('/api/users/me').set(as('owner')).expect(204); + + const gone = await http() + .get(`/api/files/${postPhotoId}`) + .set(as('member')) + .expect(404); + expect(errorCode(gone)).toBe('RES_001'); + // 다이어리와 상관없는 내 사진은 그대로 + await http() + .get(`/api/files/${profilePhotoId}`) + .set(as('member')) + .expect(200); + }); + + it('하루가 지나도 아무도 쓰지 않는 파일은 정리하고, 쓰는 파일은 남긴다', async () => { + const orphanId = await uploadPng('uploader'); + const usedId = await uploadPng('uploader'); + await http() + .patch('/api/users/me') + .set(as('uploader')) + .send({ photoFileId: usedId }) + .expect(200); + // 올린 지 이틀이 지난 것으로 만든다 + await app + .get(DataSource) + .query( + `UPDATE files SET created_at = now() - interval '2 days' WHERE id = ANY($1)`, + [[orphanId, usedId]], + ); + + const released = await app.get(FileSweeper).sweep(); + + expect(released).toBeGreaterThanOrEqual(1); + await http().get(`/api/files/${orphanId}`).set(as('uploader')).expect(404); + await http().get(`/api/files/${usedId}`).set(as('uploader')).expect(200); + }); +}); diff --git a/flutter/lib/core/api/error_lexicon.g.dart b/flutter/lib/core/api/error_lexicon.g.dart index be91a16..00e2d2d 100644 --- a/flutter/lib/core/api/error_lexicon.g.dart +++ b/flutter/lib/core/api/error_lexicon.g.dart @@ -24,6 +24,8 @@ const Map kErrorCodeMessages = { 'CARD_001': '펫 명함은 최대 10장까지 만들 수 있어요.', 'CARD_002': '명함을 찾을 수 없어요. 명함 코드를 다시 확인해 주세요.', 'CARD_003': '내 명함은 받을 수 없어요.', + 'CARD_004': '이미 보관함에 있는 명함이에요.', + 'CARD_005': '보관함이 가득 찼어요. (최대 100장)', }; /// 매핑 실패 시 사용하는 폴백 코드 diff --git a/frontend/src/shared/constants/error-codes.generated.ts b/frontend/src/shared/constants/error-codes.generated.ts index abf0dd3..83ad2ac 100644 --- a/frontend/src/shared/constants/error-codes.generated.ts +++ b/frontend/src/shared/constants/error-codes.generated.ts @@ -24,6 +24,8 @@ export const ERROR_CODE_MESSAGES = { CARD_001: '펫 명함은 최대 10장까지 만들 수 있어요.', CARD_002: '명함을 찾을 수 없어요. 명함 코드를 다시 확인해 주세요.', CARD_003: '내 명함은 받을 수 없어요.', + CARD_004: '이미 보관함에 있는 명함이에요.', + CARD_005: '보관함이 가득 찼어요. (최대 100장)', } as const export type ErrorCode = keyof typeof ERROR_CODE_MESSAGES