fix(backend): 리뷰 반영 — 코드 남용 막기·인증 기본 닫힘·한도 동시성·파일 정리

브랜치 리뷰(main..14e2bdd)에서 나온 서버 쪽 문제를 고친다.
- 일정 연동 코드는 한 번 연동에 쓰이면(코드 주인), 나가면(나간 사람) 바꾼다 — 예전에 받은 코드로 다시 들어올 수 없다
- 코드 조회·참여 7개 엔드포인트가 회원 단위 한 바구니(분당 30회)를 같이 쓴다 (@InviteCodeLookup, 인증 뒤에 세는 가드)
- 펫 명함 상세히 공유는 마지막으로 공유한 뒤 7일만 — 그 뒤에는 간략히로 보여 주고 저장한다
- 개발용 로그인·개발용 서명 키는 NODE_ENV 가 development·test 일 때만 (그 밖은 JWT_SECRET 필수, 기동 차단)
- 다이어리 6권·명함 10장·받은 명함 한도를 회원별 advisory lock 으로 세어 동시 요청에 뚫리지 않게 한다
- 받은 명함: 같은 명함 중복(CARD_004)·보관함 100장(CARD_005), 사본 복사 실패 시 만든 사본 정리
- 탈퇴하면 내 다이어리의 다른 멤버 사진도 정리하고, 24시간 넘게 쓰이지 않는 파일을 매시간 지운다
- 대상 id 소문자 저장, GET /calendar-sharing 은 쓰지 않음(코드는 가입 때 만들고 기존 회원은 채움), 파일·작성자 FK 인덱스

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 23:21:04 +09:00
parent 043e14a3fa
commit 02c7ec0079
44 changed files with 1422 additions and 282 deletions
+19 -2
View File
@@ -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 {
@@ -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);
+10
View File
@@ -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<string, ErrorCodeDefinition>;
export const ERROR_CODES = {
@@ -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<void> {
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<void> {
await super.onModuleInit();
this.throttlers = this.throttlers.filter(
(t) => t.name === INVITE_THROTTLER,
);
}
protected override shouldSkip(context: ExecutionContext): Promise<boolean> {
const marked = this.reflector.getAllAndOverride<boolean>(
INVITE_CODE_LOOKUP_KEY,
[context.getHandler(), context.getClass()],
);
return Promise.resolve(!marked);
}
protected override getTracker(req: Record<string, unknown>): Promise<string> {
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);
}
}
+14 -1
View File
@@ -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}개까지예요.` }),
@@ -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']);
});
});
@@ -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<void> {
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<void> {
await queryRunner.query(
`DROP INDEX "public"."UQ_received_cards_owner_source"`,
);
await queryRunner.query(
`ALTER TABLE "pet_cards" DROP COLUMN "share_mode_set_at"`,
);
}
}
@@ -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<void> {
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<void> {
// 채운 코드는 그대로 둔다 — 예전 코드도 비어 있으면 처음 열 때 만들었으므로 값이 있어도 문제없다
await queryRunner.query(
`ALTER TABLE "users" ALTER COLUMN "calendar_invite_code" DROP NOT NULL`,
);
}
}
@@ -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<void> {
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<void> {
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"`,
);
}
}
+25 -7
View File
@@ -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('소셜 설정은 비어 있으면 끈 것으로 본다', () => {
+14 -5
View File
@@ -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';
}
@@ -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:
@@ -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,
+33 -3
View File
@@ -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 = <T>(run: () => Promise<T>): Promise<T> =>
* 다이어리에 함께하는 사람(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<Array<string | null>> {
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<void> {
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 =
@@ -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;
@@ -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');
});
});
@@ -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<PetCard, 'shareMode' | 'shareModeSetAt'>,
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';
}
/**
* 공유한 모양에 맞춰 아이 정보를 잘라 낸다.
* 간략히 명함에 보이지 않는 정보(건강·병원·메모)는 상세히로 공유했을 때만 넘긴다 — 받는 사람에게 더 보여 주지 않는다.
@@ -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;
@@ -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;
@@ -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<void> {
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<PetCardResponseDto> {
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(
@@ -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> = {}): 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<ReceivedCard>) => v),
save: jest.fn(),
};
const dataSource = {
manager,
transaction: jest.fn((run: (m: typeof manager) => Promise<unknown>) =>
run(manager),
),
};
const service = new ReceivedCardService(
cards as unknown as Repository<PetCard>,
received as unknown as Repository<ReceivedCard>,
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<ReceivedCard>) =>
manager.save.mockImplementation((v: Partial<ReceivedCard>) =>
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');
});
});
@@ -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<PetCard>,
@InjectRepository(ReceivedCard)
private readonly received: Repository<ReceivedCard>,
@InjectDataSource()
private readonly dataSource: DataSource,
private readonly uploads: UploadService,
) {}
/** 받기 전 미리보기 — 아직 보관하지 않는다 */
/** 받기 전 미리보기 — 아직 보관하지 않는다. 상세히는 7일이 지나면 간략히로 보인다 */
async preview(viewerId: string, code: string): Promise<SharedCardPreviewDto> {
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<ReceivedCardResponseDto> {
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<string | null> = [];
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<void> {
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,
@@ -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:
@@ -86,6 +86,7 @@ export class Pet {
@JoinColumn({ name: 'photo_file_id' })
photoFile?: StoredFile | null;
@Index()
@Column({ type: 'uuid', nullable: true })
photoFileId!: string | null;
@@ -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:
@@ -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<CalendarSharingDto> {
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<CalendarInvitationDto> {
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<CalendarSharingDto> {
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<void> {
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<void> {
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<void> {
await manager.update(
User,
{ id: userId },
{ calendarInviteCode: CALENDAR_INVITE_CODE.random() },
);
}
/** 초대 코드의 주인 — 형식이 틀리거나 없는 코드는 SCHED_001 */
private async findCodeOwner(rawCode: string): Promise<User> {
private async findCodeOwner(
manager: EntityManager,
rawCode: string,
): Promise<User> {
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<User & { calendarInviteCode: string }> {
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 };
}
}
@@ -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(/쓸 수 없는 이름/);
});
});
+68
View File
@@ -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');
}
@@ -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<number> {
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;
}
}
}
+2 -1
View File
@@ -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 {}
@@ -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);
@@ -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;
@@ -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<ReadonlyArray<string | null>>;
}
+56 -32
View File
@@ -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<boolean> {
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<void> {
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<string | null> = [];
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}개 정리)`);
}
@@ -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();
+24 -16
View File
@@ -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<string, unknown>,
): Record<string, unknown> {
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';
+10
View File
@@ -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() ?? '');
}
+34
View File
@@ -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<void> {
await manager.query('SELECT pg_advisory_xact_lock($1, hashtext($2))', [
scope,
key,
]);
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { randomInt } from 'crypto';
// 초대 코드 `RUP-XXXX…` — 부수효과 없는 순수 함수 (무작위 값만 crypto 에서 얻는다).
// 다이어리(6자)·일정 연동(4자)처럼 뒷부분 길이만 다르게 쓴다.
// 종류마다 뒷부분 길이를 정해 쓴다 — 지금은 다이어리·일정 연동 모두 6자.
/** 코드 앞머리 — 앱이 보여 주는 형식 */
const INVITE_CODE_PREFIX = 'RUP-';