From dfecaa7654946734eba8bf42af978347bd34c24b Mon Sep 17 00:00:00 2001 From: icurfer Date: Tue, 20 Jan 2026 23:39:05 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Attachment=20=EB=AA=A8=EB=8D=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EA=B4=80=EB=A0=A8=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.5 --- blog/migrations/0004_attachment.py | 25 +++ ...er_id_attachment_uploader_name_and_more.py | 29 +++ blog/migrations/0006_attachment_batch_id.py | 18 ++ blog/models.py | 30 +++ blog/serializers.py | 94 +++++++- blog/urls.py | 19 +- blog/views.py | 205 +++++++++++++++++- blog_prj/settings.py | 27 +++ requirements.txt | 7 + 9 files changed, 447 insertions(+), 7 deletions(-) create mode 100644 blog/migrations/0004_attachment.py create mode 100644 blog/migrations/0005_attachment_uploader_id_attachment_uploader_name_and_more.py create mode 100644 blog/migrations/0006_attachment_batch_id.py diff --git a/blog/migrations/0004_attachment.py b/blog/migrations/0004_attachment.py new file mode 100644 index 0000000..043c5d8 --- /dev/null +++ b/blog/migrations/0004_attachment.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.14 on 2026-01-20 13:58 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('blog', '0003_tag_post_tags'), + ] + + operations = [ + migrations.CreateModel( + name='Attachment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('file', models.FileField(upload_to='attachments/%Y/%m/%d/')), + ('original_name', models.CharField(max_length=255)), + ('file_size', models.PositiveIntegerField()), + ('uploaded_at', models.DateTimeField(auto_now_add=True)), + ('post', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='blog.post')), + ], + ), + ] diff --git a/blog/migrations/0005_attachment_uploader_id_attachment_uploader_name_and_more.py b/blog/migrations/0005_attachment_uploader_id_attachment_uploader_name_and_more.py new file mode 100644 index 0000000..bd7672b --- /dev/null +++ b/blog/migrations/0005_attachment_uploader_id_attachment_uploader_name_and_more.py @@ -0,0 +1,29 @@ +# Generated by Django 4.2.14 on 2026-01-20 14:07 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('blog', '0004_attachment'), + ] + + operations = [ + migrations.AddField( + model_name='attachment', + name='uploader_id', + field=models.CharField(blank=True, default='', max_length=150), + ), + migrations.AddField( + model_name='attachment', + name='uploader_name', + field=models.CharField(blank=True, default='', max_length=150), + ), + migrations.AlterField( + model_name='attachment', + name='post', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='blog.post'), + ), + ] diff --git a/blog/migrations/0006_attachment_batch_id.py b/blog/migrations/0006_attachment_batch_id.py new file mode 100644 index 0000000..6853060 --- /dev/null +++ b/blog/migrations/0006_attachment_batch_id.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.14 on 2026-01-20 14:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('blog', '0005_attachment_uploader_id_attachment_uploader_name_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='attachment', + name='batch_id', + field=models.CharField(blank=True, default='', max_length=36), + ), + ] diff --git a/blog/models.py b/blog/models.py index 659c4ab..8822550 100644 --- a/blog/models.py +++ b/blog/models.py @@ -1,4 +1,6 @@ from django.db import models +from django.db.models.signals import pre_delete +from django.dispatch import receiver from django.conf import settings from django.contrib.auth.models import User @@ -56,3 +58,31 @@ class Comment(models.Model): def __str__(self): return f"Comment by {self.author_name} on {self.post.title}" + + +class Attachment(models.Model): + """첨부파일 모델""" + post = models.ForeignKey( + Post, + related_name="attachments", + on_delete=models.CASCADE, + null=True, + blank=True + ) # null이면 임시 파일 (아직 게시글에 연결되지 않음) + file = models.FileField(upload_to='attachments/%Y/%m/%d/') + original_name = models.CharField(max_length=255) # 원본 파일명 + file_size = models.PositiveIntegerField() # 파일 크기 (bytes) + uploaded_at = models.DateTimeField(auto_now_add=True) + uploader_id = models.CharField(max_length=150, blank=True, default='') # 업로더 ID + uploader_name = models.CharField(max_length=150, blank=True, default='') # 업로더 이름 + batch_id = models.CharField(max_length=36, blank=True, default='') # 업로드 배치 ID (같은 게시글 그룹) + + def __str__(self): + return self.original_name + + +@receiver(pre_delete, sender=Attachment) +def delete_attachment_file(sender, instance, **kwargs): + """Attachment 삭제 시 MinIO에서 파일도 삭제""" + if instance.file: + instance.file.delete(save=False) diff --git a/blog/serializers.py b/blog/serializers.py index 6e58bc3..8b79192 100644 --- a/blog/serializers.py +++ b/blog/serializers.py @@ -1,7 +1,33 @@ # blog/serializers.py +import os +import re from rest_framework import serializers -from .models import Post, Comment, Tag +from .models import Post, Comment, Tag, Attachment + + +def get_unique_filename(original_name, existing_names): + """ + 중복 파일명이 있으면 (2), (3) 등의 번호를 추가하여 고유한 파일명 반환 + """ + if original_name not in existing_names: + return original_name + + name, ext = os.path.splitext(original_name) + match = re.match(r'^(.+)\((\d+)\)$', name) + if match: + base_name = match.group(1) + start_num = int(match.group(2)) + 1 + else: + base_name = name + start_num = 2 + + counter = start_num + while True: + new_name = f"{base_name}({counter}){ext}" + if new_name not in existing_names: + return new_name + counter += 1 class TagSerializer(serializers.ModelSerializer): @@ -45,6 +71,21 @@ class CommentSerializer(serializers.ModelSerializer): return 0 +class AttachmentSerializer(serializers.ModelSerializer): + """첨부파일 시리얼라이저""" + file_url = serializers.SerializerMethodField() + + class Meta: + model = Attachment + fields = ['id', 'file', 'file_url', 'original_name', 'file_size', 'uploaded_at'] + read_only_fields = ['original_name', 'file_size', 'uploaded_at', 'file_url'] + + def get_file_url(self, obj): + if obj.file: + return obj.file.url + return None + + class PostSerializer(serializers.ModelSerializer): comment_count = serializers.SerializerMethodField() tags = TagSerializer(many=True, read_only=True) @@ -53,6 +94,12 @@ class PostSerializer(serializers.ModelSerializer): write_only=True, required=False ) + attachments = AttachmentSerializer(many=True, read_only=True) + attachment_ids = serializers.ListField( + child=serializers.IntegerField(), + write_only=True, + required=False + ) # 임시 업로드된 파일 ID 목록 class Meta: model = Post @@ -60,7 +107,8 @@ class PostSerializer(serializers.ModelSerializer): 'id', 'title', 'content', 'author_id', 'author_name', 'created_at', 'updated_at', - 'comment_count', 'tags', 'tag_names' + 'comment_count', 'tags', 'tag_names', + 'attachments', 'attachment_ids' ] read_only_fields = ['author_id', 'author_name', 'created_at', 'updated_at'] @@ -69,17 +117,22 @@ class PostSerializer(serializers.ModelSerializer): def create(self, validated_data): tag_names = validated_data.pop('tag_names', []) + attachment_ids = validated_data.pop('attachment_ids', []) post = Post.objects.create(**validated_data) self._set_tags(post, tag_names) + self._link_attachments(post, attachment_ids) return post def update(self, instance, validated_data): tag_names = validated_data.pop('tag_names', None) + attachment_ids = validated_data.pop('attachment_ids', None) for attr, value in validated_data.items(): setattr(instance, attr, value) instance.save() if tag_names is not None: self._set_tags(instance, tag_names) + if attachment_ids is not None: + self._link_attachments(instance, attachment_ids) return instance def _set_tags(self, post, tag_names): @@ -92,19 +145,54 @@ class PostSerializer(serializers.ModelSerializer): tags.append(tag) post.tags.set(tags) + def _link_attachments(self, post, attachment_ids): + """임시 업로드된 첨부파일을 게시글에 연결 (중복 파일명 자동 번호 추가)""" + if not attachment_ids: + return + + # 게시글의 기존 첨부파일명 목록 + existing_names = set(post.attachments.values_list('original_name', flat=True)) + + # 임시 파일들을 가져와서 중복 체크 후 연결 + temp_attachments = Attachment.objects.filter( + id__in=attachment_ids, + post__isnull=True + ) + + for attachment in temp_attachments: + unique_name = get_unique_filename(attachment.original_name, existing_names) + attachment.original_name = unique_name + attachment.post = post + attachment.save() + existing_names.add(unique_name) + class PostListSerializer(serializers.ModelSerializer): """목록 조회용 간소화된 시리얼라이저""" comment_count = serializers.SerializerMethodField() tags = TagSerializer(many=True, read_only=True) + thumbnail = serializers.SerializerMethodField() class Meta: model = Post fields = [ 'id', 'title', 'author_name', 'created_at', 'updated_at', - 'comment_count', 'tags' + 'comment_count', 'tags', 'thumbnail' ] def get_comment_count(self, obj): return obj.comments.count() + + def get_thumbnail(self, obj): + """첫 번째 이미지 파일을 섬네일로 반환""" + image_extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp'] + for attachment in obj.attachments.all(): + original_name = attachment.original_name.lower() + if any(original_name.endswith(ext) for ext in image_extensions): + return { + 'id': attachment.id, + 'file_url': attachment.file.url if attachment.file else None, + 'original_name': attachment.original_name + } + return None diff --git a/blog/urls.py b/blog/urls.py index 386f1b7..1579f5d 100644 --- a/blog/urls.py +++ b/blog/urls.py @@ -2,7 +2,12 @@ from django.urls import path, include from rest_framework.routers import DefaultRouter -from .views import PostListView, PostListCreateView, PostDetailView, CommentViewSet, TagListView +from .views import ( + PostListView, PostListCreateView, PostDetailView, + CommentViewSet, TagListView, + AttachmentListCreateView, AttachmentDeleteView, + TempAttachmentUploadView, TempAttachmentDeleteView +) # 댓글 라우터 comment_router = DefaultRouter() @@ -19,4 +24,16 @@ urlpatterns = [ # 댓글 관련 (포스트 하위 리소스) path('posts//', include(comment_router.urls)), + + # 첨부파일 관련 + path('posts//attachments/', + AttachmentListCreateView.as_view(), name='attachment-list'), + path('posts//attachments//', + AttachmentDeleteView.as_view(), name='attachment-delete'), + + # 임시 첨부파일 (게시글 작성 전 업로드) + path('attachments/upload/', + TempAttachmentUploadView.as_view(), name='temp-attachment-upload'), + path('attachments//', + TempAttachmentDeleteView.as_view(), name='temp-attachment-delete'), ] diff --git a/blog/views.py b/blog/views.py index 7e593c7..ff4bbcc 100644 --- a/blog/views.py +++ b/blog/views.py @@ -1,16 +1,49 @@ # blog/views.py +import os +import re from rest_framework import generics, viewsets, permissions, status from rest_framework.exceptions import PermissionDenied from rest_framework.response import Response +from rest_framework.views import APIView +from rest_framework.parsers import MultiPartParser, FormParser from django.shortcuts import get_object_or_404 -from .models import Post, Comment, Tag -from .serializers import PostSerializer, PostListSerializer, CommentSerializer, TagSerializer +from .models import Post, Comment, Tag, Attachment +from .serializers import PostSerializer, PostListSerializer, CommentSerializer, TagSerializer, AttachmentSerializer import logging logger = logging.getLogger(__name__) +def get_unique_filename(original_name, existing_names): + """ + 중복 파일명이 있으면 (2), (3) 등의 번호를 추가하여 고유한 파일명 반환 + 예: test.png -> test(2).png -> test(3).png + """ + if original_name not in existing_names: + return original_name + + # 파일명과 확장자 분리 + name, ext = os.path.splitext(original_name) + + # 기존 번호 패턴 확인 (예: test(2) -> test, 2) + match = re.match(r'^(.+)\((\d+)\)$', name) + if match: + base_name = match.group(1) + start_num = int(match.group(2)) + 1 + else: + base_name = name + start_num = 2 + + # 고유한 번호 찾기 + counter = start_num + while True: + new_name = f"{base_name}({counter}){ext}" + if new_name not in existing_names: + return new_name + counter += 1 + + class TagListView(generics.ListAPIView): """태그 목록 조회""" queryset = Tag.objects.all() @@ -172,4 +205,170 @@ class CommentViewSet(viewsets.ModelViewSet): comment_id = instance.id instance.delete() - logger.info(f"Comment {comment_id} deleted by {username}.") \ No newline at end of file + logger.info(f"Comment {comment_id} deleted by {username}.") + + +class TempAttachmentUploadView(APIView): + """임시 첨부파일 업로드 (게시글 작성 전)""" + permission_classes = [permissions.IsAuthenticated] + parser_classes = [MultiPartParser, FormParser] + + def post(self, request): + """임시 파일 업로드 - 게시글 없이 파일만 먼저 업로드""" + user = request.user + user_id = str(getattr(user, 'id', '') or getattr(user, 'email', '')) + username = getattr(user, 'username', '') or getattr(user, 'email', '') + + file = request.FILES.get('file') + if not file: + return Response( + {'detail': '파일이 필요합니다.'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # 파일 크기 제한 (10MB) + if file.size > 10 * 1024 * 1024: + return Response( + {'detail': '파일 크기는 10MB를 초과할 수 없습니다.'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # batch_id로 같은 배치 내 중복 파일명 체크 + batch_id = request.data.get('batch_id', '') + original_name = file.name + + if batch_id: + # 같은 배치 내 기존 파일명 목록 + existing_names = list( + Attachment.objects.filter( + post__isnull=True, + batch_id=batch_id + ).values_list('original_name', flat=True) + ) + original_name = get_unique_filename(file.name, existing_names) + + # MinIO에 파일 저장 (post=None으로 임시 저장) + attachment = Attachment.objects.create( + post=None, + file=file, + original_name=original_name, + file_size=file.size, + uploader_id=user_id, + uploader_name=username, + batch_id=batch_id + ) + + serializer = AttachmentSerializer(attachment) + logger.info(f"Temp attachment '{original_name}' uploaded by {username}. batch_id={batch_id}") + return Response(serializer.data, status=status.HTTP_201_CREATED) + + +class TempAttachmentDeleteView(APIView): + """임시 첨부파일 삭제""" + permission_classes = [permissions.IsAuthenticated] + + def delete(self, request, pk): + attachment = get_object_or_404(Attachment, pk=pk, post__isnull=True) + + # 업로더만 삭제 가능 + user = request.user + user_id = str(getattr(user, 'id', '') or getattr(user, 'email', '')) + username = getattr(user, 'username', '') or getattr(user, 'email', '') + + if attachment.uploader_id != user_id and attachment.uploader_name != username: + return Response( + {'detail': '삭제 권한이 없습니다.'}, + status=status.HTTP_403_FORBIDDEN + ) + + original_name = attachment.original_name + attachment.file.delete(save=False) + attachment.delete() + + logger.info(f"Temp attachment '{original_name}' deleted by {username}.") + return Response(status=status.HTTP_204_NO_CONTENT) + + +class AttachmentListCreateView(APIView): + """첨부파일 목록/업로드""" + permission_classes = [permissions.IsAuthenticatedOrReadOnly] + parser_classes = [MultiPartParser, FormParser] + + def get_post(self, post_pk): + return get_object_or_404(Post, pk=post_pk) + + def get(self, request, post_pk): + """첨부파일 목록 조회""" + post = self.get_post(post_pk) + attachments = post.attachments.all() + serializer = AttachmentSerializer(attachments, many=True) + return Response(serializer.data) + + def post(self, request, post_pk): + """첨부파일 업로드""" + post = self.get_post(post_pk) + + # 작성자만 첨부파일 추가 가능 + user = request.user + user_id = str(getattr(user, 'id', '') or getattr(user, 'email', '')) + username = getattr(user, 'username', '') or getattr(user, 'email', '') + + if post.author_id != user_id and post.author_name != username: + return Response( + {'detail': '첨부파일을 추가할 권한이 없습니다.'}, + status=status.HTTP_403_FORBIDDEN + ) + + file = request.FILES.get('file') + if not file: + return Response( + {'detail': '파일이 필요합니다.'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # 같은 게시글 내 중복 파일명 체크 + existing_names = list( + post.attachments.values_list('original_name', flat=True) + ) + unique_name = get_unique_filename(file.name, existing_names) + + # MinIO에 파일 저장 + attachment = Attachment.objects.create( + post=post, + file=file, + original_name=unique_name, + file_size=file.size, + uploader_id=user_id, + uploader_name=username + ) + + serializer = AttachmentSerializer(attachment) + logger.info(f"Attachment '{unique_name}' uploaded to post {post_pk} by {username}.") + return Response(serializer.data, status=status.HTTP_201_CREATED) + + +class AttachmentDeleteView(APIView): + """첨부파일 삭제""" + permission_classes = [permissions.IsAuthenticated] + + def delete(self, request, post_pk, pk): + attachment = get_object_or_404(Attachment, pk=pk, post_id=post_pk) + + # 작성자만 삭제 가능 + user = request.user + user_id = str(getattr(user, 'id', '') or getattr(user, 'email', '')) + username = getattr(user, 'username', '') or getattr(user, 'email', '') + + if attachment.post.author_id != user_id and attachment.post.author_name != username: + return Response( + {'detail': '삭제 권한이 없습니다.'}, + status=status.HTTP_403_FORBIDDEN + ) + + # MinIO에서 파일 삭제 + original_name = attachment.original_name + attachment.file.delete(save=False) + attachment.delete() + + logger.info(f"Attachment '{original_name}' deleted from post {post_pk} by {username}.") + return Response(status=status.HTTP_204_NO_CONTENT) \ No newline at end of file diff --git a/blog_prj/settings.py b/blog_prj/settings.py index 6d4934e..576d700 100644 --- a/blog_prj/settings.py +++ b/blog_prj/settings.py @@ -111,6 +111,7 @@ INSTALLED_APPS = [ 'rest_framework_simplejwt', 'drf_yasg', 'corsheaders', + 'storages', # MinIO/S3 스토리지 # create by.sdjo 2025-04-22 'blog', # 2025-04-22 custom app create ] @@ -253,3 +254,29 @@ STATIC_URL = 'static/' # https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +# MinIO (S3 호환) 스토리지 설정 +MINIO_ENDPOINT = os.environ.get('MINIO_ENDPOINT', 'minio.icurfer.com:9000') +MINIO_ACCESS_KEY = os.environ.get('MINIO_ACCESS_KEY', '') +MINIO_SECRET_KEY = os.environ.get('MINIO_SECRET_KEY', '') +MINIO_BUCKET = os.environ.get('MINIO_BUCKET', 'icurfer.com-posts') +MINIO_USE_SSL = os.environ.get('MINIO_USE_SSL', '1') == '1' + +# django-storages S3 설정 (MinIO 호환) +DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage' + +AWS_ACCESS_KEY_ID = MINIO_ACCESS_KEY +AWS_SECRET_ACCESS_KEY = MINIO_SECRET_KEY +AWS_STORAGE_BUCKET_NAME = MINIO_BUCKET +AWS_S3_ENDPOINT_URL = f"{'https' if MINIO_USE_SSL else 'http'}://{MINIO_ENDPOINT}" +AWS_S3_USE_SSL = MINIO_USE_SSL +AWS_S3_VERIFY = MINIO_USE_SSL +AWS_DEFAULT_ACL = None +AWS_S3_FILE_OVERWRITE = False +AWS_QUERYSTRING_AUTH = True +AWS_S3_SIGNATURE_VERSION = 's3v4' +AWS_S3_REGION_NAME = os.environ.get('MINIO_REGION_NAME', 'ap-northeast-2') + +# 최대 업로드 파일 크기 (10MB) +DATA_UPLOAD_MAX_MEMORY_SIZE = 10 * 1024 * 1024 +FILE_UPLOAD_MAX_MEMORY_SIZE = 10 * 1024 * 1024 diff --git a/requirements.txt b/requirements.txt index 40d82d7..452d15c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,6 @@ asgiref==3.8.1 +boto3==1.42.30 +botocore==1.42.30 certifi==2025.1.31 cffi==2.0.0 charset-normalizer==3.4.1 @@ -7,6 +9,7 @@ coreschema==0.0.4 cryptography==46.0.1 Django==4.2.14 django-cors-headers==4.7.0 +django-storages==1.14.6 djangorestframework==3.16.0 djangorestframework_simplejwt==5.5.0 drf-yasg==1.21.10 @@ -15,15 +18,19 @@ idna==3.10 inflection==0.5.1 itypes==1.2.0 Jinja2==3.1.6 +jmespath==1.0.1 MarkupSafe==3.0.2 mysqlclient==2.2.7 packaging==25.0 pycparser==2.23 PyJWT==2.9.0 +python-dateutil==2.9.0.post0 python-dotenv==1.0.1 pytz==2025.2 PyYAML==6.0.2 requests==2.32.3 +s3transfer==0.16.0 +six==1.17.0 sqlparse==0.5.3 typing_extensions==4.13.2 uritemplate==4.1.1