From 6d4195aacd656bcf1d250d1ab87788384620b904 Mon Sep 17 00:00:00 2001 From: sungro815 Date: Thu, 12 Feb 2026 15:13:55 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20=ED=96=89?= =?UTF-8?q?=EC=9C=84=20=EB=A1=9C=EA=B7=B8=EC=A0=80=EC=9E=A5=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AdminMemberMarketingController.php | 64 ++++- app/Services/Admin/Mail/AdminMailService.php | 104 ++++++-- .../Member/AdminMemberJoinFilterService.php | 79 +++++- .../Admin/Member/AdminMemberService.php | 118 ++++++++- .../Admin/Notice/AdminNoticeService.php | 133 +++++++++- app/Services/Admin/Qna/AdminQnaService.php | 248 +++++++++++++++++- .../Admin/Sms/AdminSmsTemplateService.php | 75 +++++- 7 files changed, 748 insertions(+), 73 deletions(-) diff --git a/app/Http/Controllers/Admin/Members/AdminMemberMarketingController.php b/app/Http/Controllers/Admin/Members/AdminMemberMarketingController.php index 8ec9497..9cfb520 100644 --- a/app/Http/Controllers/Admin/Members/AdminMemberMarketingController.php +++ b/app/Http/Controllers/Admin/Members/AdminMemberMarketingController.php @@ -4,11 +4,14 @@ namespace App\Http\Controllers\Admin\Members; use App\Services\Admin\Member\AdminMemberMarketingService; use Illuminate\Http\Request; +use App\Services\Admin\AdminAuditService; + final class AdminMemberMarketingController { public function __construct( private readonly AdminMemberMarketingService $service, + private readonly AdminAuditService $audit, ) {} public function index(Request $request) @@ -46,12 +49,47 @@ final class AdminMemberMarketingController 'has_email' => ['nullable','string','in:all,1,0'], ]); + $actorAdminId = (int) auth('admin')->id(); + $ip = (string) $request->ip(); + $ua = (string) $request->userAgent(); + $zipPassword = (string) $data['zip_password']; unset($data['zip_password']); + // 감사로그용 필터 스냅샷(민감정보 제외: zip_password는 이미 제거됨) + $auditFilters = $data; + + // q 마스킹(전화/이메일 검색인 경우만) + $qf = (string)($auditFilters['qf'] ?? 'all'); + $q = (string)($auditFilters['q'] ?? ''); + if ($q !== '') { + if ($qf === 'phone') { + $digits = preg_replace('/\D+/', '', $q); + $auditFilters['q'] = $digits !== '' ? (substr($digits, 0, 3) . '****' . substr($digits, -2)) : '***'; + } elseif ($qf === 'email' && str_contains($q, '@')) { + [$local, $domain] = explode('@', $q, 2); + $auditFilters['q'] = (mb_substr($local, 0, 2) . '***@' . $domain); + } + } + $res = $this->service->exportZip($data, $zipPassword); if (!($res['ok'] ?? false)) { + // ✅ 실패도 기록(원인 추적용) — 비밀번호는 기록하지 않음 + $this->audit->log( + actorAdminId: $actorAdminId, + action: 'admin.member.export.fail', + targetType: 'member', + targetId: 0, + before: ['filters' => $auditFilters], + after: [ + 'ok' => false, + 'message' => $res['message'] ?? '파일 생성 실패', + ], + ip: $ip, + ua: $ua, + ); + return redirect()->back()->with('toast', [ 'type' => 'danger', 'title' => '다운로드 실패', @@ -59,8 +97,32 @@ final class AdminMemberMarketingController ]); } + $zipPath = (string)($res['zip_path'] ?? ''); + $downloadName = (string)($res['download_name'] ?? 'members.zip'); + + // ✅ 성공 기록 + $bytes = (is_string($zipPath) && $zipPath !== '' && file_exists($zipPath)) ? @filesize($zipPath) : null; + + $this->audit->log( + actorAdminId: $actorAdminId, + action: 'admin.member.export', + targetType: 'member', + targetId: 0, + before: ['filters' => $auditFilters], + after: [ + 'ok' => true, + 'download_name' => $downloadName, + 'bytes' => $bytes, + // exportZip에서 제공 가능하면 같이 남겨두면 좋아 + 'row_count' => $res['row_count'] ?? null, + ], + ip: $ip, + ua: $ua, + ); + return response() - ->download($res['zip_path'], $res['download_name']) + ->download($zipPath, $downloadName) ->deleteFileAfterSend(true); } + } diff --git a/app/Services/Admin/Mail/AdminMailService.php b/app/Services/Admin/Mail/AdminMailService.php index 92c87d1..394f88d 100644 --- a/app/Services/Admin/Mail/AdminMailService.php +++ b/app/Services/Admin/Mail/AdminMailService.php @@ -6,13 +6,15 @@ use App\Repositories\Admin\Mail\AdminMailTemplateRepository; use App\Services\MailService; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; +use App\Services\Admin\AdminAuditService; final class AdminMailService { public function __construct( private readonly AdminMailRepository $repo, private readonly AdminMailTemplateRepository $tplRepo, - private readonly MailService $mail + private readonly MailService $mail, + private readonly AdminAuditService $audit, ) {} public function getSkinOptions(): array @@ -76,13 +78,60 @@ final class AdminMailService public function createTemplate(int $adminId, array $data): array { - $code = (string)$data['code']; + $code = (string)($data['code'] ?? ''); if ($this->tplRepo->existsCode($code)) { return ['ok'=>false,'message'=>'이미 존재하는 code 입니다.']; } - $id = $this->tplRepo->create([ - 'code' => $code, + $payload = [ + 'code' => $code, + 'title' => $data['title'], + 'description' => $data['description'] ?? '', + 'skin_key' => $data['skin_key'], + 'subject_tpl' => $data['subject'], + 'body_tpl' => $data['body'], + 'hero_image_url' => $data['hero_image_url'] ?? '', + 'cta_label' => $data['cta_label'] ?? '', + 'cta_url' => $data['cta_url'] ?? '', + 'is_active' => (int)(!empty($data['is_active']) ? 1 : 0), + 'created_by' => $adminId, + 'updated_by' => $adminId, + ]; + + $id = $this->tplRepo->create($payload); + + // ✅ 성공시에만 감사로그 (기존 흐름 방해 X) + if ((int)$id > 0) { + $req = request(); + $this->audit->log( + actorAdminId: $adminId, + action: 'admin.mail_template.create', + targetType: 'mail_template', + targetId: (int)$id, + before: null, + after: array_merge(['id' => (int)$id], $payload), + ip: (string)($req?->ip() ?? ''), + ua: (string)($req?->userAgent() ?? ''), + ); + } + + return ['ok'=>true,'id'=>$id]; + } + + + public function updateTemplate(int $adminId, int $id, array $data): array + { + // ✅ before 스냅샷: repo에 get/find가 있으면 사용, 없으면 null로 진행(흐름 방해 X) + $before = null; + try { + if (method_exists($this->tplRepo, 'find')) { + $before = $this->tplRepo->find($id); + } elseif (method_exists($this->tplRepo, 'get')) { + $before = $this->tplRepo->get($id); + } + } catch (\Throwable $ignored) {} + + $payload = [ 'title' => $data['title'], 'description' => $data['description'] ?? '', 'skin_key' => $data['skin_key'], @@ -91,31 +140,36 @@ final class AdminMailService 'hero_image_url'=> $data['hero_image_url'] ?? '', 'cta_label' => $data['cta_label'] ?? '', 'cta_url' => $data['cta_url'] ?? '', - 'is_active' => (int)(!empty($data['is_active']) ? 1 : 0), - 'created_by' => $adminId, + 'is_active' => (int)(($data['is_active'] ?? 0) ? 1 : 0), 'updated_by' => $adminId, - ]); + ]; - return ['ok'=>true,'id'=>$id]; + $affected = $this->tplRepo->update($id, $payload); + + $ok = ($affected >= 0); + + // ✅ 성공시에만 감사로그 + if ($ok) { + $req = request(); + + $beforeAudit = $before ? (array)$before : ['id' => $id]; + $afterAudit = array_merge($beforeAudit, $payload); + + $this->audit->log( + actorAdminId: $adminId, + action: 'admin.mail_template.update', + targetType: 'mail_template', + targetId: (int)$id, + before: $beforeAudit, + after: $afterAudit, + ip: (string)($req?->ip() ?? ''), + ua: (string)($req?->userAgent() ?? ''), + ); + } + + return ['ok'=>$ok]; } - public function updateTemplate(int $adminId, int $id, array $data): array - { - $affected = $this->tplRepo->update($id, [ - 'title'=>$data['title'], - 'description'=>$data['description'] ?? '', - 'skin_key'=>$data['skin_key'], - 'subject_tpl'=>$data['subject'], - 'body_tpl'=>$data['body'], - 'hero_image_url'=>$data['hero_image_url'] ?? '', - 'cta_label'=>$data['cta_label'] ?? '', - 'cta_url'=>$data['cta_url'] ?? '', - 'is_active'=> (int)(($data['is_active'] ?? 0) ? 1 : 0), - 'updated_by'=>$adminId, - ]); - - return ['ok'=>$affected>=0]; - } public function listBatches(array $filters, int $perPage=20) { diff --git a/app/Services/Admin/Member/AdminMemberJoinFilterService.php b/app/Services/Admin/Member/AdminMemberJoinFilterService.php index bcf4dba..8bbbbe1 100644 --- a/app/Services/Admin/Member/AdminMemberJoinFilterService.php +++ b/app/Services/Admin/Member/AdminMemberJoinFilterService.php @@ -6,11 +6,13 @@ use App\Repositories\Admin\Member\AdminMemberJoinFilterRepository; use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; +use App\Services\Admin\AdminAuditService; final class AdminMemberJoinFilterService { public function __construct( private readonly AdminMemberJoinFilterRepository $repo, + private readonly AdminAuditService $audit, ) {} public function indexData(array $query = []): array @@ -82,15 +84,31 @@ final class AdminMemberJoinFilterService return $row; } - public function store(array $input, string $actorName): array + public function store(array $input, string $actorName, ?int $actorAdminId = null, string $ip = '', string $ua = ''): array { try { - return DB::transaction(function () use ($input, $actorName) { + return DB::transaction(function () use ($input, $actorName, $actorAdminId, $ip, $ua) { $data = $this->buildSaveData($input, $actorName); $seq = $this->repo->insert($data); if ($seq <= 0) return $this->fail('등록에 실패했습니다.'); + // ✅ 감사로그: 성공시에만 (기존 흐름 방해 X) + $aid = (int)($actorAdminId ?? auth('admin')->id() ?? 0); + if ($aid > 0) { + $req = request(); + $this->audit->log( + actorAdminId: $aid, + action: 'admin.join_filter.create', + targetType: 'join_filter', + targetId: (int)$seq, + before: null, + after: array_merge(['seq' => (int)$seq], $data), + ip: $ip ?: (string)$req->ip(), + ua: $ua ?: (string)$req->userAgent(), + ); + } + return $this->ok('정상적으로 등록되었습니다.', ['seq' => $seq]); }); } catch (\Throwable $e) { @@ -98,16 +116,16 @@ final class AdminMemberJoinFilterService 'err' => $e->getMessage(), ]); - // ✅ 유효성/형식 에러는 메시지 그대로 노출(관리자 페이지라 OK) $msg = ($e instanceof \RuntimeException) ? $e->getMessage() : '등록 중 오류가 발생했습니다.'; return $this->fail($msg); } } - public function update(int $seq, array $input, string $actorName): array + + public function update(int $seq, array $input, string $actorName, ?int $actorAdminId = null, string $ip = '', string $ua = ''): array { try { - return DB::transaction(function () use ($seq, $input, $actorName) { + return DB::transaction(function () use ($seq, $input, $actorName, $actorAdminId, $ip, $ua) { $before = $this->repo->lockForUpdate($seq); if (!$before) return $this->fail('대상을 찾을 수 없습니다.'); @@ -115,9 +133,33 @@ final class AdminMemberJoinFilterService $affected = $this->repo->update($seq, $data); - // ✅ 0건(변경 없음)도 성공 처리 + // ✅ 0건(변경 없음)도 성공 처리 (기존 정책 유지) if ($affected === 0) return $this->ok('변경사항이 없습니다. (저장 완료)'); - if ($affected > 0) return $this->ok('정상적으로 수정되었습니다.'); + + if ($affected > 0) { + // ✅ 감사로그: 성공시에만 + $aid = (int)($actorAdminId ?? auth('admin')->id() ?? 0); + if ($aid > 0) { + $req = request(); + + // before/after는 "필드 덮어쓰기" 방식으로 안전하게 구성 (repo->find() 의존 X) + $beforeAudit = (array)$before; + $afterAudit = array_merge($beforeAudit, $data); + + $this->audit->log( + actorAdminId: $aid, + action: 'admin.join_filter.update', + targetType: 'join_filter', + targetId: (int)$seq, + before: $beforeAudit, + after: $afterAudit, + ip: $ip ?: (string)$req->ip(), + ua: $ua ?: (string)$req->userAgent(), + ); + } + + return $this->ok('정상적으로 수정되었습니다.'); + } return $this->fail('수정에 실패했습니다.'); }); @@ -132,16 +174,34 @@ final class AdminMemberJoinFilterService } } - public function destroy(int $seq): array + + public function destroy(int $seq, ?int $actorAdminId = null, string $ip = '', string $ua = ''): array { try { - return DB::transaction(function () use ($seq) { + return DB::transaction(function () use ($seq, $actorAdminId, $ip, $ua) { $before = $this->repo->lockForUpdate($seq); if (!$before) return $this->fail('대상을 찾을 수 없습니다.'); $affected = $this->repo->delete($seq); if ($affected <= 0) return $this->fail('삭제에 실패했습니다.'); + // ✅ 감사로그: 성공시에만 + $aid = (int)($actorAdminId ?? auth('admin')->id() ?? 0); + if ($aid > 0) { + $req = request(); + + $this->audit->log( + actorAdminId: $aid, + action: 'admin.join_filter.delete', + targetType: 'join_filter', + targetId: (int)$seq, + before: (array)$before, + after: null, + ip: $ip ?: (string)$req->ip(), + ua: $ua ?: (string)$req->userAgent(), + ); + } + return $this->ok('정상적으로 삭제되었습니다.'); }); } catch (\Throwable $e) { @@ -153,6 +213,7 @@ final class AdminMemberJoinFilterService } } + private function buildSaveData(array $input, string $actorName): array { $gubunCode = (string)($input['gubun_code'] ?? ''); diff --git a/app/Services/Admin/Member/AdminMemberService.php b/app/Services/Admin/Member/AdminMemberService.php index 0293980..25333ef 100644 --- a/app/Services/Admin/Member/AdminMemberService.php +++ b/app/Services/Admin/Member/AdminMemberService.php @@ -8,6 +8,7 @@ use App\Repositories\Member\MemberAuthRepository; use App\Services\MailService; use App\Support\LegacyCrypto\CiSeedCrypto; use Illuminate\Support\Facades\DB; +use App\Services\Admin\AdminAuditService; final class AdminMemberService { @@ -18,6 +19,7 @@ final class AdminMemberService private readonly AdminUserRepository $adminRepo, private readonly MemberAuthRepository $members, private readonly MailService $mail, + private readonly AdminAuditService $audit, ) {} public function list(array $filters): array @@ -148,15 +150,35 @@ final class AdminMemberService public function updateMember(int $memNo, array $input, int $actorAdminId, string $ip = '', string $ua = ''): array { try { - return DB::transaction(function () use ($memNo, $input, $actorAdminId) { + return DB::transaction(function () use ($memNo, $input, $actorAdminId, $ip, $ua) { $before = $this->repo->lockMemberForUpdate($memNo); if (!$before) return $this->fail('회원을 찾을 수 없습니다.'); + $now = now(); + $nowStr = $now->format('Y-m-d H:i:s'); + $legacyWhen = $now->format('y-m-d H:i:s'); + $data = []; // ✅ 기존 modify_log에서 레거시 state_log[] 뽑기 $stateLog = $this->legacyStateLogList($before->modify_log ?? null); + $beforeStateLogLast = empty($stateLog) ? null : $stateLog[count($stateLog) - 1]; + + // (audit용) before 스냅샷: 민감/대용량 제외 + phone 마스킹 + $beforePlainPhone = $this->plainPhone((string)($before->cell_phone ?? '')); + $beforeAudit = [ + 'mem_no' => $memNo, + 'stat_3' => (string)($before->stat_3 ?? ''), + 'dt_stat_3' => $before->dt_stat_3 ?? null, + 'cell_corp' => (string)($before->cell_corp ?? 'n'), + 'cell_phone' => $this->maskPhoneForLog($beforePlainPhone), + 'dt_mod' => $before->dt_mod ?? null, + 'state_log_last' => $beforeStateLogLast, + ]; + + // (audit용) after 스냅샷은 before에서 변경분만 덮어쓰기 + $afterAudit = $beforeAudit; // ✅ stat_3 변경 (1~3만 변경 허용, 4~6 금지 정책 유지) if (array_key_exists('stat_3', $input)) { @@ -171,16 +193,18 @@ final class AdminMemberService $beforeS3 = (string)($before->stat_3 ?? ''); if ($s3 !== $beforeS3) { $data['stat_3'] = $s3; - $data['dt_stat_3'] = now()->format('Y-m-d H:i:s'); + $data['dt_stat_3'] = $nowStr; - // ✅ 레거시 형식 로그 $stateLog[] = [ - 'when' => now()->format('y-m-d H:i:s'), + 'when' => $legacyWhen, 'after' => $s3, 'title' => '회원상태 접근권한 변경', 'before' => $beforeS3, 'admin_num' => (string)$actorAdminId, ]; + + $afterAudit['stat_3'] = $s3; + $afterAudit['dt_stat_3'] = $nowStr; } } @@ -195,16 +219,19 @@ final class AdminMemberService $data['cell_corp'] = $corp; $stateLog[] = [ - 'when' => now()->format('y-m-d H:i:s'), + 'when' => $legacyWhen, 'after' => $corp, 'title' => '통신사 변경', 'before' => $beforeCorp, 'admin_num' => (string)$actorAdminId, ]; + + $afterAudit['cell_corp'] = $corp; } } // ✅ 휴대폰 변경(암호화 저장) + $afterPhoneMasked = null; if (array_key_exists('cell_phone', $input)) { $raw = trim((string)($input['cell_phone'] ?? '')); @@ -222,14 +249,16 @@ final class AdminMemberService $data['cell_phone'] = $enc; // 로그는 마스킹(평문 full 저장 원하면 여기만 변경) - $beforePlain = $this->plainPhone($beforeEnc); $stateLog[] = [ - 'when' => now()->format('y-m-d H:i:s'), + 'when' => $legacyWhen, 'after' => $this->maskPhoneForLog($afterPlain), 'title' => '휴대폰번호 변경', - 'before' => $this->maskPhoneForLog($beforePlain), + 'before' => $this->maskPhoneForLog($beforePlainPhone), 'admin_num' => (string)$actorAdminId, ]; + + $afterPhoneMasked = $this->maskPhoneForLog($afterPlain); + $afterAudit['cell_phone'] = $afterPhoneMasked; } } @@ -242,11 +271,27 @@ final class AdminMemberService $data['modify_log'] = $this->encodeJsonObjectOrNull(['state_log' => $stateLog]); // 최근정보변경일시 - $data['dt_mod'] = now()->format('Y-m-d H:i:s'); + $data['dt_mod'] = $nowStr; $ok = $this->repo->updateMember($memNo, $data); if (!$ok) return $this->fail('저장에 실패했습니다.'); + // ✅ audit log (update 성공 후, 같은 트랜잭션 안에서) + $afterStateLogLast = empty($stateLog) ? null : $stateLog[count($stateLog) - 1]; + $afterAudit['dt_mod'] = $nowStr; + $afterAudit['state_log_last'] = $afterStateLogLast; + + $this->audit->log( + actorAdminId: $actorAdminId, + action: 'admin.member.update', + targetType: 'member', + targetId: $memNo, + before: $beforeAudit, + after: $afterAudit, + ip: $ip, + ua: $ua, + ); + return $this->ok('변경되었습니다.'); }); } catch (\Throwable $e) { @@ -255,6 +300,7 @@ final class AdminMemberService } + // ------------------------- // Maps // ------------------------- @@ -387,20 +433,27 @@ final class AdminMemberService if (mb_strlen($memo) > 1000) return $this->fail('메모는 1000자 이내로 입력해 주세요.'); try { - return DB::transaction(function () use ($memNo, $memo, $actorAdminId) { + return DB::transaction(function () use ($memNo, $memo, $actorAdminId, $ip, $ua) { $before = $this->repo->lockMemberForUpdate($memNo); if (!$before) return $this->fail('회원을 찾을 수 없습니다.'); + $now = now(); + $nowStr = $now->format('Y-m-d H:i:s'); + $legacyWhen = $now->format('y-m-d H:i:s'); // 레거시(2자리년도) + // ✅ 기존 admin_memo에서 레거시 memo[] 뽑기 $list = $this->legacyAdminMemoList($before->admin_memo ?? null); + $beforeCount = is_array($list) ? count($list) : 0; + $beforeLast = $beforeCount > 0 ? $list[$beforeCount - 1] : null; // ✅ append (레거시 키 유지) - $list[] = [ + $newItem = [ 'memo' => $memo, - 'when' => now()->format('y-m-d H:i:s'), // 레거시(2자리년도) + 'when' => $legacyWhen, 'admin_num' => (string)$actorAdminId, ]; + $list[] = $newItem; $list = $this->trimLegacyList($list, 300); // ✅ admin_memo는 반드시 {"memo":[...]} 형태 @@ -408,12 +461,50 @@ final class AdminMemberService $data = [ 'admin_memo' => $this->encodeJsonObjectOrNull($obj), - 'dt_mod' => now()->format('Y-m-d H:i:s'), + 'dt_mod' => $nowStr, ]; $ok = $this->repo->updateMember($memNo, $data); if (!$ok) return $this->fail('메모 저장에 실패했습니다.'); + // ✅ audit (성공 후) + $afterCount = count($list); + $afterLast = $afterCount > 0 ? $list[$afterCount - 1] : null; + + // 감사로그에는 메모 원문을 다 넣지 말고(유출/개인정보 위험), + // "추가됨" + 길이/해시/마지막 항목 일부 정도만 남기는 게 안전함. + $beforeAudit = [ + 'mem_no' => $memNo, + 'dt_mod' => $before->dt_mod ?? null, + 'admin_memo_cnt' => $beforeCount, + 'admin_memo_last'=> $beforeLast, + ]; + + $afterAudit = [ + 'mem_no' => $memNo, + 'dt_mod' => $nowStr, + 'admin_memo_cnt' => $afterCount, + 'admin_memo_last'=> $afterLast, + 'added' => [ + 'when' => $newItem['when'], + 'admin_num' => $newItem['admin_num'], + 'memo_len' => mb_strlen($memo), + // 필요하면 아래처럼 해시만 남겨도 됨(원문 미저장) + // 'memo_sha256' => hash('sha256', $memo), + ], + ]; + + $this->audit->log( + actorAdminId: $actorAdminId, + action: 'admin.member.memo.add', + targetType: 'member', + targetId: $memNo, + before: $beforeAudit, + after: $afterAudit, + ip: $ip, + ua: $ua, + ); + return $this->ok('메모가 추가되었습니다.'); }); } catch (\Throwable $e) { @@ -423,6 +514,7 @@ final class AdminMemberService + private function ok(string $msg): array { return ['ok'=>true,'message'=>$msg]; } private function fail(string $msg): array { return ['ok'=>false,'message'=>$msg]; } diff --git a/app/Services/Admin/Notice/AdminNoticeService.php b/app/Services/Admin/Notice/AdminNoticeService.php index 3bcb360..5f7e8a3 100644 --- a/app/Services/Admin/Notice/AdminNoticeService.php +++ b/app/Services/Admin/Notice/AdminNoticeService.php @@ -6,6 +6,7 @@ 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 { @@ -15,6 +16,7 @@ final class AdminNoticeService public function __construct( private readonly AdminNoticeRepository $repo, + private readonly AdminAuditService $audit, ) {} public function paginate(array $filters, int $perPage = 15) @@ -45,10 +47,12 @@ final class AdminNoticeService $firstSign = $max + 1; // ✅ 상단공지 순번(큰 값이 먼저) } + $content = (string)($data['content'] ?? ''); + $payload = [ 'gubun' => self::GUBUN, 'subject' => (string)($data['subject'] ?? ''), - 'content' => (string)($data['content'] ?? ''), + 'content' => $content, 'admin_num' => $adminId, 'regdate' => now()->format('Y-m-d H:i:s'), 'hiding' => 'N', // 기본 노출 @@ -62,7 +66,40 @@ final class AdminNoticeService if ($new2) $payload['file_02'] = $new2; $row = $this->repo->create($payload); - return (int) $row->getKey(); + $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 실패 시 새로 저장한 파일 제거 @@ -71,6 +108,7 @@ final class AdminNoticeService } } + public function update(int $id, array $data, ?UploadedFile $file1, ?UploadedFile $file2): void { // 새 파일 먼저 저장 @@ -84,12 +122,25 @@ final class AdminNoticeService $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(상단공지) 정리된 로직 - // - 체크 해제: 0 - // - 체크 + 기존이 0: max+1 부여 - // - 체크 + 기존이 >0: 기존 값 유지 + // ✅ first_sign 로직 유지 $wantPinned = !empty($data['first_sign']); $firstSign = (int)($row->first_sign ?? 0); @@ -101,9 +152,11 @@ final class AdminNoticeService $firstSign = 0; } + $content = (string)($data['content'] ?? ''); + $payload = [ 'subject' => (string)($data['subject'] ?? ''), - 'content' => (string)($data['content'] ?? ''), + 'content' => $content, 'link_01' => $this->nullIfBlank($data['link_01'] ?? null), 'link_02' => $this->nullIfBlank($data['link_02'] ?? null), 'hiding' => $hiding, @@ -124,6 +177,36 @@ final class AdminNoticeService $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; }); @@ -137,23 +220,59 @@ final class AdminNoticeService $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); diff --git a/app/Services/Admin/Qna/AdminQnaService.php b/app/Services/Admin/Qna/AdminQnaService.php index 68b304a..4c94c48 100644 --- a/app/Services/Admin/Qna/AdminQnaService.php +++ b/app/Services/Admin/Qna/AdminQnaService.php @@ -10,11 +10,13 @@ use Illuminate\Validation\ValidationException; use App\Jobs\SendUserQnaAnsweredMailJob; use Illuminate\Support\Str; use Illuminate\Support\Facades\Log; +use App\Services\Admin\AdminAuditService; final class AdminQnaService { public function __construct( private readonly AdminQnaRepository $repo, + private readonly AdminAuditService $audit, ) {} public function stateLabels(): array @@ -129,24 +131,56 @@ final class AdminQnaService 'seq','state','answer_admin_num','admin_change_memo' ]); - if ($row->state !== 'a'){ + if ($row->state !== 'a') { throw ValidationException::withMessages([ 'answer_content' => '현재 상태에서는 배정할 수 없습니다.', ]); } + // ✅ 감사로그 before (변경 전) + $beforeAudit = [ + 'year' => $year, + 'seq' => $seq, + 'state' => (string)($row->state ?? ''), + 'answer_admin_num' => (int)($row->answer_admin_num ?? 0), + ]; + $log = $this->decodeJson((string)($row->admin_change_memo ?? '')); $log = $this->pushStateLog($log, $adminId, $row->state, 'b', '업무를 직접 배정'); - $this->repo->update($year, $seq, [ + $payload = [ 'state' => 'b', 'answer_admin_num' => $adminId, 'receipt_date' => now()->format('Y-m-d H:i:s'), 'admin_change_memo' => json_encode($log, JSON_UNESCAPED_UNICODE), - ]); + ]; + + $this->repo->update($year, $seq, $payload); + + // ✅ 감사로그 after (변경 후) — 감사로그 실패해도 본 로직 영향 X + try { + $req = request(); + + $afterAudit = $beforeAudit; + $afterAudit['state'] = 'b'; + $afterAudit['answer_admin_num'] = $adminId; + $afterAudit['receipt_date'] = $payload['receipt_date']; + + $this->audit->log( + actorAdminId: $adminId, + action: 'admin.qna.assign_to_me', + targetType: 'qna', + targetId: (int)$seq, // year가 분리키면 before/after에 이미 남김 + before: $beforeAudit, + after: $afterAudit, + ip: (string)($req?->ip() ?? ''), + ua: (string)($req?->userAgent() ?? ''), + ); + } catch (\Throwable $ignored) {} }); } + public function startWork(int $seq, int $year, int $adminId): void { DB::transaction(function () use ($seq, $year, $adminId) { @@ -157,12 +191,20 @@ final class AdminQnaService $assigned = (int)($row->answer_admin_num ?? 0); if ($assigned !== $adminId) abort(403); - if (!in_array($row->state, ['b','d'], true)){ + if (!in_array($row->state, ['b','d'], true)) { throw ValidationException::withMessages([ 'answer_content' => '현재 상태에서는 업무 시작이 불가합니다.', ]); } + // ✅ 감사로그 before (변경 전) + $beforeAudit = [ + 'year' => $year, + 'seq' => $seq, + 'state' => (string)($row->state ?? ''), + 'answer_admin_num' => (int)($row->answer_admin_num ?? 0), + ]; + $msg = ($row->state === 'd') ? '업무를 재개합니다!' : '업무를 시작했습니다.'; $log = $this->decodeJson((string)($row->admin_change_memo ?? '')); $log = $this->pushStateLog($log, $adminId, $row->state, 'c', $msg); @@ -171,9 +213,29 @@ final class AdminQnaService 'state' => 'c', 'admin_change_memo' => json_encode($log, JSON_UNESCAPED_UNICODE), ]); + + // ✅ 감사로그 after (성공시에만 / 실패해도 본 로직 영향 X) + try { + $req = request(); + + $afterAudit = $beforeAudit; + $afterAudit['state'] = 'c'; + + $this->audit->log( + actorAdminId: $adminId, + action: 'admin.qna.start_work', + targetType: 'qna', + targetId: (int)$seq, + before: $beforeAudit, + after: $afterAudit, + ip: (string)($req?->ip() ?? ''), + ua: (string)($req?->userAgent() ?? ''), + ); + } catch (\Throwable $ignored) {} }); } + public function returnWork(int $seq, int $year, int $adminId): void { DB::transaction(function () use ($seq, $year, $adminId) { @@ -184,12 +246,20 @@ final class AdminQnaService $assigned = (int)($row->answer_admin_num ?? 0); if ($assigned !== $adminId) abort(403); - if (!in_array($row->state, ['b','c','d'], true)){ + if (!in_array($row->state, ['b','c','d'], true)) { throw ValidationException::withMessages([ 'answer_content' => '현재 상태에서는 반납할 수 없습니다.', ]); } + // ✅ 감사로그 before (변경 전) + $beforeAudit = [ + 'year' => $year, + 'seq' => $seq, + 'state' => (string)($row->state ?? ''), + 'answer_admin_num' => (int)($row->answer_admin_num ?? 0), + ]; + $log = $this->decodeJson((string)($row->admin_change_memo ?? '')); $log = $this->pushStateLog($log, $adminId, $row->state, 'a', '업무를 종료/반납했습니다.'); @@ -198,6 +268,26 @@ final class AdminQnaService 'answer_admin_num' => null, 'admin_change_memo' => json_encode($log, JSON_UNESCAPED_UNICODE), ]); + + // ✅ 감사로그 after (성공시에만 / 실패해도 본 로직 영향 X) + try { + $req = request(); + + $afterAudit = $beforeAudit; + $afterAudit['state'] = 'a'; + $afterAudit['answer_admin_num'] = null; + + $this->audit->log( + actorAdminId: $adminId, + action: 'admin.qna.return_work', + targetType: 'qna', + targetId: (int)$seq, + before: $beforeAudit, + after: $afterAudit, + ip: (string)($req?->ip() ?? ''), + ua: (string)($req?->userAgent() ?? ''), + ); + } catch (\Throwable $ignored) {} }); } @@ -211,33 +301,69 @@ final class AdminQnaService $assigned = (int)($row->answer_admin_num ?? 0); if ($assigned !== $adminId) abort(403); - if (!in_array($row->state, ['b','c'], true)){ + if (!in_array($row->state, ['b','c'], true)) { throw ValidationException::withMessages([ 'answer_content' => '현재 상태에서는 보류할 수 없습니다.', ]); } $comment = trim($comment); - if ($comment === ''){ + if ($comment === '') { throw ValidationException::withMessages([ 'answer_content' => '보류 사유를 입력해 주세요.', ]); } + // ✅ 감사로그 before (변경 전) + $beforeAudit = [ + 'year' => $year, + 'seq' => $seq, + 'state' => (string)($row->state ?? ''), + 'answer_admin_num' => (int)($row->answer_admin_num ?? 0), + ]; + $log = $this->decodeJson((string)($row->admin_change_memo ?? '')); $log = $this->pushStateLog($log, $adminId, $row->state, 'd', $comment); - $this->repo->update($year, $seq, [ + $payload = [ 'state' => 'd', 'defer_date' => now()->format('Y-m-d H:i:s'), 'defer_comment' => $comment, 'defer_admin_num' => $adminId, 'defer_code' => $deferCode ?: null, 'admin_change_memo' => json_encode($log, JSON_UNESCAPED_UNICODE), - ]); + ]; + + $this->repo->update($year, $seq, $payload); + + // ✅ 감사로그 after (성공시에만 / 실패해도 본 로직 영향 X) + try { + $req = request(); + + $afterAudit = $beforeAudit; + $afterAudit['state'] = 'd'; + $afterAudit['defer_date'] = $payload['defer_date']; + $afterAudit['defer_admin_num'] = $adminId; + $afterAudit['defer_code'] = $payload['defer_code']; + // comment는 길거나 민감할 수 있으니 전체 대신 길이/해시만 남김(원문 필요하면 여기만 변경) + $afterAudit['defer_comment_len'] = mb_strlen($comment); + $afterAudit['defer_comment_sha256'] = hash('sha256', $comment); + + $this->audit->log( + actorAdminId: $adminId, + action: 'admin.qna.postpone_work', + targetType: 'qna', + targetId: (int)$seq, + before: $beforeAudit, + after: $afterAudit, + ip: (string)($req?->ip() ?? ''), + ua: (string)($req?->userAgent() ?? ''), + ); + } catch (\Throwable $ignored) {} }); } + public function saveAnswer(int $seq, int $year, int $adminId, string $answerContent, string $answerSms): void { DB::transaction(function () use ($seq, $year, $adminId, $answerContent, $answerSms) { @@ -249,7 +375,7 @@ final class AdminQnaService $assigned = (int)($row->answer_admin_num ?? 0); if ($assigned !== $adminId) abort(403); - if (!in_array($row->state, ['c'], true)){ + if (!in_array($row->state, ['c'], true)) { throw ValidationException::withMessages([ 'answer_content' => '처리중 상태에서만 답변 저장이 가능합니다.', ]); @@ -261,6 +387,14 @@ final class AdminQnaService if ($answerContent === '') abort(422, '관리자 답변을 입력해 주세요.'); if ($answerSms === '') abort(422, 'SMS 답변을 입력해 주세요.'); + // ✅ 감사로그 before (변경 전) — 원문은 저장하지 않고 길이/해시만 + $beforeAudit = [ + 'year' => $year, + 'seq' => $seq, + 'state' => (string)($row->state ?? ''), + 'answer_admin_num' => (int)($row->answer_admin_num ?? 0), + ]; + $log = $this->decodeJson((string)($row->admin_change_memo ?? '')); $log = $this->pushMemoLog($log, $adminId, '관리자 답변 수정'); @@ -269,9 +403,32 @@ final class AdminQnaService 'answer_sms' => $answerSms, 'admin_change_memo' => json_encode($log, JSON_UNESCAPED_UNICODE), ]); + + // ✅ 감사로그 after (성공시에만 / 실패해도 본 로직 영향 X) + try { + $req = request(); + + $afterAudit = $beforeAudit; + $afterAudit['answer_content_len'] = mb_strlen($answerContent); + $afterAudit['answer_content_sha256'] = hash('sha256', $answerContent); + $afterAudit['answer_sms_len'] = mb_strlen($answerSms); + $afterAudit['answer_sms_sha256'] = hash('sha256', $answerSms); + + $this->audit->log( + actorAdminId: $adminId, + action: 'admin.qna.save_answer', + targetType: 'qna', + targetId: (int)$seq, + before: $beforeAudit, + after: $afterAudit, + ip: (string)($req?->ip() ?? ''), + ua: (string)($req?->userAgent() ?? ''), + ); + } catch (\Throwable $ignored) {} }); } + public function completeWork(int $seq, int $year, int $adminId): void { $notify = null; @@ -286,12 +443,22 @@ final class AdminQnaService $assigned = (int)($row->answer_admin_num ?? 0); if ($assigned !== $adminId) abort(403); - if (!in_array($row->state, ['b','c'], true)){ + if (!in_array($row->state, ['b','c'], true)) { throw ValidationException::withMessages([ 'answer_content' => '현재 상태에서는 완료 처리가 불가합니다.', ]); } + // ✅ 감사로그 before (변경 전) + $beforeAudit = [ + 'year' => $year, + 'seq' => $seq, + 'state' => (string)($row->state ?? ''), + 'answer_admin_num' => (int)($row->answer_admin_num ?? 0), + 'return_type' => (string)($row->return_type ?? 'web'), + 'member_num' => (int)($row->member_num ?? 0), + ]; + $answerContent = trim((string)($row->answer_content ?? '')); $answerSms = trim((string)($row->answer_sms ?? '')); @@ -309,12 +476,40 @@ final class AdminQnaService $log = $this->decodeJson((string)($row->admin_change_memo ?? '')); $log = $this->pushStateLog($log, $adminId, $row->state, 'e', '업무를 완료했습니다.'); + $completionDate = now()->format('Y-m-d H:i:s'); + $this->repo->update($year, $seq, [ 'state' => 'e', - 'completion_date' => now()->format('Y-m-d H:i:s'), + 'completion_date' => $completionDate, 'admin_change_memo' => json_encode($log, JSON_UNESCAPED_UNICODE), ]); + // ✅ 감사로그 after (성공시에만 / 실패해도 본 로직 영향 X) + try { + $req = request(); + + $afterAudit = $beforeAudit; + $afterAudit['state'] = 'e'; + $afterAudit['completion_date'] = $completionDate; + + // 답변 원문은 감사로그에 저장하지 않고 길이/해시만 + $afterAudit['answer_content_len'] = mb_strlen($answerContent); + $afterAudit['answer_content_sha256'] = hash('sha256', $answerContent); + $afterAudit['answer_sms_len'] = mb_strlen($answerSms); + $afterAudit['answer_sms_sha256'] = hash('sha256', $answerSms); + + $this->audit->log( + actorAdminId: $adminId, + action: 'admin.qna.complete_work', + targetType: 'qna', + targetId: (int)$seq, + before: $beforeAudit, + after: $afterAudit, + ip: (string)($req?->ip() ?? ''), + ua: (string)($req?->userAgent() ?? ''), + ); + } catch (\Throwable $ignored) {} + // 트랜잭션 밖에서 발송하도록 데이터만 준비 $notify = [ 'return_type' => $rt, @@ -336,6 +531,7 @@ final class AdminQnaService } } + public function addMemo(int $seq, int $year, int $adminId, string $memo): void { DB::transaction(function () use ($seq, $year, $adminId, $memo) { @@ -349,15 +545,43 @@ final class AdminQnaService // 메모는 배정자만 제한할지, 모든 관리자 가능할지 선택. 관리자 모두 // if ((int)($row->answer_admin_num ?? 0) !== $adminId) abort(403); + // ✅ 감사로그 before (변경 전) — 메모 원문은 저장하지 않음 + $beforeAudit = [ + 'year' => $year, + 'seq' => $seq, + 'answer_admin_num' => (int)($row->answer_admin_num ?? 0), + ]; + $log = $this->decodeJson((string)($row->admin_change_memo ?? '')); $log = $this->pushMemoLog($log, $adminId, $memo); $this->repo->update($year, $seq, [ 'admin_change_memo' => json_encode($log, JSON_UNESCAPED_UNICODE), ]); + + // ✅ 감사로그 after (성공시에만 / 실패해도 본 로직 영향 X) + try { + $req = request(); + + $afterAudit = $beforeAudit; + $afterAudit['memo_len'] = mb_strlen($memo); + $afterAudit['memo_sha256'] = hash('sha256', $memo); + + $this->audit->log( + actorAdminId: $adminId, + action: 'admin.qna.memo.add', + targetType: 'qna', + targetId: (int)$seq, + before: $beforeAudit, + after: $afterAudit, + ip: (string)($req?->ip() ?? ''), + ua: (string)($req?->userAgent() ?? ''), + ); + } catch (\Throwable $ignored) {} }); } + private function allowedActions(object $row, int $adminId): array { $state = (string)$row->state; diff --git a/app/Services/Admin/Sms/AdminSmsTemplateService.php b/app/Services/Admin/Sms/AdminSmsTemplateService.php index 10fb2b2..93ad50b 100644 --- a/app/Services/Admin/Sms/AdminSmsTemplateService.php +++ b/app/Services/Admin/Sms/AdminSmsTemplateService.php @@ -3,11 +3,13 @@ namespace App\Services\Admin\Sms; use App\Repositories\Admin\Sms\AdminSmsTemplateRepository; +use App\Services\Admin\AdminAuditService; final class AdminSmsTemplateService { public function __construct( - private readonly AdminSmsTemplateRepository $repo + private readonly AdminSmsTemplateRepository $repo, + private readonly AdminAuditService $audit, ) {} public function list(array $filters, int $perPage = 30) @@ -27,30 +29,91 @@ final class AdminSmsTemplateService return ['ok'=>false, 'message'=>'code는 영문/숫자/대시/언더바 3~60자로 입력하세요.']; } - $id = $this->repo->insert([ + // 감사로그용: before/after 구성 (민감정보 없음) + $after = [ 'code' => $code, 'title' => trim((string)($data['title'] ?? '')), 'body' => (string)($data['body'] ?? ''), 'description' => trim((string)($data['description'] ?? '')) ?: null, 'is_active' => (int)($data['is_active'] ?? 1), 'created_by' => $adminId, - ]); + ]; + + $id = $this->repo->insert($after); + + // ✅ 성공시에만 감사로그 (id가 0/음수면 기록 안 함) + if ($id > 0) { + $req = request(); + $this->audit->log( + actorAdminId: $adminId, + action: 'admin.template.create', // 네 컨벤션에 맞게 수정 가능 + targetType: 'template', // 실제 리소스명에 맞게 + targetId: (int)$id, + before: null, + after: array_merge(['id' => (int)$id], $after), + ip: (string)($req?->ip() ?? ''), + ua: (string)($req?->userAgent() ?? ''), + ); + } return ['ok'=>true, 'id'=>$id]; } + public function update(int $id, array $data): array { - $affected = $this->repo->update($id, [ + // ✅ before 스냅샷(가능하면) — repo에 find/get이 없으면 lockForUpdate 같은 걸로 맞춰야 함 + // 여기서는 "repo->find($id)"가 있다고 가정하지 않고, 안전하게 try로 감쌈. + $before = null; + try { + if (method_exists($this->repo, 'find')) { + $before = $this->repo->find($id); + } elseif (method_exists($this->repo, 'get')) { + $before = $this->repo->get($id); + } + } catch (\Throwable $ignored) { + $before = null; + } + + $payload = [ 'title' => trim((string)($data['title'] ?? '')), 'body' => (string)($data['body'] ?? ''), 'description' => trim((string)($data['description'] ?? '')) ?: null, 'is_active' => (int)($data['is_active'] ?? 1), - ]); + ]; - return ['ok'=>($affected >= 0)]; + $affected = $this->repo->update($id, $payload); + + // ✅ 기존 리턴 정책 유지 + $ok = ($affected >= 0); + + // ✅ 성공시에만 감사로그 + if ($ok) { + $req = request(); + $actorAdminId = (int)(auth('admin')->id() ?? 0); + + // actorAdminId를 못 구하면 로그 생략(기존 프로그램 방해 X) + if ($actorAdminId > 0) { + $beforeAudit = $before ? (array)$before : ['id' => $id]; + $afterAudit = array_merge($beforeAudit, $payload); + + $this->audit->log( + actorAdminId: $actorAdminId, + action: 'admin.template.update', // 네 컨벤션에 맞게 + targetType: 'template', + targetId: (int)$id, + before: $beforeAudit, + after: $afterAudit, + ip: (string)($req?->ip() ?? ''), + ua: (string)($req?->userAgent() ?? ''), + ); + } + } + + return ['ok'=>$ok]; } + public function activeForSend(int $limit = 200): array { return $this->repo->listActive($limit);