giftcon_dev/app/Services/Admin/Notice/AdminNoticeService.php
2026-02-12 15:13:55 +09:00

344 lines
13 KiB
PHP

<?php
namespace App\Services\Admin\Notice;
use App\Models\GcBoard;
use App\Repositories\Admin\Notice\AdminNoticeRepository;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use App\Services\Admin\AdminAuditService;
final class AdminNoticeService
{
private const GUBUN = 'notice';
private const DISK = 'public';
private const DIR = 'bbs';
public function __construct(
private readonly AdminNoticeRepository $repo,
private readonly AdminAuditService $audit,
) {}
public function paginate(array $filters, int $perPage = 15)
{
// paginator 링크 쿼리스트링 유지
return $this->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;
}
}