repo->paginate($filters, $perPage)->withQueryString(); } public function get(int $id): GcBoard { return $this->repo->findOrFailForEdit($id); } public function create(array $data, ?UploadedFile $file1, ?UploadedFile $file2, int $adminId): int { // 파일은 먼저 저장(성공 시 DB 반영), 예외 시 롤백+파일 삭제 $new1 = $file1 ? $this->storeFile($file1) : null; $new2 = $file2 ? $this->storeFile($file2) : null; try { return $this->repo->transaction(function () use ($data, $adminId, $new1, $new2) { $wantPinned = !empty($data['first_sign']); $firstSign = 0; if ($wantPinned) { $max = $this->repo->maxFirstSignForUpdate(); // lock 포함 $firstSign = $max + 1; // 상단공지 순번(큰 값이 먼저) } $content = (string)($data['content'] ?? ''); $payload = [ 'gubun' => self::GUBUN, 'subject' => (string)($data['subject'] ?? ''), 'content' => $content, 'admin_num' => $adminId, 'regdate' => now()->format('Y-m-d H:i:s'), 'hiding' => 'N', // 기본 노출 'first_sign' => $firstSign, 'link_01' => $this->nullIfBlank($data['link_01'] ?? null), 'link_02' => $this->nullIfBlank($data['link_02'] ?? null), 'hit' => 0, ]; if ($new1) $payload['file_01'] = $new1; if ($new2) $payload['file_02'] = $new2; $row = $this->repo->create($payload); $id = (int) $row->getKey(); // 감사로그: 성공시에만, 실패해도 본 로직 영향 X if ($id > 0) { try { $req = request(); $this->audit->log( actorAdminId: $adminId, action: 'admin.notice.create', targetType: 'notice', targetId: $id, before: null, after: [ 'id' => $id, 'gubun' => $payload['gubun'], 'subject' => $payload['subject'], 'hiding' => $payload['hiding'], 'first_sign' => $payload['first_sign'], 'link_01' => $payload['link_01'], 'link_02' => $payload['link_02'], 'file_01' => $payload['file_01'] ?? null, 'file_02' => $payload['file_02'] ?? null, 'content_len' => mb_strlen($content), 'content_sha256' => hash('sha256', $content), 'regdate' => $payload['regdate'], 'admin_num' => $payload['admin_num'], ], ip: (string)($req?->ip() ?? ''), ua: (string)($req?->userAgent() ?? ''), ); } catch (\Throwable $ignored) {} } return $id; }); } catch (\Throwable $e) { // DB 실패 시 새로 저장한 파일 제거 $this->deletePhysicalFiles([$new1, $new2]); throw $e; } } public function update(int $id, array $data, ?UploadedFile $file1, ?UploadedFile $file2): void { // 새 파일 먼저 저장 $new1 = $file1 ? $this->storeFile($file1) : null; $new2 = $file2 ? $this->storeFile($file2) : null; $oldToDelete = []; try { $oldToDelete = (array) $this->repo->transaction(function () use ($id, $data, $new1, $new2) { $row = $this->repo->lockForUpdate($id); // 감사로그 before(업데이트 전) $beforeContent = (string)($row->content ?? ''); $beforeAudit = [ 'id' => (int)$id, 'gubun' => (string)($row->gubun ?? self::GUBUN), 'subject' => (string)($row->subject ?? ''), 'hiding' => (string)($row->hiding ?? ''), 'first_sign' => (int)($row->first_sign ?? 0), 'link_01' => (string)($row->link_01 ?? ''), 'link_02' => (string)($row->link_02 ?? ''), 'file_01' => (string)($row->file_01 ?? ''), 'file_02' => (string)($row->file_02 ?? ''), 'content_len' => mb_strlen($beforeContent), 'content_sha256' => hash('sha256', $beforeContent), ]; $hiding = !empty($data['hiding']) ? 'Y' : 'N'; // first_sign 로직 유지 $wantPinned = !empty($data['first_sign']); $firstSign = (int)($row->first_sign ?? 0); if ($wantPinned) { if ($firstSign <= 0) { $firstSign = $this->repo->maxFirstSignForUpdate() + 1; } } else { $firstSign = 0; } $content = (string)($data['content'] ?? ''); $payload = [ 'subject' => (string)($data['subject'] ?? ''), 'content' => $content, 'link_01' => $this->nullIfBlank($data['link_01'] ?? null), 'link_02' => $this->nullIfBlank($data['link_02'] ?? null), 'hiding' => $hiding, 'first_sign' => $firstSign, ]; $oldFiles = []; if ($new1) { $oldFiles[] = (string)($row->file_01 ?? ''); $payload['file_01'] = $new1; } if ($new2) { $oldFiles[] = (string)($row->file_02 ?? ''); $payload['file_02'] = $new2; } $this->repo->update($row, $payload); // 감사로그: 성공시에만, 실패해도 본 로직 영향 X try { $actorAdminId = (int)(auth('admin')->id() ?? 0); if ($actorAdminId > 0) { $req = request(); $afterAudit = $beforeAudit; $afterAudit['subject'] = $payload['subject']; $afterAudit['hiding'] = $payload['hiding']; $afterAudit['first_sign'] = $payload['first_sign']; $afterAudit['link_01'] = (string)($payload['link_01'] ?? ''); $afterAudit['link_02'] = (string)($payload['link_02'] ?? ''); if (isset($payload['file_01'])) $afterAudit['file_01'] = (string)$payload['file_01']; if (isset($payload['file_02'])) $afterAudit['file_02'] = (string)$payload['file_02']; $afterAudit['content_len'] = mb_strlen($content); $afterAudit['content_sha256'] = hash('sha256', $content); $this->audit->log( actorAdminId: $actorAdminId, action: 'admin.notice.update', targetType: 'notice', targetId: (int)$id, before: $beforeAudit, after: $afterAudit, ip: (string)($req?->ip() ?? ''), ua: (string)($req?->userAgent() ?? ''), ); } } catch (\Throwable $ignored) {} // 트랜잭션 밖에서 삭제할 이전 파일명 반환 return $oldFiles; }); } catch (\Throwable $e) { // DB 실패 시 새로 저장한 파일 제거 $this->deletePhysicalFiles([$new1, $new2]); throw $e; } // 커밋 이후에만 기존 파일 삭제 $this->deletePhysicalFiles($oldToDelete); } public function delete(int $id): void { $files = (array) $this->repo->transaction(function () use ($id) { $row = $this->repo->lockForUpdate($id); $beforeContent = (string)($row->content ?? ''); $files = [ (string)($row->file_01 ?? ''), (string)($row->file_02 ?? ''), ]; $this->repo->delete($row); // 감사로그: 성공시에만, 실패해도 본 로직 영향 X try { $actorAdminId = (int)(auth('admin')->id() ?? 0); if ($actorAdminId > 0) { $req = request(); $this->audit->log( actorAdminId: $actorAdminId, action: 'admin.notice.delete', targetType: 'notice', targetId: (int)$id, before: [ 'id' => (int)$id, 'gubun' => (string)($row->gubun ?? self::GUBUN), 'subject' => (string)($row->subject ?? ''), 'hiding' => (string)($row->hiding ?? ''), 'first_sign' => (int)($row->first_sign ?? 0), 'link_01' => (string)($row->link_01 ?? ''), 'link_02' => (string)($row->link_02 ?? ''), 'file_01' => (string)($row->file_01 ?? ''), 'file_02' => (string)($row->file_02 ?? ''), 'content_len' => mb_strlen($beforeContent), 'content_sha256' => hash('sha256', $beforeContent), ], after: null, ip: (string)($req?->ip() ?? ''), ua: (string)($req?->userAgent() ?? ''), ); } } catch (\Throwable $ignored) {} return $files; }); $this->deletePhysicalFiles($files); } public function download(int $id, int $slot): array { $row = $this->repo->findOrFailForEdit($id); $file = $slot === 1 ? (string)($row->file_01 ?? '') : (string)($row->file_02 ?? ''); $file = trim($file); if ($file === '') return ['ok' => false, 'path' => '', 'name' => '']; $path = self::DIR.'/'.basename($file); if (!Storage::disk(self::DISK)->exists($path)) { return ['ok' => false, 'path' => '', 'name' => '']; } return ['ok' => true, 'path' => $path, 'name' => $file]; } private function storeFile(UploadedFile $file): string { $orig = $file->getClientOriginalName(); $safe = $this->safeFilename($orig); $base = pathinfo($safe, PATHINFO_FILENAME); $ext = pathinfo($safe, PATHINFO_EXTENSION); $name = $safe; // 충돌 방지 $i = 0; while (Storage::disk(self::DISK)->exists(self::DIR.'/'.$name)) { $i++; $suffix = date('YmdHis').'_'.mt_rand(1000, 9999).'_'.$i; $name = $base.'_'.$suffix.($ext ? '.'.$ext : ''); } Storage::disk(self::DISK)->putFileAs(self::DIR, $file, $name); return $name; } private function deletePhysicalFiles(array $files): void { foreach ($files as $f) { $f = trim((string)$f); if ($f === '') continue; $path = self::DIR.'/'.$f; if (Storage::disk(self::DISK)->exists($path)) { Storage::disk(self::DISK)->delete($path); } } } private function safeFilename(string $name): string { $name = trim($name); $name = preg_replace('/[^\pL\pN\.\-\_\s]/u', '', $name) ?: 'file'; $name = preg_replace('/\s+/', '_', $name); $name = preg_replace('/_+/', '_', $name); return mb_substr($name, 0, 180); } private function nullIfBlank($v): ?string { $v = trim((string)($v ?? '')); return $v === '' ? null : $v; } }