ShoppingItemAttachmentService.java
package com.hwhub.backend.application.service;
import com.hwhub.backend.domain.enums.ProgramType;
import com.hwhub.backend.domain.model.HouseholdModel;
import com.hwhub.backend.domain.model.ShoppingItemAttachment;
import com.hwhub.backend.domain.repository.ShoppingItemAttachmentRepository;
import com.hwhub.backend.domain.repository.ShoppingItemRepository;
import com.hwhub.backend.domain.storage.ObjectStorageClient;
import com.hwhub.backend.infrastructure.s3.ObjectStorageConfig.ShoppingItemStorageSettings;
import com.hwhub.backend.presentation.rest.common.ResourceNotFoundException;
import java.net.URL;
import java.util.List;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional
public class ShoppingItemAttachmentService {
private final UserService userService;
private final ShoppingItemRepository shoppingItemRepository;
private final ShoppingItemAttachmentRepository attachmentRepository;
private final ObjectStorageClient objectStorageClient;
private final ShoppingItemStorageSettings storageSettings;
// presigned PUT URL 発行
public CreateUploadUrlResult createUploadUrl(
Long shoppingItemId, String fileName, String mimeType, Long userId) {
var item =
shoppingItemRepository
.findById(shoppingItemId)
.orElseThrow(() -> new ResourceNotFoundException("Attachment file not found"));
// household 認可チェック
List<HouseholdModel> list = userService.getHouseholds(userId);
if (list.stream().noneMatch(e -> e.getHouseholdId().equals(item.getHouseholdId()))) {
throw new IllegalStateException("household mismatch");
}
String ext = extractExtension(fileName);
String key = buildFileKey(item.getHouseholdId(), shoppingItemId, ext);
URL url =
objectStorageClient.createPresignedPutUrl(
storageSettings.bucket(), key, mimeType, storageSettings.urlTtl());
return new CreateUploadUrlResult(url.toString(), key);
}
// アップロード完了後にメタ情報を登録
public ShoppingItemAttachment createAttachment(
Long shoppingItemId, String fileKey, String fileName, String mimeType, Long userId) {
var item = shoppingItemRepository.findById(shoppingItemId).orElseThrow();
// household 認可チェック
List<HouseholdModel> list = userService.getHouseholds(userId);
if (list.stream().noneMatch(e -> e.getHouseholdId().equals(item.getHouseholdId()))) {
throw new IllegalStateException("household mismatch");
}
int nextSortOrder =
attachmentRepository.findByShoppingItemId(shoppingItemId).stream()
.map(ShoppingItemAttachment::getSortOrder)
.max(Integer::compareTo)
.orElse(0)
+ 1;
ShoppingItemAttachment model =
ShoppingItemAttachment.create(shoppingItemId, fileKey, fileName, mimeType, nextSortOrder);
return attachmentRepository.save(model, userId, ProgramType.ONL_SHPATCH.getCode());
}
// 一覧 + presigned GET URL 付き
@Transactional(readOnly = true)
public List<ShoppingItemAttachment> listAttachments(Long shoppingItemId, Long userId) {
var item = shoppingItemRepository.findById(shoppingItemId).orElseThrow();
// household 認可チェック
List<HouseholdModel> list = userService.getHouseholds(userId);
if (list.stream().noneMatch(e -> e.getHouseholdId().equals(item.getHouseholdId()))) {
throw new IllegalStateException("household mismatch");
}
var attachmentList = attachmentRepository.findByShoppingItemId(shoppingItemId);
return attachmentList.stream()
.map(
a -> {
URL url =
objectStorageClient.createPresignedGetUrl(
storageSettings.bucket(), a.getFileKey(), storageSettings.urlTtl());
a.setImageUrl(url.toString());
return a;
})
.toList();
}
public void deleteAttachment(Long shoppingItemId, Long attachmentId, Long userId) {
var item = shoppingItemRepository.findById(shoppingItemId).orElseThrow();
// household 認可チェック
List<HouseholdModel> list = userService.getHouseholds(userId);
if (list.stream().noneMatch(e -> e.getHouseholdId().equals(item.getHouseholdId()))) {
throw new IllegalStateException("household mismatch");
}
var attachment = attachmentRepository.findById(attachmentId).orElseThrow();
if (!attachment.getShoppingItemId().equals(shoppingItemId)) {
throw new IllegalStateException("shoppingItem mismatch");
}
// S3のオブジェクトは消さない。別のアイテムが同じ file_key を使っている可能性があるため
// objectStorageClient.deleteObject(storageSettings.bucket(), attachment.getFileKey());
attachmentRepository.deleteById(attachmentId);
}
public record CreateUploadUrlResult(String uploadUrl, String fileKey) {}
private String buildFileKey(Long householdId, Long shoppingItemId, String ext) {
String uuid = UUID.randomUUID().toString();
// shopping-item/{householdId}/{shoppingItemId}/{uuid}.ext
return "%s/%d/%d/%s%s"
.formatted(storageSettings.shoppingItemBasePath(), householdId, shoppingItemId, uuid, ext);
}
private String extractExtension(String fileName) {
if (fileName == null) return "";
int dot = fileName.lastIndexOf('.');
if (dot == -1) return "";
return fileName.substring(dot); // ".jpg" みたいな形
}
}