generated from ttipo/project-base
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:
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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('소셜 설정은 비어 있으면 끈 것으로 본다', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(/쓸 수 없는 이름/);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
+3
-1
@@ -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>>;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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() ?? '');
|
||||
}
|
||||
@@ -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,7 +1,7 @@
|
||||
import { randomInt } from 'crypto';
|
||||
|
||||
// 초대 코드 `RUP-XXXX…` — 부수효과 없는 순수 함수 (무작위 값만 crypto 에서 얻는다).
|
||||
// 다이어리(6자)·일정 연동(4자)처럼 뒷부분 길이만 다르게 쓴다.
|
||||
// 종류마다 뒷부분 길이를 정해 쓴다 — 지금은 다이어리·일정 연동 모두 6자.
|
||||
|
||||
/** 코드 앞머리 — 앱이 보여 주는 형식 */
|
||||
const INVITE_CODE_PREFIX = 'RUP-';
|
||||
|
||||
@@ -55,7 +55,6 @@ describe('일정 연동 (e2e)', () => {
|
||||
let app: INestApplication<App>;
|
||||
const tokens: Record<string, string> = {};
|
||||
const ids: Record<string, string> = {};
|
||||
const codes: Record<string, string> = {};
|
||||
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<string> =>
|
||||
(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<RuleBody>(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<SharingBody>(joined).linked).toBe(true);
|
||||
expect(memberIds(data<SharingBody>(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<SharingBody>(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<EventBody[]>(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 () => {
|
||||
|
||||
@@ -12,5 +12,6 @@
|
||||
"testRegex": "test/.*\\.e2e-spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
}
|
||||
},
|
||||
"testTimeout": 30000
|
||||
}
|
||||
|
||||
@@ -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 = <T>(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<App>;
|
||||
const tokens: Record<string, string> = {};
|
||||
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<string> => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,8 @@ const Map<String, String> kErrorCodeMessages = {
|
||||
'CARD_001': '펫 명함은 최대 10장까지 만들 수 있어요.',
|
||||
'CARD_002': '명함을 찾을 수 없어요. 명함 코드를 다시 확인해 주세요.',
|
||||
'CARD_003': '내 명함은 받을 수 없어요.',
|
||||
'CARD_004': '이미 보관함에 있는 명함이에요.',
|
||||
'CARD_005': '보관함이 가득 찼어요. (최대 100장)',
|
||||
};
|
||||
|
||||
/// 매핑 실패 시 사용하는 폴백 코드
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user