344 lines
13 KiB
PHP
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;
|
|
}
|
|
}
|