feat: 드라이브(파일 저장소/폴더 관리) 기능 추가

NCP Object Storage(S3 호환) 기반 파일 업로드/폴더 관리 기능을 백엔드에 추가한다.

- 스토리지 계층(common/storage): presign 기반 업로드/다운로드 URL 발급 래퍼
- 드라이브 모듈: 폴더 CRUD, 파일 presign 업로드→complete→다운로드, 이름변경/삭제
- 엔티티 3종: drive_folders(폴더 트리), drive_files(객체 메타), project_folder_links(프로젝트 공유)
- 마이그레이션 AddDriveTables: 3개 테이블 + 인덱스/외래키 생성
- stale pending 파일 정리 @Cron(매시) 추가
- env/docker-compose: OBJECT_STORAGE_* 주입(비시크릿은 루트, ACCESS/SECRET 키는 backend env)
- 의존성: @aws-sdk/client-s3, @aws-sdk/s3-request-presigner 추가

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-29 21:11:08 +09:00
parent 101b1553b0
commit 22a02be937
24 changed files with 2020 additions and 0 deletions
@@ -0,0 +1,109 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
// 드라이브 기능 — drive_folders / drive_files / project_folder_links 테이블 추가.
// - drive_folders: 사용자 소유 폴더 트리(adjacency list + materialized path)
// - drive_files: 오브젝트 스토리지 객체 메타(presign 업로드, status 로 완료 관리)
// - project_folder_links: 폴더를 프로젝트에 연동(공유), 권한(read/read-write)
export class AddDriveTables1782600000000 implements MigrationInterface {
name = 'AddDriveTables1782600000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// ===== drive_folders =====
await queryRunner.query(
`CREATE TABLE "drive_folders" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "path" text NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "owner_id" uuid, "parent_id" uuid, CONSTRAINT "PK_drive_folders" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_drive_folders_owner" ON "drive_folders" ("owner_id")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_drive_folders_parent" ON "drive_folders" ("parent_id")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_drive_folders_path" ON "drive_folders" ("path")`,
);
// ===== drive_files =====
await queryRunner.query(
`CREATE TABLE "drive_files" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "bucket" character varying NOT NULL, "object_key" character varying NOT NULL, "size" bigint NOT NULL, "mime" character varying NOT NULL, "kind" character varying NOT NULL, "status" character varying NOT NULL DEFAULT 'pending', "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "owner_id" uuid, "folder_id" uuid, CONSTRAINT "PK_drive_files" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_drive_files_object_key" ON "drive_files" ("object_key")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_drive_files_owner" ON "drive_files" ("owner_id")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_drive_files_folder" ON "drive_files" ("folder_id")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_drive_files_status" ON "drive_files" ("status")`,
);
// ===== project_folder_links =====
await queryRunner.query(
`CREATE TABLE "project_folder_links" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "permission" character varying NOT NULL DEFAULT 'read', "created_at" TIMESTAMP NOT NULL DEFAULT now(), "project_id" uuid, "folder_id" uuid, "linked_by_id" uuid, CONSTRAINT "PK_project_folder_links" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_project_folder_links_project" ON "project_folder_links" ("project_id")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_project_folder_links_folder" ON "project_folder_links" ("folder_id")`,
);
// 한 폴더는 한 프로젝트에 한 번만 연동
await queryRunner.query(
`ALTER TABLE "project_folder_links" ADD CONSTRAINT "UQ_project_folder_link" UNIQUE ("project_id", "folder_id")`,
);
// ===== 외래키 =====
await queryRunner.query(
`ALTER TABLE "drive_folders" ADD CONSTRAINT "FK_drive_folders_owner" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "drive_folders" ADD CONSTRAINT "FK_drive_folders_parent" FOREIGN KEY ("parent_id") REFERENCES "drive_folders"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "drive_files" ADD CONSTRAINT "FK_drive_files_owner" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "drive_files" ADD CONSTRAINT "FK_drive_files_folder" FOREIGN KEY ("folder_id") REFERENCES "drive_folders"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "project_folder_links" ADD CONSTRAINT "FK_project_folder_links_project" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "project_folder_links" ADD CONSTRAINT "FK_project_folder_links_folder" FOREIGN KEY ("folder_id") REFERENCES "drive_folders"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "project_folder_links" ADD CONSTRAINT "FK_project_folder_links_linked_by" FOREIGN KEY ("linked_by_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// 외래키 → 테이블 순서 역순 정리
await queryRunner.query(
`ALTER TABLE "project_folder_links" DROP CONSTRAINT "FK_project_folder_links_linked_by"`,
);
await queryRunner.query(
`ALTER TABLE "project_folder_links" DROP CONSTRAINT "FK_project_folder_links_folder"`,
);
await queryRunner.query(
`ALTER TABLE "project_folder_links" DROP CONSTRAINT "FK_project_folder_links_project"`,
);
await queryRunner.query(
`ALTER TABLE "drive_files" DROP CONSTRAINT "FK_drive_files_folder"`,
);
await queryRunner.query(
`ALTER TABLE "drive_files" DROP CONSTRAINT "FK_drive_files_owner"`,
);
await queryRunner.query(
`ALTER TABLE "drive_folders" DROP CONSTRAINT "FK_drive_folders_parent"`,
);
await queryRunner.query(
`ALTER TABLE "drive_folders" DROP CONSTRAINT "FK_drive_folders_owner"`,
);
await queryRunner.query(`DROP TABLE "project_folder_links"`);
await queryRunner.query(`DROP TABLE "drive_files"`);
await queryRunner.query(`DROP TABLE "drive_folders"`);
}
}