관리자 행위 로그저장 처리

This commit is contained in:
sungro815 2026-02-12 15:13:55 +09:00
parent f13196780e
commit 6d4195aacd
7 changed files with 748 additions and 73 deletions

View File

@ -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);
}
}

View File

@ -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)
{

View File

@ -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'] ?? '');

View File

@ -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]; }

View File

@ -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);

View File

@ -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;

View File

@ -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);