Files
Gnuboard7/app/Services/AttachmentService.php
T
HeuJung d17ea28da5 fix(core,board,ecommerce,page,kginicis): 정책값 하드코딩·계약 분리 결함 일괄 수정
관리자 환경설정이 값을 저장은 하지만 실제 동작에는 닿지 않던 결함군을 정리했다.
설정 화면(min/max)·저장 검증(FormRequest)·런타임 적용(Service) 세 계층이 각자
리터럴을 들고 있어 서로 조용히 갈라지던 것이 공통 원인이다.

경계값 SSoT 도입:
- config/core.php `settings_limits` 를 단일 출처로 두고, FormRequest 는 이 값으로
 규칙을 만들고 화면은 설정 응답의 `_meta.limits` 로 같은 값을 받아 바인딩한다.
- 게시판·이커머스도 각 모듈 config 의 limits 를 동일 구조로 노출한다.

적용되지 않던 정책을 실제로 적용:
- 비밀번호 최소 길이·특수문자 필수(신규 지정 7개 경로. 로그인은 제외 — 정책을
 올렸을 때 기존 회원이 자기 계정에 접근조차 못 하는 데드락을 막기 위함)
- 계정 잠금 시간 0 = 무한대(전용 컬럼 분리. timestamp 상한과 NULL 규약 때문에
 기존 컬럼 재사용이 불가능하다) + 관리자 수동 해제 경로
- 업로드 최대 크기·허용 확장자, 게시판 첨부 개수·댓글 깊이, 페이지 첨부 정책,
 장바구니 수량·구매 한도, 마일리지 사용 한도, 주문 부가세

파생 결함:
- 큐 Job(sync 드라이버)이 진행 중인 요청의 Request 바인딩을 덮어써, 뒤이어 실행되는
 훅 리스너가 헤더를 잃고 조용히 건너뛰었다. Job 이 원 바인딩을 복원하도록 고치고,
 전송 헤더가 필요한 리스너는 동기 실행으로 선언한다.
- 확장 업데이트 실패 시 상태만 되돌리고 상태 캐시를 비우지 않아, 그 확장의 관리자
 화면만 캐시 TTL(기본 하루) 동안 404 로 남았다. 실패 경로에도 무효화를 대칭으로
 두고, 빈 목록 자체를 캐시하지 않는다.
- 글 작성 시 첨부 파일이 Service 로 전달되지 않아 검증·권한은 통과하고 첨부만 사라졌다.
- 환경설정 저장 실패 안내가 내부 설정 키를 그대로 노출했다(105개 항목에 라벨 부여).

재발 방지:
- audit 룰 3종(레이아웃 경계 리터럴 / FormRequest 경계 불일치 / Service 재클램프)
- 테스트 실행 범위 강제 훅(check-test-scope)
- 트러블슈팅 사례 2건(큐 Job 요청 오염 / 확장 상태 캐시 고착)

공개 이슈: gnuboard/g7
2026-07-30 07:52:24 +09:00

377 lines
13 KiB
PHP

