From 8fc9287747f78204016cb86843f1c5f3d1d93dca Mon Sep 17 00:00:00 2001 From: ttipo Date: Tue, 23 Jun 2026 14:32:35 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EC=B2=A8=EB=B6=80(=EC=83=9D=EC=84=B1/?= =?UTF-8?q?=ED=8E=B8=EC=A7=91/=EC=83=81=EC=84=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 프로젝트에 문서 파일을 첨부할 수 있도록 추가(업무 첨부 인프라·업로드 설정 재사용). 백엔드: - ProjectAttachment 엔티티 + 마이그레이션(project_attachments) - 업로드(관리자)/삭제(관리자)/다운로드(멤버) 엔드포인트, 멀터 디스크 저장 - 프로젝트 상세 응답에 attachments 포함 프론트: - 생성 화면: 파일 staged 후 생성 직후 업로드 - 편집(설정) 화면: 기존 문서 목록 + 추가/삭제(즉시), 폼 미저장 보존 - 프로젝트 상세: 문서 목록(다운로드 전용) - useProject.uploadFile/removeFile, ApiProjectAttachment 타입 권한: 업로드/삭제=관리자, 조회/다운로드=멤버. 형식/용량은 업무 첨부와 동일(25MB). 런타임 검증: 업로드·목록·다운로드·삭제 + 비관리자 403 확인. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../1782500000000-AddProjectAttachments.ts | 32 ++++ .../entities/project-attachment.entity.ts | 55 ++++++ .../src/modules/project/project.controller.ts | 87 ++++++++- backend/src/modules/project/project.module.ts | 3 +- .../src/modules/project/project.service.ts | 129 ++++++++++++- frontend/src/composables/useProject.ts | 20 +- frontend/src/mock/relay.mock.ts | 4 + .../src/pages/relay/ProjectCreatePage.vue | 139 +++++++++++++- .../src/pages/relay/ProjectDetailPage.vue | 93 ++++++++- .../src/pages/relay/ProjectSettingsPage.vue | 180 ++++++++++++++++++ frontend/src/stores/project.store.ts | 1 + frontend/src/types/project.ts | 14 +- 12 files changed, 749 insertions(+), 8 deletions(-) create mode 100644 backend/src/migrations/1782500000000-AddProjectAttachments.ts create mode 100644 backend/src/modules/project/entities/project-attachment.entity.ts diff --git a/backend/src/migrations/1782500000000-AddProjectAttachments.ts b/backend/src/migrations/1782500000000-AddProjectAttachments.ts new file mode 100644 index 0000000..416023d --- /dev/null +++ b/backend/src/migrations/1782500000000-AddProjectAttachments.ts @@ -0,0 +1,32 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +// 프로젝트 문서 첨부 — project_attachments 테이블 추가. +// (업무 첨부 attachments 와 동일 구조, project_id FK) +export class AddProjectAttachments1782500000000 implements MigrationInterface { + name = 'AddProjectAttachments1782500000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "project_attachments" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "stored_name" character varying NOT NULL, "size" integer NOT NULL, "mime" character varying NOT NULL, "kind" character varying NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "project_id" uuid, "uploader_id" uuid, CONSTRAINT "PK_project_attachments" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_project_attachments_project" ON "project_attachments" ("project_id")`, + ); + await queryRunner.query( + `ALTER TABLE "project_attachments" ADD CONSTRAINT "FK_project_attachments_project" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "project_attachments" ADD CONSTRAINT "FK_project_attachments_uploader" FOREIGN KEY ("uploader_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "project_attachments" DROP CONSTRAINT "FK_project_attachments_uploader"`, + ); + await queryRunner.query( + `ALTER TABLE "project_attachments" DROP CONSTRAINT "FK_project_attachments_project"`, + ); + await queryRunner.query(`DROP TABLE "project_attachments"`); + } +} diff --git a/backend/src/modules/project/entities/project-attachment.entity.ts b/backend/src/modules/project/entities/project-attachment.entity.ts new file mode 100644 index 0000000..9bc1b1f --- /dev/null +++ b/backend/src/modules/project/entities/project-attachment.entity.ts @@ -0,0 +1,55 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + type Relation, +} from 'typeorm'; +import { User } from '../../user/entities/user.entity'; +import { Project } from './project.entity'; +import type { AttachmentKind } from '../../task/entities/attachment.entity'; + +// 프로젝트 문서 첨부 — 실제 바이너리는 서버 업로드 폴더에 저장하고 메타데이터만 보관. +// (업무 첨부 Attachment 와 동일 패턴 / 동일 업로드 설정 재사용) +@Entity('project_attachments') +export class ProjectAttachment { + @PrimaryGeneratedColumn('uuid') + id!: string; + + // 소속 프로젝트 (프로젝트 삭제 시 함께 삭제) + @Index() + @ManyToOne(() => Project, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'project_id' }) + project!: Relation; + + // 업로더 (사용자 삭제 시에도 첨부는 유지 — SET NULL) + @ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true }) + @JoinColumn({ name: 'uploader_id' }) + uploader!: User | null; + + // 원본 파일명(다운로드 시 표시) + @Column() + name!: string; + + // 디스크 저장명(업로드 폴더 내 고유 파일명) + @Column({ name: 'stored_name' }) + storedName!: string; + + // 파일 크기(바이트) + @Column({ type: 'int' }) + size!: number; + + // MIME 타입 + @Column() + mime!: string; + + // 아이콘 종류(MIME/확장자에서 파생) + @Column({ type: 'varchar' }) + kind!: AttachmentKind; + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; +} diff --git a/backend/src/modules/project/project.controller.ts b/backend/src/modules/project/project.controller.ts index 6f2f431..42edc31 100644 --- a/backend/src/modules/project/project.controller.ts +++ b/backend/src/modules/project/project.controller.ts @@ -12,14 +12,35 @@ import { Patch, Post, Query, + Res, + UploadedFile, UseGuards, + UseInterceptors, } from '@nestjs/common'; -import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { + ApiConsumes, + ApiOperation, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { FileInterceptor } from '@nestjs/platform-express'; +import type { Response } from 'express'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { BusinessException } from '../../common/exceptions/business.exception'; import type { PublicUser } from '../user/user.service'; import type { PaginatedResult } from '../../common/pagination/pagination'; -import { ProjectService, type ProjectResponse } from './project.service'; +import { + ProjectService, + type ProjectResponse, + type ProjectAttachmentResponse, +} from './project.service'; +import { + UPLOAD_DIR, + MAX_FILE_SIZE, + fileMimeFilter, + type UploadedDiskFile, +} from '../task/upload.config'; import { CreateProjectDto } from './dto/create-project.dto'; import { UpdateProjectDto } from './dto/update-project.dto'; @@ -91,4 +112,66 @@ export class ProjectController { await this.projectService.remove(projectId, user.id); return null; } + + /* ===================== 문서 첨부 ===================== */ + + @Post(':projectId/files') + @HttpCode(HttpStatus.CREATED) + @UseInterceptors( + FileInterceptor('file', { + dest: UPLOAD_DIR, + limits: { fileSize: MAX_FILE_SIZE }, + fileFilter: fileMimeFilter, + }), + ) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: '프로젝트 문서 업로드 (admin)' }) + @ApiResponse({ status: 201, description: '업로드 성공' }) + addFile( + @Param('projectId', ParseUUIDPipe) projectId: string, + @UploadedFile() file: UploadedDiskFile, + @CurrentUser() user: PublicUser, + ): Promise { + if (!file) { + throw new BusinessException( + 'BIZ_001', + '업로드할 수 없는 파일 형식이거나 파일이 없습니다.', + HttpStatus.BAD_REQUEST, + ); + } + return this.projectService.addFile(projectId, user.id, file); + } + + @Delete(':projectId/files/:fileId') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '프로젝트 문서 삭제 (admin)' }) + @ApiResponse({ status: 200, description: '삭제 성공' }) + async removeFile( + @Param('projectId', ParseUUIDPipe) projectId: string, + @Param('fileId', ParseUUIDPipe) fileId: string, + @CurrentUser() user: PublicUser, + ): Promise { + await this.projectService.removeFile(projectId, user.id, fileId); + return null; + } + + @Get(':projectId/files/:fileId/download') + @ApiOperation({ summary: '프로젝트 문서 다운로드 (멤버)' }) + @ApiResponse({ status: 200, description: '파일 스트림' }) + async downloadFile( + @Param('projectId', ParseUUIDPipe) projectId: string, + @Param('fileId', ParseUUIDPipe) fileId: string, + @CurrentUser() user: PublicUser, + @Res() res: Response, + ): Promise { + const { attachment, absolutePath } = + await this.projectService.getFileForDownload(projectId, user.id, fileId); + res.setHeader('Content-Type', attachment.mime); + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader( + 'Content-Disposition', + `attachment; filename*=UTF-8''${encodeURIComponent(attachment.name)}`, + ); + res.sendFile(absolutePath); + } } diff --git a/backend/src/modules/project/project.module.ts b/backend/src/modules/project/project.module.ts index 8003847..7cf7bb2 100644 --- a/backend/src/modules/project/project.module.ts +++ b/backend/src/modules/project/project.module.ts @@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { UserModule } from '../user/user.module'; import { Project } from './entities/project.entity'; import { ProjectMember } from './entities/project-member.entity'; +import { ProjectAttachment } from './entities/project-attachment.entity'; import { Task } from '../task/entities/task.entity'; import { ActivityModule } from '../activity/activity.module'; import { NotificationModule } from '../notification/notification.module'; @@ -15,7 +16,7 @@ import { ProjectMemberController } from './project-member.controller'; // ProjectService 의 권한 헬퍼(assertProjectMember/Admin)를 다른 모듈이 주입해 사용한다. @Module({ imports: [ - TypeOrmModule.forFeature([Project, ProjectMember, Task]), + TypeOrmModule.forFeature([Project, ProjectMember, ProjectAttachment, Task]), UserModule, ActivityModule, NotificationModule, diff --git a/backend/src/modules/project/project.service.ts b/backend/src/modules/project/project.service.ts index 0b9d7d9..55a3144 100644 --- a/backend/src/modules/project/project.service.ts +++ b/backend/src/modules/project/project.service.ts @@ -5,6 +5,8 @@ import { } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { ILike, Repository, type FindOptionsOrder } from 'typeorm'; +import { unlink } from 'fs/promises'; +import { join } from 'path'; import { UserService, type PublicUser } from '../user/user.service'; import { paginated, @@ -13,10 +15,29 @@ import { } from '../../common/pagination/pagination'; import { Project } from './entities/project.entity'; import { ProjectMember } from './entities/project-member.entity'; +import { ProjectAttachment } from './entities/project-attachment.entity'; import { Task } from '../task/entities/task.entity'; +import type { AttachmentKind } from '../task/entities/attachment.entity'; +import { + UPLOAD_DIR, + deriveKind, + decodeOriginalName, + type UploadedDiskFile, +} from '../task/upload.config'; import { CreateProjectDto } from './dto/create-project.dto'; import { UpdateProjectDto } from './dto/update-project.dto'; +// 프로젝트 첨부 응답 +export interface ProjectAttachmentResponse { + id: string; + name: string; + size: number; + kind: AttachmentKind; + url: string; + uploader: PublicUser | null; + createdAt: string; +} + // 프로젝트 응답 형태 — 파생 라벨/진행률은 프론트에서 계산 export interface ProjectResponse { id: string; @@ -27,9 +48,10 @@ export interface ProjectResponse { doneCount: number; // 완료 업무 수 totalCount: number; // 전체 업무 수 updatedAt: string; - // 단건(findOne)에서만 — 현재 사용자 권한 + // 단건(findOne)에서만 — 현재 사용자 권한 + 문서 첨부 canManage?: boolean; // admin isOwner?: boolean; // 소유자 + attachments?: ProjectAttachmentResponse[]; } // 프로젝트 비즈니스 로직 + 권한 헬퍼(다른 모듈이 주입해 사용) @@ -42,6 +64,8 @@ export class ProjectService { private readonly memberRepo: Repository, @InjectRepository(Task) private readonly taskRepo: Repository, + @InjectRepository(ProjectAttachment) + private readonly attachmentRepo: Repository, private readonly userService: UserService, ) {} @@ -94,6 +118,7 @@ export class ProjectService { ...base, canManage: membership?.roleType === 'admin', isOwner: membership?.isOwner ?? false, + attachments: await this.listFiles(project.id), }; } @@ -181,6 +206,108 @@ export class ProjectService { } } + // --- 문서 첨부 --- + + // 첨부 목록(최신순) — 프로젝트 상세에 포함 + async listFiles(projectId: string): Promise { + const rows = await this.attachmentRepo.find({ + where: { project: { id: projectId } }, + relations: ['uploader'], + order: { createdAt: 'DESC' }, + }); + return rows.map((a) => this.toAttachmentResponse(a, projectId)); + } + + // 첨부 업로드 — 관리자만. 디스크 저장은 multer 가 처리, 메타데이터만 영속 + async addFile( + projectId: string, + userId: string, + file: UploadedDiskFile, + ): Promise { + const project = await this.getProjectOrThrow(projectId); + await this.assertProjectAdmin(project.id, userId); + const uploader = await this.userService.findById(userId); + + const name = decodeOriginalName(file.originalname); + const saved = await this.attachmentRepo.save( + this.attachmentRepo.create({ + project: { id: project.id } as Project, + uploader: uploader ?? null, + name, + storedName: file.filename, + size: file.size, + mime: file.mimetype, + kind: deriveKind(file.mimetype, name), + }), + ); + return this.toAttachmentResponse( + { ...saved, uploader: uploader ?? null }, + project.id, + ); + } + + // 첨부 삭제 — 관리자만. 디스크 파일도 정리 + async removeFile( + projectId: string, + userId: string, + fileId: string, + ): Promise { + const project = await this.getProjectOrThrow(projectId); + await this.assertProjectAdmin(project.id, userId); + const attachment = await this.attachmentRepo.findOne({ + where: { id: fileId, project: { id: project.id } }, + }); + if (!attachment) { + throw new NotFoundException(); + } + await this.attachmentRepo.remove(attachment); + await this.deleteFileFromDisk(attachment.storedName); + } + + // 다운로드용 첨부 메타 + 디스크 절대경로 — 멤버면 조회 가능. 컨트롤러가 스트리밍 + async getFileForDownload( + projectId: string, + userId: string, + fileId: string, + ): Promise<{ attachment: ProjectAttachment; absolutePath: string }> { + const project = await this.getProjectOrThrow(projectId); + await this.assertProjectMember(project.id, userId); + const attachment = await this.attachmentRepo.findOne({ + where: { id: fileId, project: { id: project.id } }, + }); + if (!attachment) { + throw new NotFoundException(); + } + return { + attachment, + absolutePath: join(UPLOAD_DIR, attachment.storedName), + }; + } + + // 디스크 파일 삭제(베스트 에포트 — 없어도 무시) + private async deleteFileFromDisk(storedName: string): Promise { + try { + await unlink(join(UPLOAD_DIR, storedName)); + } catch { + // 이미 없거나 권한 문제 — 본 흐름을 막지 않는다 + } + } + + private toAttachmentResponse( + att: ProjectAttachment, + projectId: string, + ): ProjectAttachmentResponse { + return { + id: att.id, + name: att.name, + size: att.size, + kind: att.kind, + url: `/api/projects/${projectId}/files/${att.id}/download`, + uploader: att.uploader ? UserService.toPublic(att.uploader) : null, + createdAt: att.createdAt.toISOString(), + }; + } + // 프로젝트별 업무 완료/전체 수 — 진행률 집계용 private async taskCountsByProject( ids: string[], diff --git a/frontend/src/composables/useProject.ts b/frontend/src/composables/useProject.ts index b52346b..e645088 100644 --- a/frontend/src/composables/useProject.ts +++ b/frontend/src/composables/useProject.ts @@ -1,6 +1,7 @@ import { useApi } from './useApi' import type { ApiProject, + ApiProjectAttachment, CreateProjectPayload, ProjectListQuery, UpdateProjectPayload, @@ -41,5 +42,22 @@ export function useProject() { await api.delete(`/projects/${id}`) } - return { list, get, create, update, remove } + // 문서 첨부 업로드(관리자) — multipart/form-data + async function uploadFile( + projectId: string, + file: File, + ): Promise { + const form = new FormData() + form.append('file', file) + return (await api.post(`/projects/${projectId}/files`, form, { + headers: { 'Content-Type': 'multipart/form-data' }, + })) as unknown as ApiProjectAttachment + } + + // 문서 첨부 삭제(관리자) + async function removeFile(projectId: string, fileId: string): Promise { + await api.delete(`/projects/${projectId}/files/${fileId}`) + } + + return { list, get, create, update, remove, uploadFile, removeFile } } diff --git a/frontend/src/mock/relay.mock.ts b/frontend/src/mock/relay.mock.ts index 78f17a4..bd90439 100644 --- a/frontend/src/mock/relay.mock.ts +++ b/frontend/src/mock/relay.mock.ts @@ -3,6 +3,8 @@ * (구 목업 정적 데이터는 백엔드 연동 완료로 제거됨 — store/composable 가 실제 응답을 사용) */ +import type { ApiProjectAttachment } from '@/types/project' + /** 업무 상태 */ export type TaskStatus = 'todo' | 'prog' | 'review' | 'done' | 'changes' @@ -95,6 +97,8 @@ export interface Project { canManage?: boolean /** 소유자 여부 — 프로젝트 삭제 권한 */ isOwner?: boolean + /** 문서 첨부 — 단건 조회에서만 채워짐 */ + attachments?: ApiProjectAttachment[] } /** 프로젝트의 업무 목록 행 */ diff --git a/frontend/src/pages/relay/ProjectCreatePage.vue b/frontend/src/pages/relay/ProjectCreatePage.vue index fd88a3e..696e213 100644 --- a/frontend/src/pages/relay/ProjectCreatePage.vue +++ b/frontend/src/pages/relay/ProjectCreatePage.vue @@ -4,17 +4,46 @@ import { ref } from 'vue' import { useRouter, RouterLink } from 'vue-router' import AppShell from '@/layouts/AppShell.vue' import { useProjectStore } from '@/stores/project.store' +import { useProject } from '@/composables/useProject' import { useDialog } from '@/composables/useDialog' +import { formatBytes } from '@/shared/utils/format' const router = useRouter() const projectStore = useProjectStore() +const projectApi = useProject() const dialog = useDialog() const name = ref('') const description = ref('') const submitting = ref(false) -// 생성 — 성공 시 새 프로젝트 상세로 이동 +// 첨부 문서 — 생성 전엔 메모리에 보관, 생성 직후 업로드 +const pendingFiles = ref([]) +const fileInput = ref(null) +function triggerFilePicker() { + fileInput.value?.click() +} +function onPickFiles(e: Event) { + const input = e.target as HTMLInputElement + pendingFiles.value.push(...(input.files ? Array.from(input.files) : [])) + input.value = '' +} +function removePending(i: number) { + pendingFiles.value.splice(i, 1) +} +async function uploadPending(projectId: string): Promise { + let failed = 0 + for (const f of pendingFiles.value) { + try { + await projectApi.uploadFile(projectId, f) + } catch { + failed++ + } + } + return failed +} + +// 생성 — 성공 시 첨부 업로드 후 새 프로젝트 상세로 이동 async function createProject() { if (submitting.value) return if (!name.value.trim()) { @@ -27,6 +56,13 @@ async function createProject() { name: name.value.trim(), description: description.value.trim() || undefined, }) + const failed = await uploadPending(project.id) + if (failed > 0) { + void dialog.alert( + `문서 ${failed}개 업로드에 실패했습니다. 프로젝트 설정에서 다시 첨부해 주세요.`, + { variant: 'warning' }, + ) + } await router.push(`/projects/${project.id}`) } catch { // 에러는 API 인터셉터에서 전역 처리 @@ -93,6 +129,48 @@ async function createProject() { /> + +
+
+ 프로젝트 문서 +
+
+ 기획서·가이드 등 관련 문서를 첨부하세요. (1개당 최대 25MB) +
+
+
+ {{ f.name }} + {{ formatBytes(f.size) }} + +
+ + +
+
+