Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
de54fb2
feat: 팬아트
Jeongho0805 Oct 3, 2025
7a40616
feat: 팬아트 프론트
Jeongho0805 Oct 4, 2025
0bb8e52
fix: 팬아트 기능 및 디자인 수정
Jeongho0805 Oct 5, 2025
c9e14b4
fix: 포도아트 키워드 검색 수정, 디자인 수정
Jeongho0805 Oct 7, 2025
ce549fe
fix: 팬아트 디자인 수정
Jeongho0805 Oct 7, 2025
580aeaf
fix: 팬아트 디자인 수정
Jeongho0805 Oct 7, 2025
8ec5590
fix: 팬아트 수정 삭제 추가
Jeongho0805 Oct 7, 2025
7e50953
fix: 댓글 수정 삭제 추가
Jeongho0805 Oct 7, 2025
e8c263e
fix: 포도아트 모바일 반응형 지원
Jeongho0805 Oct 7, 2025
462fbe2
Merge pull request #7 from Jeongho0805/feature/fan-art
Jeongho0805 Oct 7, 2025
5c1a14f
fix: 포도아트 모바일 반응형 지원
Jeongho0805 Oct 10, 2025
1fff6c8
fix: ads.txt 누락 수정
Jeongho0805 Oct 10, 2025
4706b35
fix: 자동 확대 지원
Jeongho0805 Oct 10, 2025
0545378
fix: 모바일 반응형 지원
Jeongho0805 Oct 10, 2025
144cd6f
fix: 모바일 반응형 지원
Jeongho0805 Oct 10, 2025
15bf65f
fix: 포도아트 타이틀 수정
Jeongho0805 Oct 10, 2025
da10bdc
fix: 픽셀 아트 캔버스 그리기 성능 최적화 및 디자인 변경
Jeongho0805 Oct 11, 2025
faa9680
feat: 페이징 사이즈 최대 크기 제한
Jeongho0805 Oct 11, 2025
2341f51
fix: 정렬 활성화 디자인 수정
Jeongho0805 Oct 11, 2025
82ddea3
fix: 정렬 활성화 디자인 수정
Jeongho0805 Oct 11, 2025
69954a4
fix: 불필요한 메서드 제거
Jeongho0805 Oct 11, 2025
d38b623
fix 만들기 버튼 디자인 변경
Jeongho0805 Oct 11, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,6 @@ out/
src/main/resources/application-*.yml
src/main/generated/**

ads.txt
navercc99a5b089096abdec8e622dcd5b05e9.html
logs
CLAUDE.md
103 changes: 103 additions & 0 deletions src/main/java/com/example/ticketing/art/application/ArtController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package com.example.ticketing.art.application;

import com.example.ticketing.art.dto.*;
import com.example.ticketing.common.auth.Auth;
import com.example.ticketing.common.auth.ClientInfo;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@Slf4j
@RestController
@RequestMapping("/api/arts")
@RequiredArgsConstructor
public class ArtController {

private final ArtService artService;

@GetMapping
public ResponseEntity<Page<ArtResponse>> searchArts(
@Auth ClientInfo clientInfo,
@ModelAttribute ArtSearchCondition condition,
@PageableDefault(size = 20) Pageable pageable) {
Page<ArtResponse> response = artService.searchArts(condition, clientInfo, pageable);
return ResponseEntity.ok(response);
}

@GetMapping("/{artId}")
public ResponseEntity<ArtResponse> getArt(@Auth ClientInfo clientInfo, @PathVariable Long artId) {
ArtResponse response = artService.getArt(clientInfo, artId);
return ResponseEntity.ok(response);
}

@PostMapping
public ResponseEntity<ArtResponse> createArt(
@Valid @RequestBody ArtCreateRequest request,
@Auth ClientInfo clientInfo) {
ArtResponse response = artService.createArt(request, clientInfo);
return ResponseEntity.ok(response);
}

@PutMapping("/{artId}")
public ResponseEntity<ArtResponse> updateArt(
@PathVariable Long artId,
@Valid @RequestBody ArtUpdateRequest request,
@Auth ClientInfo clientInfo) {
ArtResponse response = artService.updateArt(artId, request, clientInfo);
return ResponseEntity.ok(response);
}

@DeleteMapping("/{artId}")
public ResponseEntity<Void> deleteArt(
@PathVariable Long artId,
@Auth ClientInfo clientInfo) {
artService.deleteArt(artId, clientInfo);
return ResponseEntity.noContent().build();
}

@PostMapping("/{artId}/like")
public ResponseEntity<ArtLikeResponse> toggleLike(@PathVariable Long artId, @Auth ClientInfo clientInfo) {
ArtLikeResponse artLikeResponse = artService.toggleLike(artId, clientInfo);
return ResponseEntity.ok(artLikeResponse);
}

@GetMapping("/{artId}/comments")
public ResponseEntity<Page<ArtCommentResponse>> getComments(
@PathVariable Long artId,
@Auth ClientInfo clientInfo,
@PageableDefault(size = 20) Pageable pageable) {
Page<ArtCommentResponse> comments = artService.getComments(artId, clientInfo, pageable);
return ResponseEntity.ok(comments);
}

@PostMapping("/{artId}/comments")
public ResponseEntity<ArtCommentResponse> createComment(
@PathVariable Long artId,
@Valid @RequestBody ArtCommentRequest request,
@Auth ClientInfo clientInfo) {
ArtCommentResponse response = artService.createComment(artId, request, clientInfo);
return ResponseEntity.ok(response);
}

@PutMapping("/comments/{commentId}")
public ResponseEntity<ArtCommentResponse> updateComment(
@PathVariable Long commentId,
@Valid @RequestBody ArtCommentRequest request,
@Auth ClientInfo clientInfo) {
ArtCommentResponse response = artService.updateComment(commentId, request, clientInfo);
return ResponseEntity.ok(response);
}

@DeleteMapping("/comments/{commentId}")
public ResponseEntity<Void> deleteComment(
@PathVariable Long commentId,
@Auth ClientInfo clientInfo) {
artService.deleteComment(commentId, clientInfo);
return ResponseEntity.noContent().build();
}
}
234 changes: 234 additions & 0 deletions src/main/java/com/example/ticketing/art/application/ArtService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
package com.example.ticketing.art.application;

import com.example.ticketing.art.domain.entity.Art;
import com.example.ticketing.art.domain.entity.ArtComment;
import com.example.ticketing.art.domain.entity.ArtLike;
import com.example.ticketing.art.domain.entity.ArtView;
import com.example.ticketing.art.domain.repository.*;
import com.example.ticketing.art.dto.*;
import com.example.ticketing.client.component.ClientManager;
import com.example.ticketing.client.domain.Client;
import com.example.ticketing.common.auth.ClientInfo;
import com.example.ticketing.common.exception.ErrorCode;
import com.example.ticketing.common.exception.GlobalException;
import com.example.ticketing.common.exception.ValidateException;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ArtService {

private final ArtRepository artRepository;
private final ArtLikeRepository artLikeRepository;
private final ArtCommentRepository artCommentRepository;
private final ArtViewRepository artViewRepository;
private final ClientManager clientManager;

@Transactional
public ArtResponse createArt(ArtCreateRequest request, ClientInfo clientInfo) {
Client client = clientManager.findById(clientInfo.getClientId());
validatePixelData(request.getPixelData(), request.getWidth(), request.getHeight());

Art art = Art.builder()
.title(request.getTitle())
.pixelData(request.getPixelData())
.width(request.getWidth())
.height(request.getHeight())
.client(client)
.build();

Art savedArt = artRepository.save(art);
return ArtResponse.from(savedArt);
}

public Page<ArtResponse> searchArts(ArtSearchCondition condition, ClientInfo clientInfo, Pageable pageable) {
Long currentClientId = clientInfo != null ? clientInfo.getClientId() : null;

ArtQueryCondition queryCondition = ArtQueryCondition.builder()
.keyword(condition.getKeyword())
.sortBy(condition.getSortBy())
.sortDirection(condition.getSortDirection())
.filterType(condition.getFilterType())
.currentClientId(currentClientId)
.build();

Page<Art> arts = artRepository.searchArts(queryCondition, pageable);

Client client = clientInfo != null ? clientManager.findById(clientInfo.getClientId()) : null;
if (client == null) {
return arts.map(art -> ArtResponse.from(art, false));
}
return arts.map(art -> {
boolean isLiked = artLikeRepository.existsByArtAndClient(art, client);
return ArtResponse.from(art, isLiked);
});
}

@Transactional
public ArtResponse getArt(ClientInfo clientInfo, Long artId) {
Art art = artRepository.findById(artId)
.orElseThrow(() -> new GlobalException(ErrorCode.INTERNAL_SERVER_ERROR));

Client client = clientInfo != null ? clientManager.findById(clientInfo.getClientId()) : null;

// 조회수 증가: 이미 조회한 사용자가 아닌 경우에만
if (client != null && !artViewRepository.existsByArtAndClient(art, client)) {
ArtView artView = ArtView.builder()
.art(art)
.client(client)
.build();
artViewRepository.save(artView);
artRepository.incrementViewCount(artId);
}

// 좋아요 여부 및 소유 여부 확인
if (client == null) {
return ArtResponse.from(art, false, false);
}

boolean isLiked = artLikeRepository.existsByArtAndClient(art, client);
boolean isOwned = art.getClient().getId().equals(client.getId());

return ArtResponse.from(art, isLiked, isOwned);
}

@Transactional
public ArtResponse updateArt(Long artId, ArtUpdateRequest request, ClientInfo clientInfo) {
Art art = artRepository.findById(artId)
.orElseThrow(() -> new GlobalException(ErrorCode.INTERNAL_SERVER_ERROR));

if (!art.getClient().getId().equals(clientInfo.getClientId())) {
throw new ValidateException(ErrorCode.FORBIDDEN);
}
this.validatePixelData(request.getPixelData(), art.getWidth(), art.getHeight());
art.update(request.getTitle(), request.getPixelData());

return ArtResponse.from(art);
}

@Transactional
public void deleteArt(Long artId, ClientInfo clientInfo) {
Art art = artRepository.findById(artId)
.orElseThrow(() -> new GlobalException(ErrorCode.INTERNAL_SERVER_ERROR));

if (!art.getClient().getId().equals(clientInfo.getClientId())) {
throw new GlobalException(ErrorCode.INTERNAL_SERVER_ERROR);
}

artRepository.delete(art);
}

@Transactional
public ArtLikeResponse toggleLike(Long artId, ClientInfo clientInfo) {
Art art = artRepository.findById(artId)
.orElseThrow(() -> new GlobalException(ErrorCode.INTERNAL_SERVER_ERROR));

Client client = clientManager.findById(clientInfo.getClientId());
int likeCount = art.getLikeCount();

boolean isLiked;
if (artLikeRepository.existsByArtAndClient(art, client)) {
artLikeRepository.deleteByArtAndClient(art, client);
artRepository.decrementLikeCount(artId);
isLiked = false;
likeCount--;
} else {
ArtLike artLike = ArtLike.builder()
.art(art)
.client(client)
.build();
artLikeRepository.save(artLike);
artRepository.incrementLikeCount(artId);
isLiked = true;
likeCount++;
}

return ArtLikeResponse.builder()
.isLiked(isLiked)
.likeCount(likeCount)
.build();
}

public Page<ArtCommentResponse> getComments(Long artId, ClientInfo clientInfo, Pageable pageable) {
Art art = artRepository.findById(artId).orElseThrow(() -> new GlobalException(ErrorCode.RESOURCE_NOT_FOUND));
Page<ArtComment> comments = artCommentRepository.findByArtOrderByCreatedAtAsc(art, pageable);

return comments.map(comment -> {
if (comment.getClient().getId().equals(clientInfo.getClientId())) {
return ArtCommentResponse.from(comment, true);
}
return ArtCommentResponse.from(comment, false);
});
}

@Transactional
public ArtCommentResponse createComment(Long artId, ArtCommentRequest request, ClientInfo clientInfo) {
Art art = artRepository.findById(artId)
.orElseThrow(() -> new GlobalException(ErrorCode.INTERNAL_SERVER_ERROR));

Client client = clientManager.findById(clientInfo.getClientId());

ArtComment comment = ArtComment.builder()
.content(request.getContent())
.art(art)
.client(client)
.build();

ArtComment savedComment = artCommentRepository.save(comment);
artRepository.incrementCommentCount(artId);
return ArtCommentResponse.from(savedComment, true);
}

@Transactional
public ArtCommentResponse updateComment(Long commentId, ArtCommentRequest request, ClientInfo clientInfo) {
ArtComment comment = artCommentRepository.findById(commentId)
.orElseThrow(() -> new GlobalException(ErrorCode.INTERNAL_SERVER_ERROR));

if (!comment.getClient().getId().equals(clientInfo.getClientId())) {
throw new GlobalException(ErrorCode.FORBIDDEN);
}

comment.updateContent(request.getContent());
return ArtCommentResponse.from(comment, true);
}

@Transactional
public void deleteComment(Long commentId, ClientInfo clientInfo) {
ArtComment comment = artCommentRepository.findById(commentId)
.orElseThrow(() -> new GlobalException(ErrorCode.RESOURCE_NOT_FOUND));

if (!comment.getClient().getId().equals(clientInfo.getClientId())) {
throw new GlobalException(ErrorCode.FORBIDDEN);
}

Long artId = comment.getArt().getId();
artCommentRepository.delete(comment);
artRepository.decrementCommentCount(artId);
}

private void validatePixelData(String pixelData, Integer width, Integer height) {
if (pixelData == null || pixelData.isEmpty()) {
throw new GlobalException(ErrorCode.INTERNAL_SERVER_ERROR);
}

// 30x30 고정 크기 검증
if (width != 30 || height != 30) {
throw new GlobalException(ErrorCode.INTERNAL_SERVER_ERROR);
}

// 픽셀 데이터 길이 검증
if (pixelData.length() != width * height) {
throw new GlobalException(ErrorCode.INTERNAL_SERVER_ERROR);
}

// 0과 1로만 구성되어 있는지 검증
if (!pixelData.matches("[01]+")) {
throw new GlobalException(ErrorCode.INTERNAL_SERVER_ERROR);
}
}
}
Loading