<?php
namespace App\Services;
use App\Contracts\Extension\StorageInterface;
use App\Contracts\Repositories\AttachmentRepositoryInterface;
use App\Enums\AttachmentSourceType;
use App\Extension\HookManager;
use App\Models\Attachment;
use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* 첨부파일 서비스
*
* 첨부파일 업로드, 삭제, 다운로드 등의 비즈니스 로직을 처리합니다.
*/
class AttachmentService
{
/**
* AttachmentService 생성자
*
* @param AttachmentRepositoryInterface $repository 첨부파일 리포지토리
* @param StorageInterface $storage 스토리지 드라이버
*/
public function __construct(
private AttachmentRepositoryInterface $repository,
private StorageInterface $storage
) {}
/**
* 단일 파일 업로드
*
* @param UploadedFile $file 업로드된 파일
* @param string|null $attachmentableType 첨부 대상 타입
* @param int|null $attachmentableId 첨부 대상 ID
* @param string $collection 컬렉션명
* @param AttachmentSourceType $sourceType 소스 타입
* @param string|null $sourceIdentifier 소스 식별자
* @return Attachment 생성된 첨부파일
*/
public function upload(
UploadedFile $file,
?string $attachmentableType = null,
?int $attachmentableId = null,
string $collection = 'default',
AttachmentSourceType $sourceType = AttachmentSourceType::Core,
?string $sourceIdentifier = null,
): Attachment {
// Before 훅
HookManager::doAction('core.attachment.before_upload', $file, $attachmentableType, $attachmentableId);
// 필터 훅 - 파일 데이터 변형 (압축, 리사이즈 등 확장 포인트)
$file = HookManager::applyFilters('core.attachment.filter_upload_file', $file);
// 저장 경로 생성 (날짜별 디렉토리).
// 확장자는 클라이언트가 보낸 파일명이 아니라 MIME 추론값을 우선 사용한다 —
// 파일명 확장자는 사용자가 임의로 바꿀 수 있어 저장 확장자의 근거가 될 수 없다.
$extension = strtolower($file->extension() ?: $file->getClientOriginalExtension());
$storedFilename = Str::uuid().($extension !== '' ? '.'.$extension : '');
$datePath = date('Y/m/d');
$path = "{$datePath}/{$storedFilename}";
// 스토리지에 파일 저장
$disk = config('attachment.disk');
$this->storage->withDisk($disk)->put('', $path, file_get_contents($file->getRealPath()));
// 현재 컬렉션의 최대 order 조회
if ($attachmentableType && $attachmentableId) {
$maxOrder = $this->repository->getMaxOrder($attachmentableType, $attachmentableId, $collection);
} else {
$maxOrder = $this->repository->getMaxOrderByCollection($collection);
}
// 메타데이터 준비 (이미지인 경우 크기 정보)
$meta = [];
if (str_starts_with($file->getMimeType(), 'image/')) {
$imageSize = @getimagesize($file->getRealPath());
if ($imageSize) {
$meta['width'] = $imageSize[0];
$meta['height'] = $imageSize[1];
}
}
// DB에 저장
$attachment = $this->repository->create([
'attachmentable_type' => $attachmentableType,
'attachmentable_id' => $attachmentableId,
'source_type' => $sourceType,
'source_identifier' => $sourceIdentifier,
'original_filename' => $file->getClientOriginalName(),
'stored_filename' => $storedFilename,
'disk' => $disk,
'path' => $path,
'mime_type' => $file->getMimeType(),
'size' => $file->getSize(),
'collection' => $collection,
'order' => $maxOrder + 1,
'meta' => ! empty($meta) ? $meta : null,
'created_by' => Auth::id(),
]);
Log::info('첨부파일 업로드 완료', [
'attachment_id' => $attachment->id,
'hash' => $attachment->hash,
'original_filename' => $attachment->original_filename,
'size' => $attachment->size,
]);
// After 훅
HookManager::doAction('core.attachment.after_upload', $attachment);
return $attachment;
}
/**
* 여러 파일 일괄 업로드
*
* @param array<UploadedFile> $files 업로드할 파일 배열
* @param string|null $attachmentableType 첨부 대상 타입
* @param int|null $attachmentableId 첨부 대상 ID
* @param string $collection 컬렉션명
* @param AttachmentSourceType $sourceType 소스 타입
* @param string|null $sourceIdentifier 소스 식별자
* @return Collection<int, Attachment>
*/
public function uploadBatch(
array $files,
?string $attachmentableType = null,
?int $attachmentableId = null,
string $collection = 'default',
AttachmentSourceType $sourceType = AttachmentSourceType::Core,
?string $sourceIdentifier = null,
): Collection {
$attachments = collect();
foreach ($files as $file) {
$attachment = $this->upload(
$file,
$attachmentableType,
$attachmentableId,
$collection,
$sourceType,
$sourceIdentifier,
);
$attachments->push($attachment);
}
return $attachments;
}
/**
* 첨부파일 삭제
*
* @param int $id 첨부파일 ID
* @return bool 삭제 성공 여부
*/
public function delete(int $id): bool
{
$attachment = $this->repository->findById($id);
if (! $attachment) {
return false;
}
// 삭제 후 재정렬을 위해 정보 저장
$attachmentableType = $attachment->attachmentable_type;
$attachmentableId = $attachment->attachmentable_id;
$collection = $attachment->collection;
// Before 훅
HookManager::doAction('core.attachment.before_delete', $attachment);
// 스토리지에서 파일 삭제
$this->storage->withDisk($attachment->disk)->delete('', $attachment->path);
// DB에서 영구 삭제
$result = $this->repository->forceDelete($id);
Log::info('첨부파일 삭제 완료', [
'attachment_id' => $id,
'hash' => $attachment->hash,
]);
// 삭제 후 남은 파일들의 순서 재정렬
if ($result && $attachmentableType && $attachmentableId) {
$this->repository->reorderAfterDelete($attachmentableType, $attachmentableId, $collection);
}
// After 훅
HookManager::doAction('core.attachment.after_delete', $attachment);
return $result;
}
/**
* 순서 변경
*
* @param array<int, array{id: int, order: int}> $orderData 순서 데이터
*/
public function reorder(array $orderData): void
{
// Before 훅
HookManager::doAction('core.attachment.before_reorder', $orderData);
$this->repository->reorder($orderData);
// After 훅
HookManager::doAction('core.attachment.after_reorder', $orderData);
}
/**
* 다운로드 응답 생성
*
* 기능 레벨 권한 체크 (permission_hooks) - 다운로드 기능 자체에 대한 접근 권한
*
* @param string $hash 첨부파일 해시
* @param User|null $user 요청한 사용자 (null이면 비로그인)
* @return StreamedResponse|null 다운로드 응답 또는 null
*
* @throws AuthorizationException 기능 레벨 권한이 없는 경우
*/
public function download(string $hash, mixed $user = null): ?StreamedResponse
{
$attachment = $this->repository->findByHash($hash);
if (! $attachment) {
return null;
}
// 기능 레벨 권한 체크 (permission_hooks)
// → 'core.attachment.download' 훅에 권한이 매핑되어 있으면 체크
// → 미매핑 시 모든 사용자 허용
HookManager::checkHookPermission('core.attachment.download', $user);
// 액션 훅 - IDV 정책 가드 지점 (filter 훅과 병행)
HookManager::doAction('core.attachment.before_download_action', $attachment, $user);
// 필터 훅 - 다운로드 전 처리 (다운로드 카운트 증가 등)
$attachment = HookManager::applyFilters('core.attachment.before_download', $attachment, $user);
// 파일 존재 확인
$diskStorage = $this->storage->withDisk($attachment->disk);
if (! $diskStorage->exists('', $attachment->path)) {
Log::error('첨부파일 스토리지에 없음', [
'attachment_id' => $attachment->id,
'path' => $attachment->path,
]);
return null;
}
// 다운로드 응답 생성 (원본 파일명으로)
return $diskStorage->download('', $attachment->path, $attachment->original_filename);
}
/**
* 이미지 파일 정보 조회 (캐싱 응답용)
*
* 권한 체크 후 파일 경로와 메타 정보를 반환합니다.
* 컨트롤러에서 fileResponse()로 캐싱 헤더와 함께 응답할 수 있습니다.
*
* @param string $hash 첨부파일 해시
* @param User|null $user 요청한 사용자 (null이면 비로그인)
* @return array{path: string, mime_type: string, filename: string}|null 파일 정보 또는 null
*
* @throws AuthorizationException 기능 레벨 권한이 없는 경우
*/
public function getFileInfo(string $hash, mixed $user = null): ?array
{
$attachment = $this->repository->findByHash($hash);
if (! $attachment) {
return null;
}
// 기능 레벨 권한 체크 (permission_hooks)
HookManager::checkHookPermission('core.attachment.download', $user);
// 필터 훅 - 다운로드 전 처리
$attachment = HookManager::applyFilters('core.attachment.before_download', $attachment, $user);
// 파일 존재 확인
$diskStorage = $this->storage->withDisk($attachment->disk);
if (! $diskStorage->exists('', $attachment->path)) {
Log::error('첨부파일 스토리지에 없음', [
'attachment_id' => $attachment->id,
'path' => $attachment->path,
]);
return null;
}
return [
'path' => $diskStorage->getBasePath('').DIRECTORY_SEPARATOR.$attachment->path,
'mime_type' => $attachment->mime_type,
'filename' => $attachment->original_filename,
];
}
/**
* 해시로 첨부파일 조회
*
* @param string $hash 첨부파일 해시
* @return Attachment|null 첨부파일 또는 null
*/
public function findByHash(string $hash): ?Attachment
{
return $this->repository->findByHash($hash);
}
/**
* ID로 첨부파일 조회
*
* @param int $id 첨부파일 ID
* @return Attachment|null 첨부파일 또는 null
*/
public function findById(int $id): ?Attachment
{
return $this->repository->findById($id);
}
/**
* 첨부 대상의 첨부파일 목록 조회
*
* @param string $type attachmentable_type
* @param int $id attachmentable_id
* @param string|null $collection 컬렉션명 (null이면 전체)
* @return Collection<int, Attachment>
*/
public function getByAttachmentable(string $type, int $id, ?string $collection = null): Collection
{
return $this->repository->getByAttachmentable($type, $id, $collection);
}
/**
* 특정 소스 식별자의 첨부파일 일괄 삭제
* (모듈/플러그인 제거 시 사용)
*
* @param string $identifier 소스 식별자
* @return int 삭제된 개수
*/
public function deleteBySourceIdentifier(string $identifier): int
{
// Before 훅
HookManager::doAction('core.attachment.before_bulk_delete', $identifier);
// 해당 첨부파일들의 스토리지 파일 삭제
$attachments = $this->repository->getBySourceIdentifier($identifier);
$attachmentIds = $attachments->pluck('id')->toArray();
$snapshots = $attachments->keyBy('id')->map(fn ($a) => $a->toArray())->toArray();
foreach ($attachments as $attachment) {
$this->storage->withDisk($attachment->disk)->delete('', $attachment->path);
}
// DB에서 삭제
$count = $this->repository->deleteBySourceIdentifier($identifier);
Log::info('소스 식별자 기준 첨부파일 일괄 삭제', [
'source_identifier' => $identifier,
'deleted_count' => $count,
]);
// After 훅
HookManager::doAction('core.attachment.after_bulk_delete', $identifier, $count, $attachmentIds, $snapshots);
return $count;
}
}