Browse Source

first commit

X\choro 3 tháng trước cách đây
commit
48f1b97cc3
100 tập tin đã thay đổi với 12340 bổ sung0 xóa
  1. 62 0
      .env
  2. 5 0
      .hintrc
  3. 66 0
      README.md
  4. 32 0
      app/Console/Kernel.php
  5. 50 0
      app/Exceptions/Handler.php
  6. 415 0
      app/Helpers/common.php
  7. 169 0
      app/Helpers/string.php
  8. 50 0
      app/Http/Controllers/Account/CertifyController.php
  9. 85 0
      app/Http/Controllers/Account/CommentController.php
  10. 137 0
      app/Http/Controllers/Account/EmailController.php
  11. 68 0
      app/Http/Controllers/Account/LeaveController.php
  12. 37 0
      app/Http/Controllers/Account/LoginLogController.php
  13. 144 0
      app/Http/Controllers/Account/ModifyController.php
  14. 82 0
      app/Http/Controllers/Account/PasswordCampaignController.php
  15. 78 0
      app/Http/Controllers/Account/PasswordChangeController.php
  16. 87 0
      app/Http/Controllers/Account/PostController.php
  17. 32 0
      app/Http/Controllers/Account/ProfileController.php
  18. 68 0
      app/Http/Controllers/Admin/AjaxController.php
  19. 110 0
      app/Http/Controllers/Admin/Board/Blame/CommentController.php
  20. 111 0
      app/Http/Controllers/Admin/Board/Blame/PostController.php
  21. 158 0
      app/Http/Controllers/Admin/Board/Board/AuthorityController.php
  22. 152 0
      app/Http/Controllers/Admin/Board/Board/CategoryController.php
  23. 185 0
      app/Http/Controllers/Admin/Board/Board/CommentController.php
  24. 186 0
      app/Http/Controllers/Admin/Board/Board/ExpController.php
  25. 171 0
      app/Http/Controllers/Admin/Board/Board/GeneralController.php
  26. 298 0
      app/Http/Controllers/Admin/Board/Board/ListController.php
  27. 123 0
      app/Http/Controllers/Admin/Board/Board/ManagerController.php
  28. 300 0
      app/Http/Controllers/Admin/Board/Board/NotifyController.php
  29. 190 0
      app/Http/Controllers/Admin/Board/Board/PostController.php
  30. 188 0
      app/Http/Controllers/Admin/Board/Board/ViewController.php
  31. 197 0
      app/Http/Controllers/Admin/Board/Board/WriteController.php
  32. 87 0
      app/Http/Controllers/Admin/Board/CommentController.php
  33. 133 0
      app/Http/Controllers/Admin/Board/File/DownloadController.php
  34. 111 0
      app/Http/Controllers/Admin/Board/File/UploadController.php
  35. 267 0
      app/Http/Controllers/Admin/Board/Group/ListController.php
  36. 101 0
      app/Http/Controllers/Admin/Board/Group/ManagerController.php
  37. 83 0
      app/Http/Controllers/Admin/Board/History/CommentController.php
  38. 83 0
      app/Http/Controllers/Admin/Board/History/PostController.php
  39. 123 0
      app/Http/Controllers/Admin/Board/ImageController.php
  40. 103 0
      app/Http/Controllers/Admin/Board/Like/CommentController.php
  41. 112 0
      app/Http/Controllers/Admin/Board/Like/PostController.php
  42. 84 0
      app/Http/Controllers/Admin/Board/Link/ListController.php
  43. 89 0
      app/Http/Controllers/Admin/Board/Link/LogController.php
  44. 93 0
      app/Http/Controllers/Admin/Board/PostController.php
  45. 87 0
      app/Http/Controllers/Admin/Board/TagController.php
  46. 127 0
      app/Http/Controllers/Admin/Board/Trash/CommentController.php
  47. 127 0
      app/Http/Controllers/Admin/Board/Trash/PostController.php
  48. 104 0
      app/Http/Controllers/Admin/Config/Form/EmailController.php
  49. 52 0
      app/Http/Controllers/Admin/Config/Form/TelegramController.php
  50. 84 0
      app/Http/Controllers/Admin/Config/Layout/LogoController.php
  51. 64 0
      app/Http/Controllers/Admin/Config/Layout/MetaController.php
  52. 59 0
      app/Http/Controllers/Admin/Config/OptimizeController.php
  53. 86 0
      app/Http/Controllers/Admin/Config/Register/BasicController.php
  54. 58 0
      app/Http/Controllers/Admin/Config/Register/LoginController.php
  55. 52 0
      app/Http/Controllers/Admin/Config/Register/ModifyController.php
  56. 84 0
      app/Http/Controllers/Admin/Config/Register/NotifyController.php
  57. 359 0
      app/Http/Controllers/Admin/Config/ServerController.php
  58. 60 0
      app/Http/Controllers/Admin/Config/Setting/AccessController.php
  59. 88 0
      app/Http/Controllers/Admin/Config/Setting/BasicController.php
  60. 82 0
      app/Http/Controllers/Admin/Config/Setting/CompanyController.php
  61. 62 0
      app/Http/Controllers/Admin/Config/Setting/ExpController.php
  62. 60 0
      app/Http/Controllers/Admin/Config/Setting/NotifyController.php
  63. 61 0
      app/Http/Controllers/Admin/Config/Test/EmailController.php
  64. 53 0
      app/Http/Controllers/Admin/Config/Test/TelegramController.php
  65. 99 0
      app/Http/Controllers/Admin/Config/TestController.php
  66. 40 0
      app/Http/Controllers/Admin/ExchangeController.php
  67. 151 0
      app/Http/Controllers/Admin/Page/Banner/GroupController.php
  68. 286 0
      app/Http/Controllers/Admin/Page/Banner/ListController.php
  69. 205 0
      app/Http/Controllers/Admin/Page/DocumentController.php
  70. 187 0
      app/Http/Controllers/Admin/Page/MenuController.php
  71. 249 0
      app/Http/Controllers/Admin/Page/PopupController.php
  72. 89 0
      app/Http/Controllers/Admin/Popup/UserController.php
  73. 70 0
      app/Http/Controllers/Admin/User/Dormant/ConfigController.php
  74. 60 0
      app/Http/Controllers/Admin/User/Dormant/Form/EmailController.php
  75. 54 0
      app/Http/Controllers/Admin/User/Dormant/Form/SmsController.php
  76. 165 0
      app/Http/Controllers/Admin/User/Dormant/ListController.php
  77. 80 0
      app/Http/Controllers/Admin/User/Dormant/NotifyController.php
  78. 350 0
      app/Http/Controllers/Admin/User/ListController.php
  79. 64 0
      app/Http/Controllers/Admin/User/Log/EmailController.php
  80. 65 0
      app/Http/Controllers/Admin/User/Log/Login/LogController.php
  81. 164 0
      app/Http/Controllers/Admin/User/Log/Login/StatController.php
  82. 64 0
      app/Http/Controllers/Admin/User/Log/NameController.php
  83. 33 0
      app/Http/Controllers/AdminController.php
  84. 212 0
      app/Http/Controllers/ApiController.php
  85. 40 0
      app/Http/Controllers/Auth/ConfirmPasswordController.php
  86. 36 0
      app/Http/Controllers/Auth/ForgotPasswordController.php
  87. 175 0
      app/Http/Controllers/Auth/LoginController.php
  88. 150 0
      app/Http/Controllers/Auth/RegisterController.php
  89. 54 0
      app/Http/Controllers/Auth/ResetPasswordController.php
  90. 42 0
      app/Http/Controllers/Auth/VerificationController.php
  91. 66 0
      app/Http/Controllers/Board/BoardController.php
  92. 321 0
      app/Http/Controllers/Board/CommentController.php
  93. 420 0
      app/Http/Controllers/Board/PostController.php
  94. 15 0
      app/Http/Controllers/Controller.php
  95. 33 0
      app/Http/Controllers/DocumentController.php
  96. 28 0
      app/Http/Controllers/HomeController.php
  97. 145 0
      app/Http/Controllers/MainController.php
  98. 187 0
      app/Http/Controllers/Movie/RankController.php
  99. 457 0
      app/Http/Controllers/Movie/ReviewController.php
  100. 170 0
      app/Http/Controllers/Movie/SearchController.php

+ 62 - 0
.env

@@ -0,0 +1,62 @@
+APP_NAME=MOVIE
+APP_ENV=local
+APP_KEY=base64:AUqIa1L0F99tQlxZNeSKS2cr0Fbm6Wfisd4D6y0plC0=
+APP_DEBUG=true
+APP_URL=https://local-movie.web.or.kr
+
+LOG_CHANNEL=stack
+LOG_DEPRECATIONS_CHANNEL=null
+LOG_LEVEL=debug
+
+DB_CONNECTION=mysql
+DB_HOST=192.168.0.100
+DB_PORT=3306
+DB_DATABASE=movie
+DB_USERNAME=admin
+DB_PASSWORD=bluescreen!!
+
+FILESYSTEM_DISK=local
+BROADCAST_DRIVER=log
+QUEUE_CONNECTION=database
+
+SESSION_DRIVER=redis
+SESSION_LIFETIME=120
+
+CACHE_DRIVER=redis
+REDIS_CLIENT=predis
+REDIS_HOST=redis
+REDIS_PASSWORD=bluescreen!!
+REDIS_PORT=6379
+REDIS_DB=0
+REDIS_CACHE_DB=0
+
+MAIL_MAILER=smtp
+MAIL_HOST=${SMTP_DOMAIN}
+MAIL_PORT=${SMTP_PORT}
+MAIL_USERNAME=${SMTP_USER}
+MAIL_PASSWORD=${SMTP_PASS}
+MAIL_ENCRYPTION=ssl
+MAIL_FROM_ADDRESS="system@web.or.kr"
+MAIL_FROM_NAME="${APP_NAME}"
+
+AWS_ACCESS_KEY_ID=
+AWS_SECRET_ACCESS_KEY=
+AWS_DEFAULT_REGION=us-east-1
+AWS_BUCKET=
+AWS_USE_PATH_STYLE_ENDPOINT=false
+
+PUSHER_APP_ID=
+PUSHER_APP_KEY=
+PUSHER_APP_SECRET=
+PUSHER_HOST=
+PUSHER_PORT=443
+PUSHER_SCHEME=https
+PUSHER_APP_CLUSTER=mt1
+
+VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
+VITE_PUSHER_HOST="${PUSHER_HOST}"
+VITE_PUSHER_PORT="${PUSHER_PORT}"
+VITE_PUSHER_SCHEME="${PUSHER_SCHEME}"
+VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
+
+MOVIE_API="http://192.168.0.10:1050"

+ 5 - 0
.hintrc

@@ -0,0 +1,5 @@
+{
+  "extends": [
+    "development"
+  ]
+}

+ 66 - 0
README.md

@@ -0,0 +1,66 @@
+<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
+
+<p align="center">
+<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
+<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
+<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
+<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
+</p>
+
+## About Laravel
+
+Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
+
+- [Simple, fast routing engine](https://laravel.com/docs/routing).
+- [Powerful dependency injection container](https://laravel.com/docs/container).
+- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
+- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
+- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
+- [Robust background job processing](https://laravel.com/docs/queues).
+- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
+
+Laravel is accessible, powerful, and provides tools required for large, robust applications.
+
+## Learning Laravel
+
+Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework.
+
+You may also try the [Laravel Bootcamp](https://bootcamp.laravel.com), where you will be guided through building a modern Laravel application from scratch.
+
+If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains over 2000 video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
+
+## Laravel Sponsors
+
+We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the Laravel [Patreon page](https://patreon.com/taylorotwell).
+
+### Premium Partners
+
+- **[Vehikl](https://vehikl.com/)**
+- **[Tighten Co.](https://tighten.co)**
+- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**
+- **[64 Robots](https://64robots.com)**
+- **[Cubet Techno Labs](https://cubettech.com)**
+- **[Cyber-Duck](https://cyber-duck.co.uk)**
+- **[Many](https://www.many.co.uk)**
+- **[Webdock, Fast VPS Hosting](https://www.webdock.io/en)**
+- **[DevSquad](https://devsquad.com)**
+- **[Curotec](https://www.curotec.com/services/technologies/laravel/)**
+- **[OP.GG](https://op.gg)**
+- **[WebReinvent](https://webreinvent.com/?utm_source=laravel&utm_medium=github&utm_campaign=patreon-sponsors)**
+- **[Lendio](https://lendio.com)**
+
+## Contributing
+
+Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
+
+## Code of Conduct
+
+In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
+
+## Security Vulnerabilities
+
+If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
+
+## License
+
+The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).

+ 32 - 0
app/Console/Kernel.php

@@ -0,0 +1,32 @@
+<?php
+
+namespace App\Console;
+
+use Illuminate\Console\Scheduling\Schedule;
+use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
+
+class Kernel extends ConsoleKernel
+{
+    /**
+     * Define the application's command schedule.
+     *
+     * @param  \Illuminate\Console\Scheduling\Schedule  $schedule
+     * @return void
+     */
+    protected function schedule(Schedule $schedule)
+    {
+        // $schedule->command('inspire')->hourly();
+    }
+
+    /**
+     * Register the commands for the application.
+     *
+     * @return void
+     */
+    protected function commands()
+    {
+        $this->load(__DIR__.'/Commands');
+
+        require base_path('routes/console.php');
+    }
+}

+ 50 - 0
app/Exceptions/Handler.php

@@ -0,0 +1,50 @@
+<?php
+
+namespace App\Exceptions;
+
+use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
+use Throwable;
+
+class Handler extends ExceptionHandler
+{
+    /**
+     * A list of exception types with their corresponding custom log levels.
+     *
+     * @var array<class-string<\Throwable>, \Psr\Log\LogLevel::*>
+     */
+    protected $levels = [
+        //
+    ];
+
+    /**
+     * A list of the exception types that are not reported.
+     *
+     * @var array<int, class-string<\Throwable>>
+     */
+    protected $dontReport = [
+        //
+    ];
+
+    /**
+     * A list of the inputs that are never flashed to the session on validation exceptions.
+     *
+     * @var array<int, string>
+     */
+    protected $dontFlash = [
+        'current_password',
+        'password',
+        'password_confirmation',
+    ];
+
+    /**
+     * Register the exception handling callbacks for the application.
+     *
+     * @return void
+     */
+    public function register()
+    {
+        $this->reportable(function (Throwable $e) {
+            //
+        });
+    }
+}

+ 415 - 0
app/Helpers/common.php

@@ -0,0 +1,415 @@
+<?php
+
+// 전체 배열의 키를 대문자, 소문자로 변경해서 반환한다.
+function arrayChangeKeyCaseRecursive($arr = [], $case = CASE_LOWER): string
+{
+    return array_map(function ($item) use ($case) {
+        if (is_array($item)){
+            $item = arrayChangeKeyCaseRecursive($item, $case);
+        }
+        return $item;
+    }, array_change_key_case($arr, $case));
+}
+
+// Request Url
+function currentURL(): string
+{
+    $protocol = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? "https" : "http");
+    $host = (isset($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : 'localhost');
+    $query = (isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '');
+    return sprintf("%s://%s%s", $protocol, $host, $query);
+}
+
+// 데이터 단위 변환
+function byteFormat($num, $precision = 1): string
+{
+    if ($num >= 1000000000000) {
+        $num = round($num / 1099511627776, $precision);
+        $unit = 'TB';
+    } elseif ($num >= 1000000000) {
+        $num = round($num / 1073741824, $precision);
+        $unit = 'GB';
+    } elseif ($num >= 1000000) {
+        $num = round($num / 1048576, $precision);
+        $unit = 'MB';
+    } elseif ($num >= 1000) {
+        $num = round($num / 1024, $precision);
+        $unit = 'KB';
+    } else {
+        $unit = 'Bytes';
+        return number_format($num) . ' ' . $unit;
+    }
+    return number_format($num, $precision) . ' ' . $unit;
+}
+
+// 데이터 단위 지정 변환
+function byteToFormat($bytes = 0, $unit = "", $decimals = 0): string
+{
+    $units = ['B' => 0, 'KB' => 1, 'MB' => 2, 'GB' => 3, 'TB' => 4, 'PB' => 5, 'EB' => 6, 'ZB' => 7, 'YB' => 8];
+    $value = 0;
+    if ($bytes > 0) {
+        if (!array_key_exists($unit, $units)) {
+            $pow = floor(log($bytes) / log(1024));
+            $unit = array_search($pow, $units);
+        }
+        $value = ($bytes / pow(1024, floor($units[$unit])));
+    }
+    if (!is_numeric($decimals) || $decimals < 0) {
+        $decimals = 2;
+    }
+    return sprintf('%.' . $decimals . 'f ' . $unit, $value);
+}
+
+// 상태 보기
+function show($varName): string
+{
+    switch ($result = get_cfg_var($varName)) {
+        case 0:
+            return '<span class="text-danger">Off</span>';
+        case 1:
+            return '<span class="text-success">On</span>';
+        default:
+            return $result;
+    }
+}
+
+// 리눅스 시스템 보기 - 파일권한
+function isFun($funName = ""): string
+{
+    if (!$funName || trim($funName) == '' || preg_match('~[^a-z0-9\_]+~i', $funName, $tmp)) {
+        return '알수없음';
+    } else {
+        return (false !== function_exists($funName)) ? '<span class="text-success">사용가능</span>' : '<span class="text-danger">Г—</span>';
+    }
+}
+
+// 리눅스 시스템 보기 - CPU 모델
+function GetCoreInformation(): array
+{
+    $data = @file('/proc/stat');
+    $cores = [];
+
+    if($data) {
+        foreach ($data as $line) {
+            if (preg_match('/^cpu[0-9]/', $line)) {
+                $info = explode(' ', $line);
+                $cores[] = ['user' => $info[1], 'nice' => $info[2], 'sys' => $info[3], 'idle' => $info[4], 'iowait' => $info[5], 'irq' => $info[6], 'softirq' => $info[7]];
+            }
+        }
+    }
+
+    return $cores;
+}
+
+// 리눅스 시스템 보기 CPU 속도
+function GetCpuPercentages($stat1 = [], $stat2 = []): array
+{
+    $cpus = [];
+    if (count($stat1) !== count($stat2)) {
+        return $cpus;
+    }
+
+    for ($i = 0, $l = count($stat1); $i < $l; $i++) {
+        $dif = [];
+        $dif['user'] = ($stat2[$i]['user'] - $stat1[$i]['user']);
+        $dif['nice'] = ($stat2[$i]['nice'] - $stat1[$i]['nice']);
+        $dif['sys'] = ($stat2[$i]['sys'] - $stat1[$i]['sys']);
+        $dif['idle'] = ($stat2[$i]['idle'] - $stat1[$i]['idle']);
+        $dif['iowait'] = ($stat2[$i]['iowait'] - $stat1[$i]['iowait']);
+        $dif['irq'] = ($stat2[$i]['irq'] - $stat1[$i]['irq']);
+        $dif['softirq'] = ($stat2[$i]['softirq'] - $stat1[$i]['softirq']);
+        $total = array_sum($dif);
+        $cpu = [];
+
+        foreach ($dif as $x => $y) {
+            $cpu[$x] = round($y / $total * 100, 2);
+        }
+        $cpus['cpu' . $i] = $cpu;
+    }
+    return $cpus;
+}
+
+// 현재 URL 이 대상 URL 과 같다면 active 상태
+function activeUrl($segment = ''): string
+{
+    return (request()->is($segment) ? 'active' : '');
+}
+
+// 관리자 설정값 조회
+function configs($item = '', $default = ''): string|int|null
+{
+    $config = cache('config-meta');
+
+    if($item) {
+        $ret = (property_exists($config, $item) ? $config->{$item} : $default);
+    }else{
+        $ret = $config;
+    }
+
+    return $ret;
+}
+
+// 게시판 에디터 출력
+function htmlEditor($name = '', $content = '', $className = '', $isDhtmlEditor = true, $emoticonYN = true, $placeholder = "", $rows = 0, $id = ''): string
+{
+    // TINYMCE 에디터 추가
+    $html = "";
+
+    if ($isDhtmlEditor && !defined('LOAD_DHTML_EDITOR_JS'))
+    {
+        // tinymce iframe 허용
+        $whiteIframe = configs('white_iframe');
+        $whiteIframe = preg_replace("/[\r|\n|\r\n]+/", ",", $whiteIframe);
+        $whiteIframe = preg_replace("/\s+/", "", $whiteIframe);
+
+        $html .= PHP_EOL . '<script type="module">';
+
+        // 아이프레임 주소 허용여부 결정
+        $html .= sprintf('var whiteIframe = "%s";', $whiteIframe);
+
+        // 이모티콘 사용여부
+        $html .= sprintf('var useEmoticon = "%s";', ($emoticonYN ? 'Y' : 'N'));
+
+        $html .= '</script>';
+
+        define('LOAD_DHTML_EDITOR_JS', true);
+    }
+
+    $html .= '<textarea';
+    $html .= sprintf(' class="tinymce-editor %s"', $className);
+
+    if($name) {
+        $html .= sprintf(' name="%s"', $name);
+    }
+
+    if($id) {
+        $html .= sprintf(' id="%s"', $id);
+    }
+
+    if($placeholder) {
+        $html .= sprintf(' placeholder="%s"', $placeholder);
+    }
+
+    if ($rows) {
+        $html .= sprintf(' rows="%s"', $rows);
+    }
+
+    $html .= ('>' . htmlspecialchars($content) . '</textarea>');
+
+    return $html;
+}
+
+// 관리자 Form Submit 정보 attributes 와 맵핑
+function mapAdminAttr(array $rules, array $posts): array
+{
+    foreach(array_keys($rules) as $field) {
+        if(array_key_exists($field, $posts)) {
+            continue;
+        }
+        $posts[$field] = 0;
+    }
+    return $posts;
+}
+
+// FreeBSD 값 조회
+function getKey($keyName): bool
+{
+    return doCommand('sysctl', "-n $keyName");
+}
+
+// FreeBSD 시스템 명령 실행
+function doCommand($commandName, $args): bool
+{
+    $buffer = "";
+    if (false === ($command = findCommand($commandName))) {
+        return false;
+    }
+    if ($fp = @popen("$command $args", 'r')) {
+        while (!@feof($fp)) {
+            $buffer .= @fgets($fp, 4096);
+        }
+        return trim($buffer);
+    }
+    return false;
+}
+
+// FreeBSD 명령어 지정 조회
+function findCommand($commandName)
+{
+    $path = ['/bin', '/sbin', '/usr/bin', '/usr/sbin', '/usr/local/bin', '/usr/local/sbin'];
+    foreach ($path as $p) {
+        if (@is_executable("$p/$commandName")) {
+            return "$p/$commandName";
+        }
+    }
+    return false;
+}
+
+// 날짜와 시간에 <br/>, \n 추가
+function dateBr($date = null, $default = ''): string
+{
+    return ($date ? date('Y-m-d <\b\r /> H:i:s', strtotime($date)) : $default);
+}
+
+// Alert 띄우기
+function alert(string $msg, string|null $url = "", bool $redirect = true): Illuminate\Routing\Redirector|\Illuminate\Http\RedirectResponse|Illuminate\Http\Response|\Illuminate\Contracts\Routing\ResponseFactory
+{
+    $msg = preg_replace('/(\r\n|\r|\n)/', '\n', addslashes($msg)); // 줄바꿈 되도록 출력
+    $msg = strip_tags(trim($msg), '<br/>'); // 태그 제거, 공백 제거
+    $msg = preg_replace('/<br[^>]*>/i', '\n\n', $msg); // br 태그를 \n로 개행 alert 에서 다음줄로 하기 위함.
+
+    if ($redirect) {
+        if ($url) {
+            return redirect($url)->withErrors($msg)->withInput();
+        } else {
+            return back()->withErrors($msg)->withInput();
+        }
+    }else{
+        return response(
+            sprintf('<script>alert("%s");</script>', $msg)
+        , 200, ['Content-Type', 'text/javascript']);
+    }
+}
+
+// 로그인 확인
+function loginCheck(string $callback = null): Illuminate\Http\Response|\Illuminate\Contracts\Routing\ResponseFactory
+{
+    $s = "<script>";
+    $s .= "if (confirm(\"로그인 후 이용하실 수 있습니다.\\n로그인 화면으로 이동하시겠습니까?\")) {";
+    $s .= "location.href = \"" . route('login') . "?callback=" . urlencode(url($_SERVER['REQUEST_URI']) ?? $callback) . "\";";
+    $s .= "}else{";
+    $s .= "history.go(-1);";
+    $s .= "}";
+    $s .= "</script>";
+    return response($s, 200, ['Content-Type', 'text/javascript']);
+}
+
+// Alert 후 창 닫음
+function alertClose(string $msg): Illuminate\Http\Response|\Illuminate\Contracts\Routing\ResponseFactory
+{
+    $s = "<script>";
+    $s .= "alert(\"$msg\");";
+    $s .= "self.close();";
+    $s .= "window.close();";
+    $s .= "</script>";
+    return response($s, 200, ['Content-Type', 'text/javascript']);
+}
+
+// Send socket
+function getSock(string $url): string
+{
+    // host 와 uri 를 분리
+    $host = "";
+    $get = "";
+
+    if (preg_match("/http:\/\/([a-zA-Z0-9_\-\.]+)([^<]*)/", $url, $res)) {
+        $host = $res[1];
+        $get = $res[2];
+    }
+
+    // 80번 포트로 소캣접속 시도
+    $fp = fsockopen($host, 80, $n, $s, 30);
+    if (empty($fp)) {
+        die($s . ' (' . $n . ")\n");
+    } else {
+        fputs($fp, "GET $get HTTP/1.0\r\n");
+        fputs($fp, "Host: $host\r\n");
+        fputs($fp, "\r\n");
+
+        $header = '';
+        // header 와 content 를 분리한다.
+        while (trim($buffer = fgets($fp, 1024)) !== '') {
+            $header .= $buffer;
+        }
+        while (!feof($fp)) {
+            $buffer .= fgets($fp, 1024);
+        }
+    }
+    fclose($fp);
+
+    // content 만 return 한다.
+    return $buffer;
+}
+
+// 휴대폰 번호 조회
+function getTelNumber($phone, $hyphen = 1): string
+{
+    if ($hyphen) {
+        $preg = "$1-$2-$3";
+    } else {
+        $preg = "$1$2$3";
+    }
+
+    $phone = str_replace('-', '', trim($phone));
+    return preg_replace(
+        "/^(01[016789])([0-9]{3,4})([0-9]{4})$/",
+        $preg,
+        $phone
+    );
+}
+
+// 수행 측정시간
+function getTime(): float
+{
+    $t = explode(' ', microtime());
+    return (float)$t[0] + (float)$t[1];
+}
+
+/**
+ * iframe tag 조회
+ */
+function getIframeTag($string = '', $onlySrc = false)
+{
+    preg_match('/<iframe.*src=\"(.*)\".*><\/iframe>/isU', $string, $matches);
+    if ($onlySrc) {
+        return (isset($matches[1])) ? $matches[1] : ""; // the src part. (http://www.youtube.com/embed/IIYeKGNNNf4?rel=0)
+    } else {
+        return (isset($matches[0])) ? $matches[0] : ""; // only the <iframe ...></iframe> part
+    }
+}
+
+/*
+ * 이미지 인지 여부
+ */
+function isImage($path = ''): int
+{
+    return intval(in_array(getimagesize($path)[2], [IMAGETYPE_GIF, IMAGETYPE_JPEG, IMAGETYPE_PNG, IMAGETYPE_BMP]));
+}
+
+/*
+ * 문자 검색 후 강조
+ */
+function highlight($search, $str): string
+{
+    return (!is_null($search)) ? preg_replace( '#' . preg_quote($search,'#') . '#iu', '<span style="background: #ff9632;color: #000;">$0</span>', $str) : $str;
+}
+
+/*
+ * 모든 공백 제거
+ */
+function aTrim($str): string
+{
+    return preg_replace('/\s+/', '', $str);
+}
+
+/*
+ * 목록 시작 번호
+ * currentItems: same as limit
+ * currentPage: floor(start / limit)
+ * totalPages: ceil(totalItems / limit)
+ * last: totalPages * limit
+ * previous: (currentPage-1) * limit
+ * next: (currentPage+1) * limit
+ */
+function listNum(int $total, int $page, int $perPage): int
+{
+    return (($listNum = ($total - ($page - 1) * $perPage)) > 0 ? $listNum : $total);
+}
+
+/*
+ * 단말기 구분으로 레이아웃 처리
+ */
+function layout(string $viewPath): string
+{
+    return (DEVICE_TYPE != DEVICE_TYPE_1 ? ('mobile.' . $viewPath) : 'desktop.' . $viewPath);
+}

+ 169 - 0
app/Helpers/string.php

@@ -0,0 +1,169 @@
+<?php
+/**
+ * User: 김국현
+ * Date: 2021-04-04
+ * Time: 오후 11:01
+ */
+
+// 문자열이 한글, 영문, 숫자, 특수문자로 구성되어 있는지 검사
+function findRegexString(string $str, int $options): bool
+{
+    if (empty($str)) {
+        return false;
+    }
+
+    $s = '';
+    for ($i = 0; $i < strlen($str); $i++) {
+        $c = $str[$i];
+        $oc = ord($c);
+
+        // 한글
+        if ($oc >= 0xA0 && $oc <= 0xFF) {
+            if (strtoupper(env('APP_CHARSET', 'UTF-8')) === 'UTF-8') {
+                if ($options & _HANGUL_) {
+                    $s .= $c . $str[$i + 1] . $str[$i + 2];
+                }
+                $i += 2;
+            } else {
+                // 한글은 2바이트 이므로 문자하나를 건너뜀
+                $i++;
+                if ($options & _HANGUL_) {
+                    $s .= $c . $str[$i];
+                }
+            }
+        } elseif ($oc >= 0x30 && $oc <= 0x39) { // 숫자
+            if ($options & _NUMERIC_) {
+                $s .= $c;
+            }
+        } elseif ($oc >= 0x41 && $oc <= 0x5A) { // 영대문자
+            if (($options & _ALPHABETIC_) OR ($options & _ALPHAUPPER_)) {
+                $s .= $c;
+            }
+        } elseif ($oc >= 0x61 && $oc <= 0x7A) { // 영소문자
+            if (($options & _ALPHABETIC_) OR ($options & _ALPHALOWER_)) {
+                $s .= $c;
+            }
+        } elseif ($oc >= 0x20) { // 공백
+            if ($options & _SPACE_) {
+                $s .= $c;
+            }
+        } elseif ($oc >= 0x5F) { // `_` 언더바
+
+            if ($options & _UNDER_) {
+                $s .= $c;
+            }
+        } else {
+            if ($options & _SPECIAL_) {
+                $s .= $c;
+            }
+        }
+    }
+    // 넘어온 값과 비교하여 같으면 참, 틀리면 거짓
+    return ($str === $s);
+}
+
+// 문자열에 정규식을 적용하여 배열로 만든다.
+function countOccurrences(string $str, string $exp): int
+{
+    preg_match_all($exp, $str, $match);
+    return count($match[0]);
+}
+
+// 문자열에 소문자 개수가 몇개인지
+function countLowercase(string $str): int
+{
+    return countOccurrences($str, '/[a-z]/');
+}
+
+// 문자열에 대문자 개수가 몇개인지
+function countUppercase(string $str): int
+{
+    return countOccurrences($str, '/[A-Z]/');
+}
+
+// 문자열에 숫자 개수가 몇개인지
+function countNumbers(string $str): int
+{
+    return countOccurrences($str, '/[0-9]/');
+}
+
+// 문자열에 특수문자 개수가 몇개인지
+function countSpecialChars(string $str): int
+{
+    return countOccurrences($str, '/[!@#$%^&*()]/');
+}
+
+// 원하는 문자열을 원하는 길에 맞는지 확인해서 조정하는 기능을 합니다.
+function cutChar(string $word, int $cut): string
+{
+    $word = substr($word, 0, $cut); // 필요한 길이만큼 취함.
+    for ($k = $cut - 1; $k > 1; $k--) {
+        if (ord(substr($word, $k, 1)) < 128) {
+            break; // 한글값은 160 이상.
+        }
+    }
+    return substr($word, 0, $cut - ($cut - $k + 1) % 2);
+}
+
+// UTF-8 자르기
+function cutCharUtf8(string $word, int $cut, string $mask = '...'): string
+{
+    if($cut <= 0) {
+        return $word;
+    }
+
+    preg_match_all('/[\xE0-\xFF][\x80-\xFF]{2}|./', $word, $match); // target for BMP
+
+    $m = $match[0];
+    $len = strlen($word); // length of source string
+    if ($len <= $cut) {
+        return $word;
+    }
+
+    $ret = [];
+    $count = 0;
+    for ($i = 0; $i < $cut; $i++) {
+        $count += (strlen($m[$i]) > 1) ? 2 : 1;
+        if ($count > $cut) {
+            break;
+        }
+        $ret[] = $m[$i];
+    }
+
+    return (join('', $ret) . $mask);
+}
+
+// base encode 확인
+function isBase64($s = ""): bool
+{
+    return (bool)preg_match('/^[a-zA-Z0-9\/\r\n+]*={0,2}$/', $s);
+}
+
+// 절대 경로 삭제 (../../../)
+function canonicalizePath($path): string
+{
+    $path = array_filter(explode(DIRECTORY_SEPARATOR, $path));
+    $stack = [];
+    foreach ($path as $seg) {
+        if ($seg == '..') {
+            array_pop($stack);
+            continue;
+        }
+
+        if ($seg == '.') {
+            continue;
+        }
+
+        $stack[] = $seg;
+    }
+
+    return (DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $stack));
+}
+
+// alert 에서 php 개행이 되도록 예외처리 한다.
+function nl2nr(string $s): string
+{
+    $s = preg_replace('/(\r\n|\r|\n)/', '\n', addslashes($s)); // 줄바꿈 되도록 출력
+    $s = strip_tags(trim($s), '<br/>'); // 태그 제거, 공백 제거
+    return preg_replace('/<br[^>]*>/i', '\n\n', $s); // br 태그를 \n로 개행 alert 에서 다음줄로 하기 위함.
+}

+ 50 - 0
app/Http/Controllers/Account/CertifyController.php

@@ -0,0 +1,50 @@
+<?php
+
+namespace App\Http\Controllers\Account;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Http\Traits\CommonTrait;
+
+class CertifyController extends Controller
+{
+    use CommonTrait;
+
+    public function __construct()
+    {
+        $this->middleware(['front', 'auth']);
+    }
+
+    /**
+     * 비밀번호 확인
+     * @method GET
+     * @see /account/certify
+     */
+    public function index(Request $request)
+    {
+        $request->session()->reflash();
+        $callbackURL = $request->session()->get('url.intended', session('url'));
+
+        return view(layout('account.certify'), [
+            'callbackURL' => $callbackURL,
+            'menuID' => 'CERTIFY'
+        ]);
+    }
+
+    /**
+     * 비밀번호 인증
+     * @method POST
+     * @see /account/certify
+     */
+    public function update(Request $request)
+    {
+        $certified = $this->passwordAuthed($request->post('password'));
+        if($certified) {
+            $request->session()->flash('is-certified', 1);
+            $callbackUrl = urldecode($request->post('callback_url'));
+            return redirect($callbackUrl);
+        }else{
+            return back()->withErrors(['password' => '비밀번호가 일치하지 않습니다.'])->withInput();
+        }
+    }
+}

+ 85 - 0
app/Http/Controllers/Account/CommentController.php

@@ -0,0 +1,85 @@
+<?php
+
+namespace App\Http\Controllers\Account;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Http\Traits\CommonTrait;
+use App\Services\UserService;
+use App\Services\CommentService;
+use App\Models\DTO\SearchData;
+use App\Models\DTO\ResponseData;
+use Exception;
+
+class CommentController extends Controller
+{
+    use CommonTrait;
+
+    private UserService $userService;
+    private CommentService $commentService;
+
+    public function __construct(
+        UserService $userService, CommentService $commentService
+    )
+    {
+        $this->middleware(['front', 'auth']);
+
+        $this->userService = $userService;
+        $this->commentService = $commentService;
+    }
+
+    /**
+     * 작성 댓글
+     * @method GET|POST
+	 * @see /account/comment
+     */
+    public function index(Request $request)
+    {
+        $params = SearchData::fromRequest($request);
+        $params->path = route('account.comment');
+
+        // 내가 작성한 게시글
+        $comments = $this->commentService->userList(UID, $params);
+
+        return view(layout('account.comment'), [
+            'comments' => $comments,
+            'params' => $params,
+            'menuID' => 'COMMENT'
+        ]);
+    }
+
+    /**
+     * 댓글 삭제
+     * @method DELETE
+     * @see /account/comment/delete
+     */
+    public function delete(Request $request, ResponseData $response): ResponseData
+    {
+        try {
+
+            $posts = $request->validate([
+                'keys.*' => 'required|numeric|exists:tb_comment,id',
+            ]);
+
+            if($posts['keys']) {
+
+                $user = $request->user();
+                foreach($posts['keys'] as $key) {
+                    $comment = $this->commentService->commentModel->findOrNew($key);
+
+                    if(!$comment->exists) {
+                        continue;
+                    }
+
+                    if(!$comment->remove($comment, $user)) {
+                        throw new Exception($key . '번 댓글은 삭제할 수 없습니다.');
+                    }
+                }
+            }
+        }catch(Exception $e) {
+            $response = $response::fromException($e);
+        }
+
+        return $response;
+    }
+}

+ 137 - 0
app/Http/Controllers/Account/EmailController.php

@@ -0,0 +1,137 @@
+<?php
+
+namespace App\Http\Controllers\Account;
+
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Mail;
+use App\Http\Controllers\Controller;
+use App\Models\User;
+use App\Models\UserEmailLog;
+use App\Models\DTO\ResponseData;
+use App\Mail\VerifyCode;
+use Exception;
+
+class EmailController extends Controller
+{
+    private User $userModel;
+    private UserEmailLog $userEmailLogModel;
+
+    public function __construct(User $user, UserEmailLog $userEmailLogModel)
+    {
+        $this->middleware('auth');
+        $this->middleware('throttle:verify')->only('index');
+
+        $this->userModel = $user;
+        $this->userEmailLogModel = $userEmailLogModel;
+    }
+
+    /**
+     * 회원 이메일 수정
+     * @method GET
+     * @see /account/email
+     */
+    public function index(Request $request, ResponseData $response): ResponseData
+    {
+        try{
+
+            $rules = [
+                'email' => 'required|email|unique:users,email'
+            ];
+
+            $attributes = [
+                'email' => '이메일'
+            ];
+
+            $messages = [
+                'email.required' => '이메일을 입력해 주세요.',
+                'email.email' => '이메일 형식이 옳지 않습니다.',
+                'email.unique' => '이미 사용 중인 이메일 입니다.'
+            ];
+
+            $posts = $this->validate($request, $rules, $messages, $attributes);
+
+            if(!$this->userEmailLogModel->isUpdateAble(UID)) {
+                throw new Exception(
+                    sprintf('이메일 변경은 %d일 지난 후 가능합니다.', $this->userEmailLogModel->getDayLeft(UID))
+                );
+            }
+
+            // 이메일 인증번호/남은시간 생성
+            $verifyCode = rand(100000, 999999);
+            $verifyExpiresAt = now()->addMinutes(VERIFY_EXPIRES_AT)->diffInSeconds(now());
+
+            // 이메일 인증번호 발송
+            [$name] = explode('@', $posts['email']);
+
+            $sender = (object)[
+                'name' => $name,
+                'email' => $posts['email'],
+            ];
+            Mail::to($sender)->send(new VerifyCode($verifyCode, $verifyExpiresAt, UID));
+
+            // 쿠키 생성
+            $sender->verifyCode = $verifyCode;
+            $sender = serialize($sender);
+            $cookie = cookie('tmpVerifyData', $sender, VERIFY_EXPIRES_AT);
+            $response->cookie($cookie);
+
+            // 이메일 인증 화면
+            $response->view = view(layout('account.email'), [
+                'user' => $request->user(),
+                'menuID' => 'MODIFY',
+                'verifyExpiresAt' => $verifyExpiresAt,
+            ])->render();
+
+        }catch(Exception $e) {
+            $response = $response::fromException($e);
+        }
+
+        return $response;
+    }
+
+    /**
+     * 회원 정보 수정 처리
+     * @method POST
+     * @see /account/email/update
+     */
+    public function update(Request $request, ResponseData $response): ResponseData
+    {
+        try{
+
+            $rules = [
+                'code' => 'required|integer'
+            ];
+
+            $attributes = [
+                'code' => '인증번호'
+            ];
+
+            $messages = [
+                'code.required' => '인증번호를 입력해 주세요.',
+                'code.integer' => '인증번호 형식이 옳지 않습니다.'
+            ];
+
+            $posts = $this->validate($request, $rules, $messages, $attributes);
+            $user = $request->user();
+            $tmpVerifyData = unserialize($request->cookie('tmpVerifyData'));
+
+            if($posts['code'] != $tmpVerifyData->verifyCode) {
+                throw new Exception('인증번호가 일치하지 않습니다.');
+            }
+
+            $this->userModel->info()->generateEmailVerified(
+                $tmpVerifyData->email
+            );
+
+            // 이메일 변경 확인
+            if($user->email != $tmpVerifyData->email) {
+                $this->userEmailLogModel->setLog($user, $tmpVerifyData->email);
+            }
+
+        }catch(Exception $e) {
+            $response = $response::fromException($e);
+        }
+
+        return $response;
+    }
+}

+ 68 - 0
app/Http/Controllers/Account/LeaveController.php

@@ -0,0 +1,68 @@
+<?php
+
+namespace App\Http\Controllers\Account;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Http\Traits\CommonTrait;
+use App\Models\User;
+
+class LeaveController extends Controller
+{
+    use CommonTrait;
+
+    private User $userModel;
+
+    public function __construct(User $user)
+    {
+        $this->middleware(['front', 'auth']);
+        $this->userModel = $user;
+    }
+
+    /**
+     * 회원탈퇴
+     * @method GET
+     * @see /account/leave
+     */
+    public function index()
+    {
+        return view(layout('account.leave'), [
+            'user' => $this->userModel->info(),
+            'menuID' => 'LEAVE'
+        ]);
+    }
+
+    /**
+     * 회원 탈퇴 처리
+     * @method POST
+     * @see /account/leave/update
+     */
+    public function update(Request $request)
+    {
+        $rules = [
+            'password' => 'required|confirmed',
+            'withdrawal' => 'required|in:0,1'
+        ];
+
+        $attributes = [
+            'password' => '비밀번호',
+            'password_confirmation' => '비밀번호 확인',
+            'withdrawal' => '안내 동의'
+        ];
+
+        $posts = $this->validate($request, $rules, [], $attributes);
+
+        // 비밀번호 확인
+        $certified = $this->passwordAuthed($posts['password']);
+        if(!$certified) {
+            return back()->withErrors('비밀번호가 일치하지 않습니다.');
+        }
+
+        $user = $request->user();
+        $user->is_withdraw = 1;
+        $user->deleted_at = now();
+        $user->save();
+
+        return redirect()->route('logout')->with('message', '회원탈퇴가 완료되었습니다.');
+    }
+}

+ 37 - 0
app/Http/Controllers/Account/LoginLogController.php

@@ -0,0 +1,37 @@
+<?php
+
+namespace App\Http\Controllers\Account;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Services\UserService;
+use App\Models\DTO\SearchData;
+
+class LoginLogController extends Controller
+{
+    private UserService $userService;
+
+    public function __construct(UserService $userService)
+    {
+        $this->middleware(['front', 'auth']);
+        $this->userService = $userService;
+    }
+
+    /**
+     * 로그인 기록
+     * @method GET|POST
+     * @see /account/logingLog
+     */
+    public function index(Request $request)
+    {
+        $params = SearchData::fromRequest($request);
+        $params->startDate = ($request->post('start_date') ?? date('Y-m-d', strtotime('-7 day')));
+        $params->endDate = ($request->post('end_date') ?? now()->format('Y-m-d'));
+
+        return view(layout('account.loginLog'), [
+            'loginLog' => $this->userService->loginLog(UID, $params),
+            'params' => $params,
+            'menuID' => 'LOGINLOG'
+        ]);
+    }
+}

+ 144 - 0
app/Http/Controllers/Account/ModifyController.php

@@ -0,0 +1,144 @@
+<?php
+
+namespace App\Http\Controllers\Account;
+
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Validation\Rule;
+use App\Http\Controllers\Controller;
+use App\Http\Traits\CommonTrait;
+use App\Models\User;
+use App\Models\UserNameLog;
+use App\Models\UserEmailLog;
+use App\Models\FileLib;
+use App\Rules\AllowNickname;
+
+class ModifyController extends Controller
+{
+    use CommonTrait;
+
+    private User $userModel;
+    private UserNameLog $userNameLogModel;
+    private UserEmailLog $userEmailLogModel;
+
+    public function __construct(
+        User $user,
+        UserNameLog $UserNameLogModel,
+        UserEmailLog $userEmailLogModel
+    ) {
+        $this->middleware(['front', 'auth']);
+        $this->userModel = $user;
+        $this->userNameLogModel = $UserNameLogModel;
+        $this->userEmailLogModel = $userEmailLogModel;
+    }
+
+    /**
+     * 회원 정보 수정
+     * @method GET
+     * @see /account/modify
+     */
+    public function index(Request $request)
+    {
+        $this->isCertified($request);
+        $request->session()->reflash();
+
+        $changeNicknameDayLeft = $this->userNameLogModel->getDayLeft(UID);
+        $changeEmailDayLeft = $this->userEmailLogModel->getDayLeft(UID);
+        $userThumbWidth = config('user_thumb_width', THUMB_MAX_WIDTH);
+        $userThumbHeight = config('user_thumb_height', THUMB_MAX_HEIGHT);
+
+        return view(layout('account.modify'), [
+            'user' => $request->user(),
+            'changeNicknameDayLeft' => $changeNicknameDayLeft,
+            'changeEmailDayLeft' => $changeEmailDayLeft,
+            'userThumbWidth' => $userThumbWidth,
+            'userThumbHeight' => $userThumbHeight,
+            'menuID' => 'MODIFY'
+        ]);
+    }
+
+    /**
+     * 회원 정보 수정 처리
+     * @method POST
+     * @see /account/modify
+     */
+    public function update(Request $request, FileLib $fileLib)
+    {
+        $request->session()->reflash();
+
+        $rules = [
+            'nickname' => ['required', 'string', 'min:2', 'max:20',
+                new AllowNickname, Rule::unique('users', 'nickname')->ignore(UID, 'id')],
+            'thumb_img' => 'nullable|mimes:jpg,jpeg,gif,png|max:3192',
+            'today_message' => ['nullable', 'string', 'max:40'],
+            'about_me' => ['nullable', 'string', 'max:500'],
+            'is_open_profile' => 'nullable|numeric|in:0,1',
+            'receive_email' => 'nullable|numeric|in:0,1'
+        ];
+
+        $attributes = [
+            'nickname' => '닉네임',
+            'thumb_img' => '프로필 이미지',
+            'today_message' => '오늘의 한마디',
+            'about_me' => '자기소개',
+            'is_open_profile' => '정보 공개 여부',
+            'receive_email' => '수신여부 - 이메일(Email)'
+        ];
+
+        $messages = [
+            'profile.max' => '첨부 가능한 이미지의 최대 용량은 3MB 입니다.'
+        ];
+
+        $posts = $this->validate($request, $rules, $messages, $attributes);
+        $user = $request->user();
+
+        if(!$this->userNameLogModel->isUpdateAble($user->id)) {
+            $posts['nickname'] = $user->name;
+        }
+        if(!$request->has('is_open_profile')) {
+            $posts['is_open_profile'] = 0;
+        }
+        if(!$request->has('receive_email')) {
+            $posts['receive_email'] = 0;
+        }
+
+        $saveData = [
+            'nickname' => $posts['nickname'],
+            'today_message' => $posts['today_message'],
+            'about_me' => $posts['about_me'],
+            'is_open_profile' => $posts['is_open_profile'],
+            'receive_email' => $posts['receive_email']
+        ];
+
+        // 파일 삭제
+        if($request->get('thumb_img_del'))
+        {
+            $thumbPath = public_path($request->user()->thumb);
+            if(file_exists($thumbPath)) {
+                if(unlink($thumbPath)) {
+                    $saveData['thumb'] = null;
+                }
+            }
+
+        }else if($request->hasFile('thumb_img')) {
+
+            // 파일 저장
+            $thumb = $request->file('thumb_img');
+            $savePath = $thumb->store(UPLOAD_PATH_PUBLIC . DIRECTORY_SEPARATOR . UPLOAD_PATH_USER_THUMB . DIRECTORY_SEPARATOR . $user->id);
+            $storagePath = Storage::path($savePath);
+            $saveData['thumb'] = Storage::url($savePath);
+
+            // 이미지 크기 조정
+            $fileLib->resize($storagePath, THUMB_MAX_WIDTH, THUMB_MAX_HEIGHT);
+        }
+
+        // 이름 변경 확인
+        if($user->nickname != $posts['nickname']) {
+            $this->userNameLogModel->setLog($user, $posts['nickname']);
+        }
+
+        $this->userModel->updater(UID, $saveData);
+
+        return redirect()->route('account.profile')->withErrors('회원 정보가 수정되었습니다.');
+    }
+}

+ 82 - 0
app/Http/Controllers/Account/PasswordCampaignController.php

@@ -0,0 +1,82 @@
+<?php
+
+namespace App\Http\Controllers\Account;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Http\Traits\CommonTrait;
+use App\Rules\NumberLength;
+use App\Rules\SpecialCharLength;
+use App\Rules\UppercaseLength;
+use App\Models\User;
+
+class PasswordCampaignController extends Controller
+{
+    use CommonTrait;
+
+    private User $userModel;
+
+    public function __construct(User $user)
+    {
+        $this->middleware(['front', 'auth']);
+        $this->userModel = $user;
+    }
+
+    /**
+     * 정기 비밀번호 변경 안내
+     * @method GET
+     * @see /account/password/campaign
+     */
+    public function index()
+    {
+        return view(layout('account.passwordCampaign'), [
+            'changePasswordDay' => config('change_password_day', 0),
+            'menuID' => 'PASSWORD_CAMPAIGN'
+        ]);
+    }
+
+    /**
+     * 정기 비밀번호 변경 안내
+     * @method POST
+     * @see /account/password/campaign
+     */
+    public function update(Request $request)
+    {
+        $rules = [
+            'password' => 'required|string',
+            'new_password' => [
+                'required',
+                'string',
+                'confirmed',
+                new NumberLength,
+                new SpecialCharLength,
+                new UppercaseLength
+            ],
+        ];
+
+        $attributes = [
+            'password' => '현재 비밀번호',
+            'new_password' => '새 비밀번호',
+            'new_password_confirmation' => '새 비밀번호 확인'
+        ];
+
+        $posts = $this->validate($request, $rules, [], $attributes);
+
+        $certified = $this->passwordAuthed($posts['password']);
+        if(!$certified) {
+            return back()->withErrors('현재 비밀번호가 일치하지 않습니다.');
+        }
+
+        // 동일 비밀번호 여부 확인
+        if ($posts['new_password'] == $posts['password']) {
+            return back()->withErrors('이전 비밀번호는 사용할 수 없습니다.');
+        }
+
+        $this->userModel->updater(UID, [
+            'password' => $this->passwordMake($posts['new_password']),
+            'password_updated_at' => now()
+        ]);
+
+        return redirect()->route('account.profile')->withErrors('비밀번호가 변경되었습니다.');
+    }
+}

+ 78 - 0
app/Http/Controllers/Account/PasswordChangeController.php

@@ -0,0 +1,78 @@
+<?php
+
+namespace App\Http\Controllers\Account;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Http\Traits\CommonTrait;
+use App\Rules\NumberLength;
+use App\Rules\SpecialCharLength;
+use App\Rules\UppercaseLength;
+use App\Models\User;
+
+class PasswordChangeController extends Controller
+{
+    use CommonTrait;
+
+    private User $userModel;
+
+    public function __construct(User $user)
+    {
+        $this->middleware(['front', 'auth']);
+        $this->userModel = $user;
+    }
+
+    /**
+     * 비밀번호 변경
+     * @method GET
+     * @see /account/password
+     */
+    public function index(Request $request)
+    {
+        $this->isCertified($request);
+        $request->session()->reflash();
+
+        return view(layout('account.passwordChange'), [
+            'menuID' => 'PASSWORD'
+        ]);
+    }
+
+    /**
+     * 비밀번호 변경 처리
+     * @method POST
+     * @see /account/password/change
+     */
+    public function update(Request $request)
+    {
+        $request->session()->reflash();
+
+        $rules = [
+            'password' => [
+                'required',
+                'confirmed',
+                new NumberLength,
+                new SpecialCharLength,
+                new UppercaseLength
+            ],
+        ];
+
+        $attributes = [
+            'new_password' => '새 비밀번호',
+            'new_password_confirmation' => '새 비밀번호 확인'
+        ];
+
+        $posts = $this->validate($request, $rules, [], $attributes);
+
+        // 동일 비밀번호 여부 확인
+        if ($this->passwordAuthed($posts['password'])) {
+            return back()->withErrors('이전 비밀번호는 사용할 수 없습니다.');
+        }
+
+        $this->userModel->updater(UID, [
+            'password' => bcrypt($posts['password']),
+            'password_updated_at' => now()
+        ]);
+
+        return redirect()->route('account.profile')->withErrors('비밀번호가 변경되었습니다.');
+    }
+}

+ 87 - 0
app/Http/Controllers/Account/PostController.php

@@ -0,0 +1,87 @@
+<?php
+
+namespace App\Http\Controllers\Account;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Http\Traits\CommonTrait;
+use App\Services\UserService;
+use App\Services\BoardService;
+use App\Services\PostService;
+use App\Models\DTO\SearchData;
+use App\Models\DTO\ResponseData;
+use Exception;
+
+class PostController extends Controller
+{
+    use CommonTrait;
+
+    private UserService $userService;
+    private BoardService $boardService;
+    private PostService $postService;
+
+    public function __construct(
+        UserService $userService, BoardService $boardService, PostService $postService
+    ) {
+        $this->middleware(['front', 'auth']);
+
+        $this->userService = $userService;
+        $this->boardService = $boardService;
+        $this->postService = $postService;
+    }
+
+    /**
+     * 작성 게시글
+     * @method GET|POST
+	 * @see /account/post
+     */
+    public function index(Request $request)
+    {
+        $params = SearchData::fromRequest($request);
+        $params->path = route('account.post');
+
+        // 내가 작성한 게시글
+        $posts = $this->boardService->userPosts(UID, $params);
+
+        return view(layout('account.post'), [
+            'posts' => $posts,
+            'params' => $params,
+            'menuID' => 'POST'
+        ]);
+    }
+
+    /**
+     * 게시글 삭제
+     * @method DELETE
+     * @see /account/post/delete
+     */
+    public function delete(Request $request, ResponseData $response): ResponseData
+    {
+        try {
+
+            $posts = $request->validate([
+                'keys.*' => 'required|numeric|exists:tb_post,id',
+            ]);
+
+            if($posts['keys']) {
+
+                $user = $request->user();
+                foreach($posts['keys'] as $key) {
+                    $post = $this->postService->postModel->findOrNew($key);
+
+                    if(!$post->exists) {
+                        continue;
+                    }
+
+                    if(!$post->remove($post, $user)) {
+                        throw new Exception($key . '번 게시글은 삭제할 수 없습니다.');
+                    }
+                }
+            }
+        }catch(Exception $e) {
+            $response = $response::fromException($e);
+        }
+
+        return $response;
+    }
+}

+ 32 - 0
app/Http/Controllers/Account/ProfileController.php

@@ -0,0 +1,32 @@
+<?php
+
+namespace App\Http\Controllers\Account;
+
+use App\Http\Controllers\Controller;
+use App\Models\User;
+
+class ProfileController extends Controller
+{
+    private User $userModel;
+
+    public function __construct(User $userModel)
+    {
+        $this->middleware(['front', 'auth']);
+        $this->userModel = $userModel;
+    }
+
+    /**
+     * 내 정보
+     * @method GET
+     * @see /account/profile
+     */
+    public function index()
+    {
+        $member = $this->userModel->info();
+
+        return view(layout('account.profile'), [
+            'member' => $member,
+            'menuID' => 'PROFILE'
+        ]);
+    }
+}

+ 68 - 0
app/Http/Controllers/Admin/AjaxController.php

@@ -0,0 +1,68 @@
+<?php
+
+namespace App\Http\Controllers\Admin;
+
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Validator;
+use App\Http\Controllers\Controller;
+use App\Models\PostBlame;
+use App\Models\CommentBlame;
+
+class AjaxController extends Controller
+{
+    /**
+     * 게시판 신고처리 결과 등록
+     */
+    public function postBlameResultUpdate(Request $request)
+    {
+        $rules = [
+            'post_blame_id' => 'required|exists:tb_post_blame,id',
+            'status' => 'required|numeric|in:0,1',
+            'memo' => 'required',
+        ];
+
+        $attributes = [
+            'post_blame_id' => '신고 PK',
+            'status' => '처리 상태',
+            'memo' => '처리 내용',
+        ];
+
+        $validator = Validator::make($request->all(), $rules, [], $attributes);
+        if ($validator->fails()) {
+            return json_encode(['success' => 0, 'message' => $validator->errors()->first()]);
+        }
+        $posts = $validator->valid();
+
+        if((new PostBlame)->postBlameResultUpdate($posts['post_blame_id'], $posts['status'], $posts['memo'])) {
+            return json_encode(['success' => 1, 'message' => '신고 결과를 변경하였습니다.']);
+        }
+    }
+
+    /**
+     * 댓글 신고처리 결과 등록
+     */
+    public function commentBlameResultUpdate(Request $request)
+    {
+        $rules = [
+            'comment_blame_id' => 'required|exists:tb_comment_blame,id',
+            'status' => 'required|numeric|in:0,1',
+            'memo' => 'required',
+        ];
+
+        $attributes = [
+            'comment_blame_id' => '신고 PK',
+            'status' => '처리 상태',
+            'memo' => '처리 내용',
+        ];
+
+        $validator = Validator::make($request->all(), $rules, [], $attributes);
+        if ($validator->fails()) {
+            return json_encode(['success' => 0, 'message' => $validator->errors()->first()]);
+        }
+        $posts = $validator->valid();
+
+        if((new CommentBlame)->commentBlameResultUpdate($posts['comment_blame_id'], $posts['status'], $posts['memo'])) {
+            return json_encode(['success' => 1, 'message' => '신고 결과를 변경하였습니다.']);
+        }
+    }
+}

+ 110 - 0
app/Http/Controllers/Admin/Board/Blame/CommentController.php

@@ -0,0 +1,110 @@
+<?php
+
+namespace App\Http\Controllers\Admin\Board\Blame;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Http\Traits\AgentTrait;
+use App\Models\Board;
+use App\Models\Comment;
+use App\Models\CommentBlame;
+use App\Models\DTO\SearchData;
+use Exception;
+
+class CommentController extends Controller
+{
+    use AgentTrait;
+
+    private Board $boardModel;
+    private CommentBlame $commentBlameModel;
+
+    public function __construct(Board $board, CommentBlame $commentBlame)
+    {
+        $this->boardModel = $board;
+        $this->commentBlameModel = $commentBlame;
+    }
+
+    /**
+     * 댓글 > 신고 관리 조회
+     * @method GET
+     * @see /admin/board/blame/comment
+     */
+    public function index(Request $request)
+    {
+        $params = SearchData::fromRequest($request);
+        $params->boardID = $request->post('board_id');
+
+        $blameData = $this->commentBlameModel->data($params);
+
+        if ($blameData->rows > 0) {
+            $num = listNum($blameData->total, $params->page, $params->perPage);
+            foreach ($blameData->list as $i => $row) {
+                $row->num = $num--;
+                $row->postURL = route('board.post.view', [$row->code, $row->post_id]);
+                $row->editURL = route('admin.board.blame.comment.edit', $row->id);
+                $row->typeStr = MAP_BLAME_TYPE[$row->type];
+                $row->reason = ($row->reason ?? '-');
+
+                $blameData->list[$i] = $row;
+            }
+        }
+
+        return view('admin.board.blame.comment.index', [
+            'blameData' => $blameData,
+            'params' => $params,
+            'boardData' => $this->boardModel->all()
+        ]);
+    }
+
+    /**
+     * 댓글 > 신고 관리 삭제
+     * @method DELETE
+     * @see /admin/board/blame/comment/destroy
+     */
+    public function destroy(Request $request, Comment $commentModel)
+    {
+        try {
+
+            $chk = $request->post('chk');
+
+            if ($chk) {
+                foreach ($chk as $commentBlameID) {
+                    $commentBlame = $this->commentBlameModel->findOrNew($commentBlameID);
+
+                    if (!$commentBlame->exists) {
+                        throw new Exception($commentBlameID . '번 신고글은 존재하지 않습니다.');
+                    }
+
+                    if (!$commentBlame->delete()) {
+                        return back()->withErrors($commentBlameID . "번 신고글을 삭제할 수 없습니다.")->withInput();
+                    }
+
+                    // 신고 수 감소
+                    $commentModel->decreaseBlame($commentBlameID);
+                }
+            }
+
+            $message = '신고 기록이 삭제되었습니다.';
+            return redirect()->route('admin.board.comment.blame.index')->with('message', $message);
+        } catch (Exception $e) {
+            return back()->withErrors($e->getMessage())->withInput();
+        }
+    }
+
+    /**
+     * 댓글 > 신고 상세 보기
+     * @method GET
+     * @see /admin/board/blame/comment/{pk}/edit
+     */
+    public function edit(int $commentBlameID)
+    {
+        $blame = $this->commentBlameModel->findOrFail($commentBlameID);
+        $blame->device = $this->device($blame->user_agent);
+        $blame->platform = $this->platform($blame->user_agent);
+        $blame->browser = $this->browser($blame->user_agent);
+
+        return view('admin.board.blame.comment.edit', [
+            'blame' => $blame
+        ]);
+    }
+}

+ 111 - 0
app/Http/Controllers/Admin/Board/Blame/PostController.php

@@ -0,0 +1,111 @@
+<?php
+
+namespace App\Http\Controllers\Admin\Board\Blame;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Http\Traits\AgentTrait;
+use App\Models\Board;
+use App\Models\Post;
+use App\Models\PostBlame;
+use App\Models\DTO\SearchData;
+use Exception;
+
+class PostController extends Controller
+{
+    use AgentTrait;
+
+    private Board $boardModel;
+    private PostBlame $postBlameModel;
+
+    public function __construct(Board $board, PostBlame $postBlame)
+    {
+        $this->boardModel = $board;
+        $this->postBlameModel = $postBlame;
+    }
+
+    /**
+     * 게시판 > 신고 관리 조회
+     * @method GET
+     * @see /admin/board/blame/post
+     */
+    public function index(Request $request)
+    {
+        $params = SearchData::fromRequest($request);
+        $params->boardID = $request->post('board_id');
+
+        $blameData = $this->postBlameModel->data($params);
+
+        if ($blameData->rows > 0) {
+            $num = listNum($blameData->total, $params->page, $params->perPage);
+            foreach ($blameData->list as $i => $row) {
+                $row->num = $num--;
+                $row->postURL = route('board.post.view', [$row->code, $row->post_id]);
+                $row->editURL = route('admin.board.blame.post.edit', $row->id);
+                $row->typeStr = MAP_BLAME_TYPE[$row->type];
+                $row->reason = ($row->reason ?? '-');
+
+                $blameData->list[$i] = $row;
+            }
+        }
+
+        return view('admin.board.blame.post.index', [
+            'blameData' => $blameData,
+            'params' => $params,
+            'boardData' => $this->boardModel->all()
+        ]);
+    }
+
+    /**
+     * 게시판 > 신고 관리 삭제
+     * @method DELETE
+     * @see /admin/board/blame/destroy
+     */
+    public function destroy(Request $request, Post $postModel)
+    {
+        try {
+
+            $chk = $request->post('chk');
+
+            if ($chk) {
+                foreach ($chk as $postBlameID) {
+                    $postBlame = $this->postBlameModel->findOrNew($postBlameID);
+
+                    if(!$postBlame->exists) {
+                        throw new Exception($postBlameID . '번 신고글은 존재하지 않습니다.');
+                    }
+
+                    if(!$postBlame->delete()) {
+                        throw new Exception($postBlameID . "번 신고글을 삭제할 수 없습니다.");
+                    }
+
+                    // 신고 수 감소
+                    $postModel->decreaseBlame($postBlame->post_id);
+
+                }
+            }
+
+            $message = '신고 기록이 삭제되었습니다.';
+            return redirect()->route('admin.board.blame.post.index')->with('message', $message);
+        } catch (Exception $e) {
+            return back()->withErrors($e->getMessage())->withInput();
+        }
+    }
+
+    /**
+     * 게시판 > 신고 상세 보기
+     * @method GET
+     * @see /admin/board/blame/post/{pk}/edit
+     */
+    public function edit(int $postBlameID)
+    {
+        $blame = $this->postBlameModel->findOrFail($postBlameID);
+        $blame->device = $this->device($blame->user_agent);
+        $blame->platform = $this->platform($blame->user_agent);
+        $blame->browser = $this->browser($blame->user_agent);
+
+        return view('admin.board.blame.post.edit', [
+            'blame' => $blame
+        ]);
+    }
+}

+ 158 - 0
app/Http/Controllers/Admin/Board/Board/AuthorityController.php

@@ -0,0 +1,158 @@
+<?php
+
+namespace App\Http\Controllers\Admin\Board\Board;
+
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+use App\Http\Controllers\Controller;
+use App\Models\Board;
+use App\Models\BoardMeta;
+use Exception;
+
+class AuthorityController extends Controller
+{
+    private Board $boardModel;
+    private BoardMeta $boardMetaModel;
+
+    public function __construct(Board $board, BoardMeta $boardMeta)
+    {
+        $this->boardModel = $board;
+        $this->boardMetaModel = $boardMeta;
+    }
+
+    /**
+     * 게시판 수정 - 권한
+     * @method GET
+     * @see /admin/board/board/authority/{pk}
+     */
+    public function show(int $boardID)
+    {
+        return view('admin.board.board.authority', [
+            'boardID' => $boardID,
+            'actionURL' => route('admin.board.board.authority.store'),
+
+            // 게시판 정보
+            'boardData' => $this->boardModel->find($boardID),
+
+            // 게시판 메타 정보
+            'boardMetaData' => $this->boardMetaModel->getAllMeta($boardID),
+
+            // 모든 게시판
+            'boardAllData' => $this->boardModel->all(),
+
+            // 권한 종류
+            'authorityType' => [
+                '' => '모든 사용자',
+                '1' => '로그인 사용자',
+                '100' => '관리자',
+            ]
+        ]);
+    }
+
+    /**
+     * 게시판 수정 - 권한 저장
+     * @method POST
+     * @see /admin/board/board/authority/{pk}
+     */
+    public function store(Request $request)
+    {
+        $rules = [
+            'board_id' => 'required|numeric|exists:tb_board,id',
+            'access_post_list' => 'nullable|numeric',
+            'access_post_view' => 'nullable|numeric',
+            'access_post_write' => 'nullable|numeric',
+            'access_comment_list' => 'nullable|numeric',
+            'access_comment_write' => 'nullable|numeric',
+            'access_image_upload' => 'nullable|numeric',
+            'access_file_download' => 'nullable|numeric',
+            'grp' => 'array',
+            'all' => 'array'
+        ];
+
+        $attributes = [
+            'board_id' => '게시판 PK',
+            'access_post_list' => '목록',
+            'access_post_view' => '글 열람',
+            'access_post_write' => '글 작성',
+            'access_comment_list' => '댓글 목록',
+            'access_comment_write' => '댓글 작성',
+            'access_image_upload' => '이미지 첨부',
+            'access_file_download' => '파일 다운로드',
+            'grp' => '그룹 적용',
+            'all' => '전체 적용'
+        ];
+
+        $posts = $this->validate($request, $rules, [], $attributes);
+        $boardID = $posts['board_id'];
+
+        // 게시판 정보 조회
+        $boardData = $this->boardModel->find($boardID);
+
+        // 메타 정보 저장
+        $metaData = [];
+
+        $selectArr = [
+            'access_post_list',
+            'access_post_view',
+            'access_post_write',
+            'access_comment_list',
+            'access_comment_write',
+            'access_image_upload',
+            'access_file_download'
+        ];
+
+        foreach($selectArr as $name) {
+            $metaData[$name] = $posts[$name];
+        }
+
+        DB::beginTransaction();
+
+        try
+        {
+            $this->boardMetaModel->save($boardID, $metaData);
+
+            /*
+             * 그룹, 전체 적용
+             */
+            $groupData = []; // 그룹적용
+            $allData = []; // 전체적용
+
+            $groupKeys = $posts['grp'] ?? [];
+            $allKeys = $posts['all'] ?? [];
+
+            if ($groupKeys) {
+                $brdGroupData = $this->boardModel->findByGroup($boardData->board_group_id);
+                foreach ($brdGroupData as $bKey => $bVal) {
+                    if ($bVal->id === $boardID) {
+                        continue;
+                    }
+                    foreach ($groupKeys as $gk => $gv) {
+                        $groupData[$gk] = $metaData[$gk];
+                    }
+                    $this->boardMetaModel->save($bVal->id, $groupData);
+                }
+            }
+
+            if ($allKeys) {
+                $brdAllData = $this->boardModel->all();
+                foreach ($brdAllData as $bKey => $bVal) {
+                    if ($bVal->id === $boardID) {
+                        continue;
+                    }
+                    foreach ($allKeys as $ak => $av) {
+                        $allData[$ak] = $metaData[$ak];
+                    }
+                    $this->boardMetaModel->save($bVal->id, $allData);
+                }
+            }
+
+            $message = '게시판 - 권한 정보가 저장되었습니다.';
+            DB::commit();
+        } catch (Exception $e) {
+            $message = $e->getMessage();
+            DB::rollBack();
+        }
+
+        return redirect()->route('admin.board.board.authority.show', $boardID)->with('message', $message);
+    }
+}

+ 152 - 0
app/Http/Controllers/Admin/Board/Board/CategoryController.php

@@ -0,0 +1,152 @@
+<?php
+
+namespace App\Http\Controllers\Admin\Board\Board;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Models\Board;
+use App\Models\BoardMeta;
+use App\Models\BoardCategory;
+
+class CategoryController extends Controller
+{
+    private Board $boardModel;
+    private BoardMeta $boardMetaModel;
+    private BoardCategory $boardCategoryModel;
+
+    public function __construct(Board $board, BoardMeta $boardMeta, BoardCategory $boardCategory)
+    {
+        $this->boardModel = $board;
+        $this->boardMetaModel = $boardMeta;
+        $this->boardCategoryModel = $boardCategory;
+    }
+
+    /**
+     * 게시판 수정 - 분류
+     * @method GET
+     * @see /admin/board/board/category/{pk}
+     */
+    public function show(int $boardID)
+    {
+        return view('admin.board.board.category', [
+            'boardID' => $boardID,
+
+            // 게시판 분류
+            'boardCategoryData' => $this->boardCategoryModel->getCategoryList($boardID),
+
+            // 게시판 정보
+            'boardData' => $this->boardModel->find($boardID),
+
+            // 게시판 메타 정보
+            'boardMetaData' => $this->boardMetaModel->getAllMeta($boardID),
+
+            // 모든 게시판
+            'boardAllData' => $this->boardModel->all()
+        ]);
+    }
+
+    /**
+     * 게시판 그룹 분류 등록 저장
+     * @method POST
+     * @see /admin/board/board/category/{pk}
+     */
+    public function store(Request $request)
+    {
+        $rules = [
+            'board_id' => 'required|numeric|exists:tb_board,id',
+            'name' => 'required|string',
+            'depth' => 'required|numeric|min:0',
+        ];
+
+        $attributes = [
+            'board_id' => '게시판 수정 - 분류',
+            'category_id' => '분류 PK',
+            'parent_id' => '분류 부모 PK',
+            'name' => '분류 명',
+            'depth' => '분류 깊이',
+        ];
+
+        $type = $request->post('type');
+        if($type == 'modify') { // 수정
+            $rules['board_category_id'] = 'required|numeric|exists:tb_board_category,id';
+            $rules['parent_id'] = 'numeric|nullable|exists:tb_board_category,parent_id';
+        }else{ // 입력
+            $rules['parent_id'] = 'required|numeric|min:0';
+        }
+
+        $posts = $this->validate($request, $rules, [], $attributes);
+        $boardID = $posts['board_id'];
+        $parentID = ($posts['parent_id'] ?? null);
+        $today = now();
+
+        $saveData = [
+            'board_id' => $boardID,
+            'parent_id' => $parentID,
+            'name' => $posts['name'],
+            'depth' => $posts['depth']
+        ];
+
+        if($type == 'add') {
+            $code = $this->boardCategoryModel->nextKey($parentID, $boardID);
+            $saveData['code'] = $code;
+            $saveData['created_at'] = $today;
+            $this->boardCategoryModel->register($saveData);
+            $message = '게시판 분류가 새로 등록되었습니다.';
+        }else{
+            $saveData['id'] = $posts['board_category_id'];
+            $saveData['updated_at'] = $today;
+            $this->boardCategoryModel->updater($saveData);
+            $message = '게시판 분류가 수정 되었습니다.';
+        }
+
+        return redirect()->route('admin.board.board.category.show', $boardID)->with('message', $message);
+    }
+
+    /**
+     * 게시판 그룹 분류 삭제
+     * @method DELETE
+     * @see /admin/board/board/category/{pk}/destroy
+     */
+    public function destroy(Request $request)
+    {
+        $boardID = $request->post('board_id');
+        $chk = $request->post('chk');
+
+        if ($chk) {
+            foreach ($chk as $boardCategoryID) {
+                $this->boardCategoryModel->remove($boardCategoryID);
+            }
+        }
+
+        $message = '게시판 분류 정보가 삭제되었습니다.';
+        return redirect()->route('admin.board.board.category.show', $boardID)->with('message', $message);
+    }
+
+    /**
+     * 분류 한 단계 위로
+     */
+    public function up(int $boardCategoryID)
+    {
+        $boardID = $this->boardCategoryModel->find($boardCategoryID)->getOriginal('board_id');
+        if($boardID) {
+            $this->boardCategoryModel->orderUp($boardCategoryID);
+            return redirect()->route('admin.board.board.category.show', $boardID);
+        }else{
+            return back();
+        }
+    }
+
+    /**
+     * 분류 한 단계 아래로
+     */
+    public function down(int $boardCategoryID)
+    {
+        $boardID = $this->boardCategoryModel->find($boardCategoryID)->getOriginal('board_id');
+        if($boardID) {
+            $this->boardCategoryModel->orderDown($boardCategoryID);
+            return redirect()->route('admin.board.board.category.show', $boardID);
+        }else{
+            return back();
+        }
+    }
+}

+ 185 - 0
app/Http/Controllers/Admin/Board/Board/CommentController.php

@@ -0,0 +1,185 @@
+<?php
+
+namespace App\Http\Controllers\Admin\Board\Board;
+
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+use App\Http\Controllers\Controller;
+use App\Models\Board;
+use App\Models\BoardMeta;
+use Exception;
+
+class CommentController extends Controller
+{
+    private Board $boardModel;
+    private BoardMeta $boardMetaModel;
+
+    public function __construct(Board $board, BoardMeta $boardMeta)
+    {
+        $this->boardModel = $board;
+        $this->boardMetaModel = $boardMeta;
+    }
+
+    /**
+     * 게시판 수정 - 댓글
+     * @method GET
+     * @see /admin/board/board/comment/{pk}
+     */
+    public function show(int $boardID)
+    {
+        return view('admin.board.board.comment', [
+            'boardID' => $boardID,
+            'actionURL' => route('admin.board.board.comment.store'),
+
+            // 게시판 정보
+            'boardData' => $this->boardModel->find($boardID),
+
+            // 게시판 메타 정보
+            'boardMetaData' => $this->boardMetaModel->getAllMeta($boardID),
+
+            // 모든 게시판
+            'boardAllData' => $this->boardModel->all()
+        ]);
+    }
+
+    /**
+     * 게시판 수정 - 작성 저장
+     * @method POST
+     * @see /admin/board/board/comment/{pk}
+     */
+    public function store(Request $request)
+    {
+        $rules = [
+            'board_id' => 'required|numeric|exists:tb_board,id',
+            'use_comment' => 'required|numeric',
+            'comment_per_page' => 'required|numeric|min:0|max:20',
+            'comment_page_count' => 'required|numeric|min:0|max:10',
+            'use_comment_like' => 'required|numeric',
+            'use_comment_dislike' => 'required|numeric',
+            'show_user_thumb_in_comment' => 'required|numeric|in:0,1',
+            'show_user_icon_in_comment' => 'required|numeric|in:0,1',
+            'update_order_on_comment' => 'required|numeric',
+            'comment_default_content' => 'string|nullable|max:1000',
+            'comment_min_length' => 'required|numeric|min:0',
+            'comment_max_length' => 'required|numeric|min:0|max:99999999',
+            'use_comment_secret' => 'nullable|numeric|in:1,2',
+            'use_comment_secret_selected' => 'required|numeric|in:0,1',
+            'show_comment_ip' => 'nullable|numeric',
+            'use_comment_blame' => 'required|numeric|in:0,1',
+            'comment_blame_blind_count' => 'required|numeric|min:0',
+            'protect_delete_comment' => 'required|numeric|in:0,1',
+            'protect_update_comment' => 'required|numeric|in:0,1',
+            'grp' => 'array',
+            'all' => 'array'
+        ];
+
+        $attributes = [
+            'board_id' => '게시판 PK',
+            'use_comment' => '댓글 사용 여부',
+            'comment_per_page' => '댓글 목록 수',
+            'comment_page_count' => '댓글 페이지 수',
+            'use_comment_like' => '댓글 추천 기능',
+            'use_comment_dislike' => '댓글 비추천 기능',
+            'show_user_thumb_in_comment' => '회원 프로필 이미지',
+            'show_user_icon_in_comment' => '회원 아이콘 이미지',
+            'update_order_on_comment' => '댓글 작성시 글 수정 시각 갱신',
+            'comment_default_content' => '댓글 기본 내용',
+            'comment_min_length' => '최소 글수 제한',
+            'comment_max_length' => '최대 글수 제한',
+            'use_comment_secret' => '비밀글 사용',
+            'use_comment_secret_selected' => '비밀글 기본 선택',
+            'show_comment_ip' => 'IP 보이기',
+            'use_comment_blame' => '댓글 신고 기능',
+            'comment_blame_blind_count' => '댓글 신고 시 숨김',
+            'protect_delete_comment' => '댓글 보호 기능 (삭제 시)',
+            'protect_update_comment' => '댓글 보호 기능 (수정 시)',
+            'grp' => '그룹 적용',
+            'all' => '전체 적용'
+        ];
+
+        $posts = $this->validate($request, $rules, [], $attributes);
+        $boardID = $posts['board_id'];
+
+        $defaultData = [ // 그룹, 전체 적용 값
+            'use_comment', 'comment_per_page', 'comment_page_count', 'use_comment_like', 'use_comment_dislike',
+            'show_user_thumb_in_comment', 'show_user_icon_in_comment', 'update_order_on_comment',
+            'comment_default_content', 'comment_min_length', 'comment_max_length',
+            'use_comment_secret', 'use_comment_secret_selected', 'show_comment_ip',
+            'use_comment_blame', 'comment_blame_blind_count', 'protect_delete_comment', 'protect_update_comment'
+        ];
+
+        // 게시판 정보 조회
+        $boardData = $this->boardModel->find($boardID);
+
+        // 메타 정보 저장
+        $metaData = [
+            'use_comment' => $posts['use_comment'],
+            'comment_per_page' => $posts['comment_per_page'],
+            'comment_page_count' => $posts['comment_page_count'],
+            'use_comment_like' => $posts['use_comment_like'],
+            'use_comment_dislike' => $posts['use_comment_dislike'],
+            'show_user_thumb_in_comment' => $posts['show_user_thumb_in_comment'],
+            'show_user_icon_in_comment' => $posts['show_user_icon_in_comment'],
+            'update_order_on_comment' => $posts['update_order_on_comment'],
+            'comment_default_content' => $posts['comment_default_content'],
+            'comment_min_length' => $posts['comment_min_length'],
+            'comment_max_length' => $posts['comment_max_length'],
+            'use_comment_secret' => $posts['use_comment_secret'],
+            'use_comment_secret_selected' => $posts['use_comment_secret_selected'],
+            'show_comment_ip' => $posts['show_comment_ip'],
+            'use_comment_blame' => $posts['use_comment_blame'],
+            'comment_blame_blind_count' => $posts['comment_blame_blind_count'],
+            'protect_delete_comment' => $posts['protect_delete_comment'],
+            'protect_update_comment' => $posts['protect_update_comment']
+        ];
+
+        DB::beginTransaction();
+
+        try
+        {
+            $this->boardMetaModel->save($boardID, $metaData);
+
+            /*
+             * 그룹, 전체 적용
+             */
+            $groupData = []; // 그룹적용
+            $allData = []; // 전체적용
+
+            foreach ($defaultData as $field) {
+                if (isset($posts['grp'][$field])) {
+                    $groupData[$field] = $posts[$field];
+                }
+                if (isset($posts['all'][$field])) {
+                    $allData[$field] = $posts[$field];
+                }
+            }
+
+            if ($groupData) {
+                $brdGroupData = $this->boardModel->findByGroup($boardData->board_group_id);
+                foreach ($brdGroupData as $bKey => $bVal) {
+                    if ($bVal->id === $boardID) {
+                        continue;
+                    }
+                    $this->boardMetaModel->save($bVal->id, $groupData);
+                }
+            }
+            if ($allData) {
+                $brdAllData = $this->boardModel->all();
+                foreach ($brdAllData as $bKey => $bVal) {
+                    if ($bVal->id === $boardID) {
+                        continue;
+                    }
+                    $this->boardMetaModel->save($bVal->id, $allData);
+                }
+            }
+
+            $message = '게시판 - 댓글 정보가 저장되었습니다.';
+            DB::commit();
+        } catch (Exception $e) {
+            $message = $e->getMessage();
+            DB::rollBack();
+        }
+
+        return redirect()->route('admin.board.board.comment.show', $boardID)->with('message', $message);
+    }
+}

+ 186 - 0
app/Http/Controllers/Admin/Board/Board/ExpController.php

@@ -0,0 +1,186 @@
+<?php
+
+namespace App\Http\Controllers\Admin\Board\Board;
+
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+use App\Http\Controllers\Controller;
+use App\Models\Board;
+use App\Models\BoardMeta;
+use Exception;
+
+class ExpController extends Controller
+{
+    private Board $boardModel;
+    private BoardMeta $boardMetaModel;
+
+    public function __construct(Board $board, BoardMeta $boardMeta)
+    {
+        $this->boardModel = $board;
+        $this->boardMetaModel = $boardMeta;
+    }
+
+    /**
+     * 게시판 수정 - 경험치
+     * @method GET
+     * @see /admin/board/board/exp/{pk}
+     */
+    public function show(int $boardID)
+    {
+        return view('admin.board.board.exp', [
+            'boardID' => $boardID,
+            'actionURL' => route('admin.board.board.exp.store'),
+
+            // 게시판 정보
+            'boardData' => $this->boardModel->find($boardID),
+
+            // 게시판 메타 정보
+            'boardMetaData' => $this->boardMetaModel->getAllMeta($boardID),
+
+            // 모든 게시판
+            'boardAllData' => $this->boardModel->all()
+        ]);
+    }
+
+    /**
+     * 게시판 수정 - 경험치 저장
+     * @method POST
+     * @see /admin/board/board/exp/{pk}
+     */
+    public function store(Request $request)
+    {
+        $rules = [
+            'board_id' => 'required|numeric|exists:tb_board,id',
+            'use_exp' => 'required|numeric|in:0,1',
+            'use_exp_info' => 'required|numeric|in:0,1',
+            'exp_write' => 'required|numeric',
+            'exp_comment' => 'required|numeric',
+            'exp_post_delete' => 'required|numeric',
+            'exp_admin_post_delete' => 'required|numeric',
+            'exp_comment_delete' => 'required|numeric',
+            'exp_admin_comment_delete' => 'required|numeric',
+            'exp_file_upload' => 'required|numeric',
+            'exp_file_download' => 'required|numeric',
+            'exp_file_download_uploader' => 'required|numeric',
+            'exp_read' => 'required|numeric',
+            'exp_post_like' => 'required|numeric',
+            'exp_post_dislike' => 'required|numeric',
+            'exp_post_liked' => 'required|numeric',
+            'exp_post_disliked' => 'required|numeric',
+            'exp_comment_like' => 'required|numeric',
+            'exp_comment_dislike' => 'required|numeric',
+            'exp_comment_liked' => 'required|numeric',
+            'exp_comment_disliked' => 'required|numeric'
+        ];
+
+        $attributes = [
+            'board_id' => '게시판 PK',
+            'use_exp' => '경험치 기능 사용',
+            'use_exp_info' => '경험치 안내',
+            'exp_write' => '원글 작성',
+            'exp_comment' => '댓글 작성',
+            'exp_post_delete' => '작성자 본인이 원글 삭제',
+            'exp_admin_post_delete' => '관리자가 원글 삭제',
+            'exp_comment_delete' => '작성자 본인이 댓글 삭제',
+            'exp_admin_comment_delete' => '관리자가 댓글 삭제',
+            'exp_file_upload' => '파일 업로드',
+            'exp_file_download' => '파일 다운로드',
+            'exp_file_download_uploader' => '파일 다운로드 시 업로더에게',
+            'exp_read' => '게시글 조회',
+            'exp_post_like' => '원글 추천함',
+            'exp_post_dislike' => '원글 비추천함',
+            'exp_post_liked' => '원글 추천 받음',
+            'exp_post_disliked' => '원글 비추천 받음',
+            'exp_comment_like' => '댓글 추천함',
+            'exp_comment_dislike' => '댓글 비추천함',
+            'exp_comment_liked' => '댓글 추천 받음',
+            'exp_comment_disliked' => '댓글 비추천 받음'
+        ];
+
+        $posts = $this->validate($request, $rules, [], $attributes);
+        $boardID = $posts['board_id'];
+
+        $defaultData = [ // 그룹, 전체 적용 값
+            'use_exp', 'use_exp_info', 'exp_write', 'exp_comment', 'exp_post_delete', 'exp_admin_post_delete',
+            'exp_comment_delete', 'exp_admin_comment_delete', 'exp_file_upload', 'exp_file_download',
+            'exp_file_download_uploader', 'exp_read', 'exp_post_like', 'exp_post_dislike', 'exp_post_liked',
+            'exp_post_disliked', 'exp_comment_like', 'exp_comment_dislike', 'exp_comment_liked', 'exp_comment_disliked'
+        ];
+
+        // 게시판 정보 조회
+        $boardData = $this->boardModel->find($boardID);
+
+        // 메타 정보 저장
+        $metaData = [
+            'use_exp' => $posts['use_exp'],
+            'use_exp_info' => $posts['use_exp_info'],
+            'exp_write' => $posts['exp_write'],
+            'exp_comment' => $posts['exp_comment'],
+            'exp_post_delete' => $posts['exp_post_delete'],
+            'exp_admin_post_delete' => $posts['exp_admin_post_delete'],
+            'exp_comment_delete' => $posts['exp_comment_delete'],
+            'exp_admin_comment_delete' => $posts['exp_admin_comment_delete'],
+            'exp_file_upload' => $posts['exp_file_upload'],
+            'exp_file_download' => $posts['exp_file_download'],
+            'exp_file_download_uploader' => $posts['exp_file_download_uploader'],
+            'exp_read' => $posts['exp_read'],
+            'exp_post_like' => $posts['exp_post_like'],
+            'exp_post_dislike' => $posts['exp_post_dislike'],
+            'exp_post_liked' => $posts['exp_post_liked'],
+            'exp_post_disliked' => $posts['exp_post_disliked'],
+            'exp_comment_like' => $posts['exp_comment_like'],
+            'exp_comment_dislike' => $posts['exp_comment_dislike'],
+            'exp_comment_liked' => $posts['exp_comment_liked'],
+            'exp_comment_disliked' => $posts['exp_comment_disliked']
+        ];
+
+        DB::beginTransaction();
+
+        try
+        {
+            $this->boardMetaModel->save($boardID, $metaData);
+
+            /*
+             * 그룹, 전체 적용
+             */
+            $groupData = []; // 그룹적용
+            $allData = []; // 전체적용
+
+            foreach ($defaultData as $field) {
+                if (isset($posts['grp'][$field])) {
+                    $groupData[$field] = $posts[$field];
+                }
+                if (isset($posts['all'][$field])) {
+                    $allData[$field] = $posts[$field];
+                }
+            }
+
+            if ($groupData) {
+                $brdGroupData = $this->boardModel->findByGroup($boardData->board_group_id);
+                foreach ($brdGroupData as $bKey => $bVal) {
+                    if ($bVal->id === $boardID) {
+                        continue;
+                    }
+                    $this->boardMetaModel->save($bVal->id, $groupData);
+                }
+            }
+            if ($allData) {
+                $brdAllData = $this->boardModel->all();
+                foreach ($brdAllData as $bKey => $bVal) {
+                    if ($bVal->id === $boardID) {
+                        continue;
+                    }
+                    $this->boardMetaModel->save($bVal->id, $allData);
+                }
+            }
+
+            $message = '게시판 - 경험치 정보가 등록되었습니다.';
+            DB::commit();
+        } catch (Exception $e) {
+            $message = $e->getMessage();
+            DB::rollBack();
+        }
+
+        return redirect()->route('admin.board.board.exp.show', $boardID)->with('message', $message);
+    }
+}

+ 171 - 0
app/Http/Controllers/Admin/Board/Board/GeneralController.php

@@ -0,0 +1,171 @@
+<?php
+
+namespace App\Http\Controllers\Admin\Board\Board;
+
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+use App\Http\Controllers\Controller;
+use App\Models\Board;
+use App\Models\BoardMeta;
+use Exception;
+
+class GeneralController extends Controller
+{
+    private Board $boardModel;
+    private BoardMeta $boardMetaModel;
+
+    public function __construct(Board $board, BoardMeta $boardMeta)
+    {
+        $this->boardModel = $board;
+        $this->boardMetaModel = $boardMeta;
+    }
+
+    /**
+     * 게시판 수정 - 일반
+     * @method GET
+     * @see /admin/board/board/general/{pk}
+     */
+    public function show(int $boardID)
+    {
+        return view('admin.board.board.general', [
+            'boardID' => $boardID,
+            'actionURL' => route('admin.board.board.general.store'),
+
+            // 게시판 정보
+            'boardData' => $this->boardModel->find($boardID),
+
+            // 게시판 메타 정보
+            'boardMetaData' => $this->boardMetaModel->getAllMeta($boardID),
+
+            // 모든 게시판
+            'boardAllData' => $this->boardModel->all()
+        ]);
+    }
+
+    /**
+     * 게시판 수정 - 일반 저장
+     * @method POST
+     * @see /admin/board/board/general/{pk}
+     */
+    public function store(Request $request)
+    {
+        $rules = [
+            'board_id' => 'required|numeric|exists:tb_board,id',
+            'block_delete' => 'required|numeric|in:0,1',
+            'protect_post_day' => 'required|numeric|min:0|max:365',
+            'protect_comment_day' => 'required|numeric|min:0|max:365',
+            'protect_delete_post' => 'required|numeric|in:0,1',
+            'protect_update_post' => 'required|numeric|in:0,1',
+            'use_category' => 'required|numeric|in:0,1',
+            'use_personal' => 'required|numeric|in:0,1',
+            'use_anonymous' => 'required|numeric|in:0,1',
+            'anonymous_except_admin' => 'required|numeric|in:0,1',
+            'anonymous_name' => 'required|string|max:100',
+            'use_download_log' => 'required|numeric|in:0,1',
+            'use_post_history' => 'required|numeric|in:0,1',
+            'use_link_click_log' => 'required|numeric|in:0,1',
+            'use_comment_history' => 'required|numeric|in:0,1',
+            'grp' => 'array',
+            'all' => 'array'
+        ];
+
+        $attributes = [
+            'board_id' => '게시판 PK',
+            'block_delete' => '관리자만 삭제',
+            'protect_post_day' => '게시글 수정/삭제 금지 기간',
+            'protect_comment_day' => '댓글 수정/삭제 금지 기간',
+            'protect_delete_post' => '게시글 보호 기능 (삭제 시)',
+            'protect_update_post' => '게시글 보호 기능 (수정 시)',
+            'use_category' => '분류 기능',
+            'use_personal' => '1:1 게시판',
+            'use_anonymous' => '익명 게시판',
+            'anonymous_except_admin' => '관리자 익명 제외',
+            'anonymous_name' => '익명 이름',
+            'use_download_log' => '다운로드 기록',
+            'use_post_history' => '게시물 변경 기록',
+            'use_link_click_log' => 'Link 클릭 기록',
+            'use_comment_history' => '댓글 변경 기록',
+            'grp' => '그룹 적용',
+            'all' => '전체 적용'
+        ];
+
+        $posts = $this->validate($request, $rules, [], $attributes);
+        $boardID = $posts['board_id'];
+
+        $defaultData = [ // 그룹, 전체 적용 값
+            'block_delete', 'protect_post_day', 'protect_comment_day', 'protect_delete_post', 'protect_update_post',
+            'use_category', 'use_personal', 'use_anonymous', 'anonymous_except_admin', 'anonymous_name',
+            'use_download_log', 'use_post_history', 'use_link_click_log', 'use_comment_history'
+        ];
+
+        // 게시판 정보 조회
+        $boardData = $this->boardModel->find($boardID);
+
+        // 메타 정보 저장
+        $metaData = [
+            'block_delete' => $posts['block_delete'],
+            'protect_post_day' => $posts['protect_post_day'],
+            'protect_comment_day' => $posts['protect_comment_day'],
+            'protect_delete_post' => $posts['protect_delete_post'],
+            'protect_update_post' => $posts['protect_update_post'],
+            'use_category' => $posts['use_category'],
+            'use_personal' => $posts['use_personal'],
+            'use_anonymous' => $posts['use_anonymous'],
+            'anonymous_except_admin' => $posts['anonymous_except_admin'],
+            'anonymous_name' => $posts['anonymous_name'],
+            'use_download_log' => $posts['use_download_log'],
+            'use_post_history' => $posts['use_post_history'],
+            'use_link_click_log' => $posts['use_link_click_log'],
+            'use_comment_history' => $posts['use_comment_history']
+        ];
+
+        DB::beginTransaction();
+
+        try
+        {
+            $this->boardMetaModel->save($boardID, $metaData);
+
+            /*
+             * 그룹, 전체 적용
+             */
+            $groupData = []; // 그룹적용
+            $allData = []; // 전체적용
+
+            foreach ($defaultData as $field) {
+                if (isset($posts['grp'][$field])) {
+                    $groupData[$field] = $posts[$field];
+                }
+                if (isset($posts['all'][$field])) {
+                    $allData[$field] = $posts[$field];
+                }
+            }
+
+            if ($groupData) {
+                $brdGroupData = $this->boardModel->findByGroup($boardData->board_group_id);
+                foreach ($brdGroupData as $bKey => $bVal) {
+                    if ($bVal->id === $boardID) {
+                        continue;
+                    }
+                    $this->boardMetaModel->save($bVal->id, $groupData);
+                }
+            }
+            if ($allData) {
+                $brdAllData = $this->boardModel->all();
+                foreach ($brdAllData as $bKey => $bVal) {
+                    if ($bVal->id === $boardID) {
+                        continue;
+                    }
+                    $this->boardMetaModel->save($bVal->id, $allData);
+                }
+            }
+
+            $message = '게시판 - 일반 정보가 저장되었습니다.';
+            DB::commit();
+        } catch (Exception $e) {
+            $message = $e->getMessage();
+            DB::rollBack();
+        }
+
+        return redirect()->route('admin.board.board.general.show', $boardID)->with('message', $message);
+    }
+}

+ 298 - 0
app/Http/Controllers/Admin/Board/Board/ListController.php

@@ -0,0 +1,298 @@
+<?php
+
+namespace App\Http\Controllers\Admin\Board\Board;
+
+use Illuminate\Http\Request;
+use Illuminate\Validation\Rule;
+use Illuminate\Support\Facades\Validator;
+use App\Http\Controllers\Controller;
+use App\Models\Board;
+use App\Models\BoardMeta;
+use App\Models\BoardGroup;
+use App\Models\DTO\SearchData;
+use App\Models\DTO\ResponseData;
+use Exception;
+
+class ListController extends Controller
+{
+    private Board $boardModel;
+    private BoardMeta $boardMetaModel;
+    private BoardGroup $boardGroupModel;
+
+    public function __construct(Board $board, BoardMeta $boardMeta, BoardGroup $boardGroup)
+    {
+        $this->boardModel = $board;
+        $this->boardMetaModel = $boardMeta;
+        $this->boardGroupModel = $boardGroup;
+    }
+
+    /**
+     * 게시판 관리
+     * @method GET
+     * @see /admin/board/board/list
+     */
+    public function index(Request $request)
+    {
+        $params = SearchData::fromRequest($request);
+        $boardData = $this->boardModel->data($params);
+
+        if ($boardData->rows > 0) {
+            $num = listNum($boardData->total,$params->page, $params->perPage);
+            foreach ($boardData->list as $i => $row) {
+                $row->num = $num--;
+                $row->postRows = number_format($row->postRows);
+                $row->commentRows = number_format($row->commentRows);
+                $row->editURL = route('admin.board.board.list.show', $row->id);
+
+                $boardData->list[$i] = $row;
+            }
+        }
+
+        return view('admin.board.board.list', [
+            'boardGroupData' => $this->boardGroupModel->all(),
+            'boardData' => $boardData,
+            'params' => $params
+        ]);
+    }
+
+    /**
+     * 게시판 수정 - 기본
+     * @method GET
+     * @see /admin/board/board/list/{pk}
+     */
+    public function show(int $boardID)
+    {
+        return view('admin.board.board.basic', [
+            'actionURL' => route('admin.board.board.list.update', $boardID),
+            'boardData' => $this->boardModel->find($boardID),
+            'boardMetaData' => $this->boardMetaModel->getAllMeta($boardID),
+            'boardAllData' => $this->boardModel->all(), // 모든 게시판
+            'boardGroupData' => $this->boardGroupModel->all(), // 게시판 그룹
+            'boardID' => $boardID
+        ]);
+    }
+
+    /**
+     * 게시판 등록
+     * @method GET
+     * @see /admin/board/board/create
+     */
+    public function create()
+    {
+        return view('admin.board.board.basic', [
+            'actionURL' => route('admin.board.board.list.store'),
+            'boardData' => [],
+            'boardMetaData' => [],
+            'boardAllData' => $this->boardModel->all(), // 모든 게시판
+            'boardGroupData' => $this->boardGroupModel->all(), // 게시판 그룹
+            'boardID' => null
+        ]);
+    }
+
+    /**
+     * 게시판 등록 저장
+     * @method POST
+     * @see /admin/board/board/list
+     */
+    public function store(Request $request, ResponseData $response)
+    {
+        $rules = [
+            'code' => [
+                'required',
+                'string',
+                'min:3',
+                'max:50',
+                'alpha_dash',
+            ],
+            'name' => 'required|max:255',
+            'board_group_id' => 'required|exists:tb_board_group,id',
+            'pc_header_content' => 'string|nullable|max:4000',
+            'pc_footer_content' => 'string|nullable|max:4000',
+            'mobile_header_content' => 'string|nullable|max:4000',
+            'mobile_footer_content' => 'string|nullable|max:4000',
+            'sort' => 'required|numeric',
+            'is_search' => 'required|numeric|in:0,1',
+            'is_display' => 'required|numeric|in:0,1',
+            'use_inform_modal' => 'required|numeric|in:0,1',
+            'inform_content' => 'string|nullable'
+        ];
+
+        $attributes = [
+            'code' => '주소',
+            'name' => '제목',
+            'board_group_id' => '그룹 PK',
+            'pc_header_content' => 'PC 상단 내용',
+            'pc_footer_content' => 'PC 하단 내용',
+            'mobile_header_content' => '모바일 상단 내용',
+            'mobile_footer_content' => '모바일 하단 내용',
+            'sort' => '순서',
+            'is_search' => '검색 여부',
+            'is_display' => '사용 여부',
+            'use_inform_modal' => '이용안내 표시',
+            'inform_content' => '이용안내 내용'
+        ];
+
+        $this->validate($request, $rules, [], $attributes);
+
+        $result = $this->boardModel->register($request, $response);
+
+        if(!$result->success) {
+            return back()->withErrors($result->message)->withInput();
+        }
+
+        $message = '게시판이 등록되었습니다.';
+        return redirect()->route('admin.board.board.list.index')->with('message', $message);
+    }
+
+    /**
+     * 게시판 수정 저장
+     * @method PUT
+     * @see /admin/board/board/list/{pk}
+     */
+    public function update(Request $request, ResponseData $response)
+    {
+        $boardID = $request->post('board_id');
+
+        $rules = [
+            'board_id' => 'required|numeric|exists:tb_board,id',
+            'code' => [
+                'required',
+                'string',
+                'min:3',
+                'max:50',
+                'alpha_dash',
+                Rule::unique('tb_board', 'code')->ignore($boardID, 'id')
+            ],
+            'name' => 'required|max:255',
+            'board_group_id' => 'required|exists:tb_board_group,id',
+            'pc_header_content' => 'string|nullable',
+            'pc_footer_content' => 'string|nullable',
+            'mobile_header_content' => 'string|nullable',
+            'mobile_footer_content' => 'string|nullable',
+            'sort' => 'required|numeric',
+            'is_search' => 'required|numeric|in:0,1',
+            'is_display' => 'required|numeric|in:0,1',
+            'use_inform_modal' => 'required|numeric|in:0,1',
+            'inform_content' => 'string|nullable'
+        ];
+
+        $attributes = [
+            'board_id' => 'PK',
+            'code' => '주소',
+            'name' => '제목',
+            'board_group_id' => '그룹 PK',
+            'pc_header_content' => 'PC 상단 내용',
+            'pc_footer_content' => 'PC 하단 내용',
+            'mobile_header_content' => '모바일 상단 내용',
+            'mobile_footer_content' => '모바일 하단 내용',
+            'sort' => '순서',
+            'is_search' => '검색 여부',
+            'is_display' => '사용 여부',
+            'use_inform_modal' => '이용안내 표시',
+            'inform_content' => '이용안내 내용'
+        ];
+
+        $this->validate($request, $rules, [], $attributes);
+
+        $result = $this->boardModel->updater($boardID, $request, $response);
+
+        if(!$result->success) {
+            return back()->withErrors($result->message)->withInput();
+        }
+
+        $message = '게시판이 수정되었습니다.';
+        return redirect()->route('admin.board.board.list.show', $boardID)->with('message', $message);
+    }
+
+    /**
+     * 게시판 선택 수정
+     * @method PUT
+     * @see /admin/board/board/list
+     */
+    public function listUpdate(Request $request)
+    {
+        $posts = $request->all();
+
+        if($posts['chk']) {
+            foreach($posts['chk'] as $boardID) {
+                $updateData = [
+                    'code' => ($posts['code'][$boardID] ?? ''),
+                    'name' => ($posts['name'][$boardID] ?? ''),
+                    'board_group_id' => ($posts['board_group_id'][$boardID] ?? ''),
+                    'sort' => ($posts['sort'][$boardID] ?? '0'),
+                    'is_search' => ($posts['is_search'][$boardID] ?? '0'),
+                    'is_display' => ($posts['is_display'][$boardID] ?? '0'),
+                ];
+
+                $rules = [
+                    'code' => [
+                        'required',
+                        'string',
+                        'min:3',
+                        'max:50',
+                        'alpha_dash',
+                        Rule::unique('tb_board', 'code')->ignore($boardID, 'id')
+                    ],
+                    'name' => 'required|max:255',
+                    'board_group_id' => 'required|exists:tb_board_group,id',
+                    'sort' => 'required|numeric',
+                    'is_search' => 'required|numeric|in:0,1',
+                    'is_display' => 'required|numeric|in:0,1'
+                ];
+
+                $attributes = [
+                    'code' => '주소',
+                    'name' => '제목',
+                    'board_group_id' => '그룹 PK',
+                    'sort' => '순서',
+                    'is_search' => '검색 여부',
+                    'is_display' => '사용 여부'
+                ];
+
+                $validator = Validator::make($updateData, $rules, [], $attributes);
+                if($validator->fails()) {
+                    return back()->withErrors($validator)->withInput();
+                }
+
+                $this->boardModel->find($boardID)->update($updateData);
+            }
+        }
+
+        $message = '게시판 정보가 변경되었습니다.';
+        return redirect()->route('admin.board.board.list.index')->with('message', $message);
+    }
+
+    /**
+     * 게시판 삭제
+     * @method DELETE
+     * @see /admin/board/board/list/destroy
+     */
+    public function destroy(Request $request)
+    {
+        try{
+
+            $chk = $request->post('chk');
+
+            if ($chk) {
+                foreach ($chk as $boardID) {
+                    $board = $this->boardModel->findOrNew($boardID);
+
+                    if (!$board->exists) {
+                        throw new Exception($boardID . '번 게시판은 존재하지 않습니다.');
+                    }
+
+                    if ($board->post->count() > 0) {
+                        return back()->withErrors($boardID . "번 게시판은 삭제할 수 없습니다.")->withInput();
+                    }
+
+                    $this->boardModel->remove($boardID);
+                }
+            }
+
+            $message = '게시판 정보가 삭제되었습니다.';
+            return redirect()->route('admin.board.board.list.index')->with('message', $message);
+        }catch(Exception $e) {
+            return back()->withErrors($e->getMessage())->withInput();
+        }
+    }
+}

+ 123 - 0
app/Http/Controllers/Admin/Board/Board/ManagerController.php

@@ -0,0 +1,123 @@
+<?php
+
+namespace App\Http\Controllers\Admin\Board\Board;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Models\User;
+use App\Models\Board;
+use App\Models\BoardAdmin;
+use Exception;
+
+class ManagerController extends Controller
+{
+    private Board $boardModel;
+    private BoardAdmin $boardAdminModel;
+    private User $userModel;
+
+    public function __construct(Board $board, BoardAdmin $boardAdmin, User $user)
+    {
+        $this->boardModel = $board;
+        $this->boardAdminModel = $boardAdmin;
+        $this->userModel = $user;
+    }
+
+    /**
+     * 게시판 수정 - 관리자
+     * @method GET
+     * @see /admin/board/board/manager/{pk}
+     */
+    public function show(int $boardID)
+    {
+        return view('admin.board.board.manager', [
+            'boardID' => $boardID,
+            'actionURL' => route('admin.board.board.manager.store'),
+
+            // 게시판 관리자 목록
+            'boardAdminData' => $this->boardAdminModel->data(),
+
+            // 게시판 정보
+            'boardData' => $this->boardModel->find($boardID),
+
+            // 모든 게시판
+            'boardAllData' => $this->boardModel->all()
+        ]);
+    }
+
+    /**
+     * 게시판 그룹 관리자 등록 저장
+     * @method POST
+     * @see /admin/board/board/manager/{pk}
+     */
+    public function store(Request $request)
+    {
+        $rules = [
+            'board_id' => 'required|numeric|exists:tb_board,id',
+            'sid' => 'required|exists:users,sid'
+        ];
+
+        $attributes = [
+            'board_id' => '게시판 수정 - 관리자',
+            'sid' => '회원 ID'
+        ];
+
+        $posts = $this->validate($request, $rules, [], $attributes);
+
+        $boardID = $posts['board_id'];
+        $sid = $posts['sid'];
+
+        $user = $this->userModel->findByUserID($sid);
+
+        // 이미 등록된 관리자인지 확인
+        $isBoardAdmin = $this->boardAdminModel->where([
+            ['board_id', $boardID],
+            ['user_id', $user->id]
+        ])->count();
+
+        if ($isBoardAdmin) {
+            return back()->withErrors(['sid' => "{$sid}는 이미 등록된 관리자 입니다."])->withInput();
+        }
+
+        $this->boardAdminModel->insert([
+            'board_id' => $boardID,
+            'user_id' => $user->id,
+            'created_at' => now()
+        ]);
+
+        $message = '게시판 관리자가 새로 등록되었습니다.';
+        return redirect()->route('admin.board.board.manager.show', $boardID)->with('message', $message);
+    }
+
+    /**
+     * 게시판 그룹 관리자 삭제
+     * @method DELETE
+     * @see /admin/board/board/manager/{pk}/destroy
+     */
+    public function destroy(Request $request)
+    {
+        try {
+
+            $boardID = $request->post('board_id');
+            $chk = $request->post('chk');
+
+            if ($chk) {
+                foreach ($chk as $boardAdminID) {
+                    $boardAdmin = $this->boardAdminModel->findOrNew($boardAdminID);
+
+                    if (!$boardAdmin->exists) {
+                        throw new Exception($boardAdminID . '번 관리자는 존재하지 않습니다.');
+                    }
+
+                    if (!$boardAdmin->delete()) {
+                        throw new Exception($boardAdminID . '번 관리자는 삭제할 수 없습니다.');
+                    }
+                }
+            }
+
+            $message = '게시판 관리자 정보가 삭제되었습니다.';
+            return redirect()->route('admin.board.board.manager.show', $boardID)->with('message', $message);
+        } catch (Exception $e) {
+            return back()->withErrors($e->getMessage())->withInput();
+        }
+    }
+}

+ 300 - 0
app/Http/Controllers/Admin/Board/Board/NotifyController.php

@@ -0,0 +1,300 @@
+<?php
+
+namespace App\Http\Controllers\Admin\Board\Board;
+
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+use App\Http\Controllers\Controller;
+use App\Models\Board;
+use App\Models\BoardMeta;
+use Exception;
+
+class NotifyController extends Controller
+{
+    private Board $boardModel;
+    private BoardMeta $boardMetaModel;
+
+    public function __construct(Board $board, BoardMeta $boardMeta)
+    {
+        $this->boardModel = $board;
+        $this->boardMetaModel = $boardMeta;
+    }
+
+    /**
+     * 게시판 수정 - 알람
+     * @method GET
+     * @see /admin/board/board/notify/{pk}
+     */
+    public function show(int $boardID)
+    {
+        return view('admin.board.board.notify', [
+            'boardID' => $boardID,
+            'actionURL' => route('admin.board.board.notify.store'),
+
+            // 게시판 정보
+            'boardData' => $this->boardModel->find($boardID),
+
+            // 게시판 메타 정보
+            'boardMetaData' => $this->boardMetaModel->getAllMeta($boardID),
+
+            // 모든 게시판
+            'boardAllData' => $this->boardModel->all()
+        ]);
+    }
+
+    /**
+     * 게시판 수정 - 알람 저장
+     * @method POST
+     * @see /admin/board/board/notify/{pk}
+     */
+    public function store(Request $request)
+    {
+        $rules = [
+            'board_id' => 'required|numeric|exists:tb_board,id',
+            'send_email_post_super_admin' => 'numeric|in:0,1',
+            'send_email_post_post_writer' => 'numeric|in:0,1',
+            'send_email_comment_super_admin' => 'numeric|in:0,1',
+            'send_email_comment_post_writer' => 'numeric|in:0,1',
+            'send_email_comment_comment_writer' => 'numeric|in:0,1',
+            'send_email_post_blame_super_admin' => 'numeric|in:0,1',
+            'send_email_post_blame_post_writer' => 'numeric|in:0,1',
+            'send_email_comment_blame_super_admin' => 'numeric|in:0,1',
+            'send_email_comment_blame_post_writer' => 'numeric|in:0,1',
+            'send_email_comment_blame_comment_writer' => 'numeric|in:0,1',
+            'send_email_post_personal_super_admin' => 'numeric|in:0,1',
+            'send_email_post_personal_post_writer' => 'numeric|in:0,1',
+            'send_email_post_personal_reply_super_admin' => 'numeric|in:0,1',
+            'send_email_post_personal_reply_post_writer' => 'numeric|in:0,1',
+            'send_email_post_personal_reply_comment_writer' => 'numeric|in:0,1',
+
+            'send_telegram_post_super_admin' => 'numeric|in:0,1',
+            'send_telegram_comment_super_admin' => 'numeric|in:0,1',
+            'send_telegram_blame_super_admin' => 'numeric|in:0,1',
+            'send_telegram_comment_blame_super_admin' => 'numeric|in:0,1',
+            'grp' => 'array',
+            'all' => 'array'
+        ];
+
+        $attributes = [
+            'board_id' => '게시판 PK',
+            'send_email_post_super_admin' => '이메일 발송(게시글 작성) - 최고 관리자',
+            'send_email_post_post_writer' => '이메일 발송(게시글 작성) - 게시글 작성자',
+            'send_email_comment_super_admin' => '이메일 발송(댓글 작성) - 최고 관리자',
+            'send_email_comment_post_writer' => '이메일 발송(댓글 작성) - 게시글 작성자',
+            'send_email_comment_comment_writer' => '이메일 발송(댓글) - 댓글 작성자',
+            'send_email_post_blame_super_admin' => '이메일 발송(게시글 신고) - 최고 관리자',
+            'send_email_post_blame_post_writer' => '이메일 발송(게시글 신고) - 게시글 작성자',
+            'send_email_comment_blame_super_admin' => '이메일 발송(댓글 신고) - 최고 관리자',
+            'send_email_comment_blame_post_writer' => '이메일 발송(댓글 신고) - 게시글 작성자',
+            'send_email_comment_blame_comment_writer' => '이메일 발송(댓글 신고) - 댓글 작성자',
+            'send_email_post_personal_super_admin' => '이메일 발송(1:1 문의 접수 시) - 최고관리자',
+            'send_email_post_personal_post_writer' => '이메일 발송(1:1 문의 접수 시) - 게시글 작성자',
+            'send_email_post_personal_reply_super_admin' => '이메일 발송(1:1 문의 답변 시) - 최고관리자',
+            'send_email_post_personal_reply_post_writer' => '이메일 발송(1:1 문의 답변 시) - 게시글 작성자',
+            'send_email_post_personal_reply_comment_writer' => '이메일 발송(1:1 문의 답변 시) - 댓글 작성자',
+
+            'send_telegram_post_super_admin' => '문자 발송(게시글 작성) - 최고 관리자',
+            'send_telegram_comment_super_admin' => '문자 발송(댓글 작성) - 최고 관리자',
+            'send_telegram_blame_super_admin' => '문자 발송(게시글 신고) - 최고 관리자',
+            'send_telegram_comment_blame_super_admin' => '문자 발송(댓글 신고) - 최고 관리자',
+            'grp' => '그룹 적용',
+            'all' => '전체 적용'
+        ];
+
+        $posts = $this->validate($request, $rules, [], $attributes);
+        $boardID = $posts['board_id'];
+
+        // 게시판 정보 조회
+        $boardData = $this->boardModel->find($boardID);
+
+        // 메타 정보 저장
+        $metaData = [
+            // 게시글 작성
+            'send_email_post_super_admin' => ($posts['send_email_post_super_admin'] ?? 0),
+            'send_email_post_post_writer' => ($posts['send_email_post_post_writer'] ?? 0),
+
+            // 댓글 작성
+            'send_email_comment_super_admin' => ($posts['send_email_comment_super_admin'] ?? 0),
+            'send_email_comment_post_writer' => ($posts['send_email_comment_post_writer'] ?? 0),
+            'send_email_comment_comment_writer' => ($posts['send_email_comment_comment_writer'] ?? 0),
+
+            // 게시글 신고
+            'send_email_post_blame_super_admin' => ($posts['send_email_post_blame_super_admin'] ?? 0),
+            'send_email_post_blame_post_writer' => ($posts['send_email_post_blame_post_writer'] ?? 0),
+
+            // 댓글 신고
+            'send_email_comment_blame_super_admin' => ($posts['send_email_comment_blame_super_admin'] ?? 0),
+            'send_email_comment_blame_post_writer' => ($posts['send_email_comment_blame_post_writer'] ?? 0),
+            'send_email_comment_blame_comment_writer' => ($posts['send_email_comment_blame_comment_writer'] ?? 0),
+
+            // 1:1 문의
+            'send_email_post_personal_super_admin' => ($posts['send_email_post_personal_super_admin'] ?? 0),
+            'send_email_post_personal_post_writer' => ($posts['send_email_post_personal_post_writer'] ?? 0),
+            'send_email_post_personal_reply_super_admin' => ($posts['send_email_post_personal_reply_super_admin'] ?? 0),
+            'send_email_post_personal_reply_post_writer' => ($posts['send_email_post_personal_reply_post_writer'] ?? 0),
+            'send_email_post_personal_reply_comment_writer' => ($posts['send_email_post_personal_reply_comment_writer'] ?? 0),
+
+            // 텔레그램
+            'send_telegram_post_super_admin' => ($posts['send_telegram_post_super_admin'] ?? 0),
+            'send_telegram_comment_super_admin' => ($posts['send_telegram_comment_super_admin'] ?? 0),
+            'send_telegram_blame_super_admin' => ($posts['send_telegram_blame_super_admin'] ?? 0),
+            'send_telegram_comment_blame_super_admin' => ($posts['send_telegram_comment_blame_super_admin'] ?? 0),
+        ];
+
+        DB::beginTransaction();
+
+        try
+        {
+            $this->boardMetaModel->save($boardID, $metaData);
+
+            /*
+             * 그룹, 전체 적용
+             */
+            $groupData = []; // 그룹적용
+            $allData = []; // 전체적용
+
+            $groupKeys = $posts['grp'] ?? [];
+            $allKeys = $posts['all'] ?? [];
+
+            if ($groupKeys) {
+                $brdGroupData = $this->boardModel->findByGroup($boardData->board_group_id);
+                foreach ($brdGroupData as $bKey => $bVal) {
+                    if ($bVal->id === $boardID) {
+                        continue;
+                    }
+
+                    foreach ($groupKeys as $gk => $gv) {
+                        // 게시글 작성 시
+                        if ($gk === 'send_email_post') {
+                            $groupData['send_email_post_super_admin'] = $metaData['send_email_post_super_admin'];
+                            $groupData['send_email_post_post_writer'] = $metaData['send_email_post_post_writer'];
+                        }
+                        // 댓글 작성 시
+                        if ($gk === 'send_email_comment') {
+                            $groupData['send_email_comment_super_admin'] = $metaData['send_email_comment_super_admin'];
+                            $groupData['send_email_comment_post_writer'] = $metaData['send_email_comment_post_writer'];
+                            $groupData['send_email_comment_comment_writer'] = $metaData['send_email_comment_comment_writer'];
+                        }
+                        // 게시글 신고 발생 시
+                        if ($gk === 'send_email_post_blame') {
+                            $groupData['send_email_post_blame_super_admin'] = $metaData['send_email_post_blame_super_admin'];
+                            $groupData['send_email_post_blame_post_writer'] = $metaData['send_email_post_blame_post_writer'];
+                        }
+                        // 댓글 신고 발생 시
+                        if ($gk === 'send_email_comment_blame') {
+                            $groupData['send_email_comment_blame_super_admin'] = $metaData['send_email_comment_blame_super_admin'];
+                            $groupData['send_email_comment_blame_post_writer'] = $metaData['send_email_comment_blame_post_writer'];
+                            $groupData['send_email_comment_blame_comment_writer'] = $metaData['send_email_comment_blame_comment_writer'];
+                        }
+                        // 1:1 문의 접수 시
+                        if ($gk === 'send_email_post_personal') {
+                            $groupData['send_email_post_personal_super_admin'] = $metaData['send_email_post_personal_super_admin'];
+                            $groupData['send_email_post_personal_post_writer'] = $metaData['send_email_post_personal_post_writer'];
+                        }
+                        // 1:1 문의 답변 시
+                        if ($gk === 'send_email_post_personal_reply') {
+                            $groupData['send_email_post_personal_reply_super_admin'] = $metaData['send_email_post_personal_reply_super_admin'];
+                            $groupData['send_email_post_personal_reply_post_writer'] = $metaData['send_email_post_personal_reply_post_writer'];
+                            $groupData['send_email_post_personal_reply_comment_writer'] = $metaData['send_email_post_personal_reply_comment_writer'];
+                        }
+
+                        /*
+                         * 텔레그램
+                         */
+                        // 게시글 작성 시
+                        if ($gk === 'send_telegram_post') {
+                            $groupData['send_telegram_post_super_admin'] = $metaData['send_telegram_post_super_admin'];
+                        }
+                        // 댓글 작성 시
+                        if ($gk === 'send_telegram_comment') {
+                            $groupData['send_telegram_comment_super_admin'] = $metaData['send_telegram_comment_super_admin'];
+                        }
+                        // 게시글 신고 발생 시
+                        if ($gk === 'send_telegram_blame') {
+                            $groupData['send_telegram_blame_super_admin'] = $metaData['send_telegram_blame_super_admin'];
+                        }
+                        // 댓글 신고 발생 시
+                        if ($gk === 'send_telegram_comment_blame') {
+                            $groupData['send_telegram_comment_blame_super_admin'] = $metaData['send_telegram_comment_blame_super_admin'];
+                        }
+                    }
+
+                    $this->boardMetaModel->save($bVal->id, $groupData);
+                }
+            }
+
+            if ($allKeys) {
+                $brdAllData = $this->boardModel->all();
+                foreach ($brdAllData as $bKey => $bVal) {
+                    if ($bVal->id === $boardID) {
+                        continue;
+                    }
+                    foreach ($allKeys as $ak => $av) {
+                        // 게시글 작성 시
+                        if ($ak === 'send_email_post') {
+                            $allData['send_email_post_super_admin'] = $metaData['send_email_post_super_admin'];
+                            $allData['send_email_post_post_writer'] = $metaData['send_email_post_post_writer'];
+                        }
+                        // 댓글 작성 시
+                        if ($ak === 'send_email_comment') {
+                            $allData['send_email_comment_super_admin'] = $metaData['send_email_comment_super_admin'];
+                            $allData['send_email_comment_post_writer'] = $metaData['send_email_comment_post_writer'];
+                            $allData['send_email_comment_comment_writer'] = $metaData['send_email_comment_comment_writer'];
+                        }
+                        // 게시글 신고 발생 시
+                        if ($ak === 'send_email_post_blame') {
+                            $allData['send_email_post_blame_super_admin'] = $metaData['send_email_post_blame_super_admin'];
+                            $allData['send_email_post_blame_post_writer'] = $metaData['send_email_post_blame_post_writer'];
+                        }
+                        // 댓글 신고 발생 시
+                        if ($ak === 'send_email_comment_blame') {
+                            $allData['send_email_comment_blame_super_admin'] = $metaData['send_email_comment_blame_super_admin'];
+                            $allData['send_email_comment_blame_post_writer'] = $metaData['send_email_comment_blame_post_writer'];
+                            $allData['send_email_comment_blame_comment_writer'] = $metaData['send_email_comment_blame_comment_writer'];
+                        }
+                        // 1:1 문의 접수 시
+                        if ($ak === 'send_email_post_personal') {
+                            $allData['send_email_post_personal_super_admin'] = $metaData['send_email_post_personal_super_admin'];
+                            $allData['send_email_post_personal_post_writer'] = $metaData['send_email_post_personal_post_writer'];
+                        }
+                        // 1:1 문의 답변 시
+                        if ($ak === 'send_email_post_personal_reply') {
+                            $allData['send_email_post_personal_reply_super_admin'] = $metaData['send_email_post_personal_reply_super_admin'];
+                            $allData['send_email_post_personal_reply_post_writer'] = $metaData['send_email_post_personal_reply_post_writer'];
+                            $allData['send_email_post_personal_reply_comment_writer'] = $metaData['send_email_post_personal_reply_comment_writer'];
+                        }
+
+                        /*
+                         * 텔레그램
+                         */
+                        // 게시글 작성 시
+                        if ($ak === 'send_telegram_post') {
+                            $allData['send_telegram_post_super_admin'] = $metaData['send_telegram_post_super_admin'];
+                        }
+                        // 댓글 작성 시
+                        if ($ak === 'send_telegram_comment') {
+                            $allData['send_telegram_comment_super_admin'] = $metaData['send_telegram_comment_super_admin'];
+                        }
+                        // 게시글 신고 발생 시
+                        if ($ak === 'send_telegram_blame') {
+                            $allData['send_telegram_blame_super_admin'] = $metaData['send_telegram_blame_super_admin'];
+                        }
+                        // 댓글 신고 발생 시
+                        if ($ak === 'send_telegram_comment_blame') {
+                            $allData['send_telegram_comment_blame_super_admin'] = $metaData['send_telegram_comment_blame_super_admin'];
+                        }
+                    }
+                    $this->boardMetaModel->save($bVal->id, $allData);
+                }
+            }
+
+            $message = '게시판 - 알람 정보가 저장되었습니다.';
+            DB::commit();
+        } catch (Exception $e) {
+            $message = $e->getMessage();
+            DB::rollBack();
+        }
+
+        return redirect()->route('admin.board.board.notify.show', $boardID)->with('message', $message);
+    }
+}

+ 190 - 0
app/Http/Controllers/Admin/Board/Board/PostController.php

@@ -0,0 +1,190 @@
+<?php
+
+namespace App\Http\Controllers\Admin\Board\Board;
+
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+use App\Http\Controllers\Controller;
+use App\Models\Board;
+use App\Models\BoardMeta;
+use Exception;
+
+class PostController extends Controller
+{
+    private Board $boardModel;
+    private BoardMeta $boardMetaModel;
+
+    public function __construct(Board $board, BoardMeta $boardMeta)
+    {
+        $this->boardModel = $board;
+        $this->boardMetaModel = $boardMeta;
+    }
+
+    /**
+     * 게시판 수정 - 목록
+     * @method GET
+     * @see /admin/board/board/post/{pk}
+     */
+    public function show(int $boardID)
+    {
+        return view('admin.board.board.post', [
+            'boardID' => $boardID,
+            'actionURL' => route('admin.board.board.post.store'),
+
+            // 게시판 정보
+            'boardData' => $this->boardModel->find($boardID),
+
+            // 게시판 메타 정보
+            'boardMetaData' => $this->boardMetaModel->getAllMeta($boardID),
+
+            // 모든 게시판
+            'boardAllData' => $this->boardModel->all()
+        ]);
+    }
+
+    /**
+     * 게시판 수정 - 목록 저장
+     * @method POST
+     * @see /admin/board/board/post/{pk}
+     */
+    public function store(Request $request)
+    {
+        $rules = [
+            'board_id' => 'required|numeric|exists:tb_board,id',
+            'board_layout_type' => 'required|numeric|in:0,1,2',
+            'list_sort_type' => 'required|numeric|in:1,2,3,4',
+            'list_per_page' => 'required|numeric|between:0,50',
+            'list_mobile_per_page' => 'required|numeric|between:0,50',
+            'list_page_count' => 'required|numeric|between:0,50',
+            'list_page_mobile_count' => 'required|numeric|between:0,50',
+            'always_show_write_button' => 'required|in:0,1',
+            'show_list_from_view' => 'required|in:0,1',
+            'new_icon_hour' => 'required|numeric|between:0,24',
+            'hot_icon_hit' => 'required|numeric|between:0,999999',
+            'hot_icon_day' => 'required|numeric|between:0,365',
+            'subject_length' => 'required|max:255',
+            'subject_mobile_length' => 'required|max:255',
+            'except_notice' => 'required|numeric',
+            'except_speaker' => 'required|numeric',
+            'grp' => 'array',
+            'all' => 'array'
+        ];
+
+        $attributes = [
+            'board_id' => '게시판 PK',
+            'board_layout_type' => '게시판 종류',
+            'list_sort_type' => '기본 정렬 순',
+            'list_per_page' => 'PC - 목록 수',
+            'list_mobile_per_page' => '모바일 - 목록 수',
+            'list_page_count' => 'PC - 페이지 수',
+            'list_page_mobile_count' => '모바일 - 페이지 수',
+            'always_show_write_button' => '글쓰기 버튼 보이기',
+            'show_list_from_view' => '하단 목록 보이기',
+            'new_icon_hour' => 'NEW 아이콘 - 시간',
+            'hot_icon_hit' => 'HOT 아이콘 - 조회 수',
+            'hot_icon_day' => 'HOT 아이콘 - 일',
+            'subject_length' => 'PC - 제목',
+            'subject_mobile_length' => 'PC - 모바일',
+            'except_notice' => '공지사항 제외',
+            'except_speaker' => '전체공지 제외',
+            'grp' => '그룹 적용',
+            'all' => '전체 적용'
+        ];
+
+        $posts = $this->validate($request, $rules, [], $attributes);
+        $boardID = $posts['board_id'];
+
+        $defaultData = [ // 그룹, 전체 적용 값
+            'board_layout_type', 'list_sort_type', 'list_per_page', 'list_mobile_per_page',
+            'list_page_count', 'list_page_mobile_count', 'always_show_write_button', 'show_list_from_view', 'new_icon_hour',
+            'hot_icon_hit', 'hot_icon_day', 'subject_length', 'subject_mobile_length', 'except_notice', 'except_speaker'
+        ];
+
+        $mapMobileData = [ // 모바일 값
+            'list_per_page' => 'list_mobile_per_page',
+            'list_page_count' => 'list_page_mobile_count',
+            'subject_length' => 'subject_mobile_length'
+        ];
+
+        // 게시판 정보 조회
+        $boardData = $this->boardModel->find($posts['board_id']);
+
+        // 메타 정보 저장
+        $metaData = [
+            'board_layout_type' => $posts['board_layout_type'],
+            'list_sort_type' => $posts['list_sort_type'],
+            'list_per_page' => $posts['list_per_page'],
+            'list_mobile_per_page' => $posts['list_mobile_per_page'],
+            'list_page_count' => $posts['list_page_count'],
+            'list_page_mobile_count' => $posts['list_page_mobile_count'],
+            'always_show_write_button' => $posts['always_show_write_button'],
+            'show_list_from_view' => $posts['show_list_from_view'],
+            'new_icon_hour' => $posts['new_icon_hour'],
+            'hot_icon_hit' => $posts['hot_icon_hit'],
+            'hot_icon_day' => $posts['hot_icon_day'],
+            'subject_length' => $posts['subject_length'],
+            'subject_mobile_length' => $posts['subject_mobile_length'],
+            'except_notice' => $posts['except_notice'],
+            'except_speaker' => $posts['except_speaker']
+        ];
+
+        DB::beginTransaction();
+
+        try
+        {
+
+            $this->boardMetaModel->save($boardID, $metaData);
+
+            /*
+             * 그룹, 전체 적용
+             */
+            $groupData = []; // 그룹적용
+            $allData = []; // 전체적용
+
+            foreach ($defaultData as $field) {
+                if (isset($posts['grp'][$field])) {
+                    $groupData[$field] = $posts[$field];
+
+                    if (array_key_exists($field, $mapMobileData)) {
+                        $groupData[$mapMobileData[$field]] = $posts[$mapMobileData[$field]];
+                    }
+                }
+                if (isset($posts['all'][$field])) {
+                    $allData[$field] = $posts[$field];
+
+                    if (array_key_exists($field, $mapMobileData)) {
+                        $allData[$mapMobileData[$field]] = $posts[$mapMobileData[$field]];
+                    }
+                }
+            }
+
+            if ($groupData) {
+                $brdGroupData = $this->boardModel->findByGroup($boardData->board_group_id);
+                foreach ($brdGroupData as $bKey => $bVal) {
+                    if ($bVal->id === $boardID) {
+                        continue;
+                    }
+                    $this->boardMetaModel->save($bVal->id, $groupData);
+                }
+            }
+
+            if ($allData) {
+                $brdAllData = $this->boardModel->all();
+                foreach ($brdAllData as $bKey => $bVal) {
+                    if ($bVal->id === $boardID) {
+                        continue;
+                    }
+                    $this->boardMetaModel->save($bVal->id, $allData);
+                }
+            }
+
+            $message = '게시판 - 목록 정보가 저장되었습니다.';
+            DB::commit();
+        } catch (Exception $e) {
+            $message = $e->getMessage();
+            DB::rollBack();
+        }
+
+        return redirect()->route('admin.board.board.post.show', $boardID)->with('message', $message);
+    }
+}

+ 188 - 0
app/Http/Controllers/Admin/Board/Board/ViewController.php

@@ -0,0 +1,188 @@
+<?php
+
+namespace App\Http\Controllers\Admin\Board\Board;
+
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+use App\Http\Controllers\Controller;
+use App\Models\Board;
+use App\Models\BoardMeta;
+use Exception;
+
+class ViewController extends Controller
+{
+    private Board $boardModel;
+    private BoardMeta $boardMetaModel;
+
+    public function __construct(Board $board, BoardMeta $boardMeta)
+    {
+        $this->boardModel = $board;
+        $this->boardMetaModel = $boardMeta;
+    }
+
+    /**
+     * 게시판 수정 - 열람
+     * @method GET
+     * @see /admin/board/board/view/{pk}
+     */
+    public function show(int $boardID)
+    {
+        return view('admin.board.board.view', [
+            'boardID' => $boardID,
+            'actionURL' => route('admin.board.board.view.store'),
+
+            // 게시판 정보
+            'boardData' => $this->boardModel->find($boardID),
+
+            // 게시판 메타 정보
+            'boardMetaData' => $this->boardMetaModel->getAllMeta($boardID),
+
+            // 모든 게시판
+            'boardAllData' => $this->boardModel->all()
+        ]);
+    }
+
+    /**
+     * 게시판 수정 - 열람 저장
+     * @method POST
+     * @see /admin/board/board/view/{pk}
+     */
+    public function store(Request $request)
+    {
+        $rules = [
+            'board_id' => 'required|numeric|exists:tb_board,id',
+            'use_bookmark' => 'required|numeric|in:0,1',
+            'use_post_like' => 'required|numeric|in:0,1',
+            'use_post_dislike' => 'required|numeric|in:0,1',
+            'use_print' => 'required|numeric|in:0,1',
+            'use_sns' => 'required|numeric|in:0,1',
+            'use_prev_next_post' => 'required|numeric|in:0,1',
+            'use_blame' => 'required|numeric|in:0,1',
+            'blame_blind_count' => 'required|numeric',
+            'show_post_ip' => 'nullable|numeric',
+            'content_target_blank' => 'required|numeric|in:0,1',
+            'use_auto_url' => 'required|numeric|in:0,1',
+            'use_copy_post_url' => 'required|numeric|in:0,1',
+            'use_url_qrcode' => 'required|numeric|in:0,1',
+            'use_attached_url_qrcode' => 'required|numeric|in:0,1',
+            'need_like_for_download' => 'required|numeric|in:0,1',
+            'need_comment_for_download' => 'required|numeric|in:0,1',
+            'show_user_thumb_in_post' => 'required|numeric|in:0,1',
+            'show_user_icon_in_post' => 'required|numeric|in:0,1',
+            'use_post_user_regdate' => 'required|numeric|in:0,1',
+            'grp' => 'array',
+            'all' => 'array'
+        ];
+
+        $attributes = [
+            'board_id' => '게시판 PK',
+            'use_bookmark' => '즐겨찾기 기능',
+            'use_post_like' => '추천 기능',
+            'use_post_dislike' => '비추천 기능',
+            'use_print' => '본문 인쇄 기능',
+            'use_sns' => 'SNS 보내기 기능',
+            'use_prev_next_post' => '이전글, 다음글 버튼',
+            'use_blame' => '신고 기능',
+            'blame_blind_count' => '신고 시 숨김',
+            'show_post_ip' => 'IP 보이기',
+            'content_target_blank' => 'Link 새창',
+            'use_auto_url' => '본문 URL Link 생성',
+            'use_copy_post_url' => '주소 복사 버튼',
+            'use_url_qrcode' => '글 주소 QR 코드',
+            'use_attached_url_qrcode' => '첨부된 링크 QR 코드 ',
+            'need_like_for_download' => '다운로드 제한 (추천 필수)',
+            'need_comment_for_download' => '다운로드 제한 (댓글 필수)',
+            'show_user_thumb_in_post' => '회원 프로필 이미지',
+            'show_user_icon_in_post' => '회원 아이콘 이미지',
+            'use_post_user_regdate' => '회원 가입일',
+            'grp' => '그룹 적용',
+            'all' => '전체 적용'
+        ];
+
+        $posts = $this->validate($request, $rules, [], $attributes);
+        $boardID = $posts['board_id'];
+
+        $defaultData = [ // 그룹, 전체 적용 값
+            'use_bookmark', 'use_post_like', 'use_post_dislike', 'use_print', 'use_sns',
+            'use_prev_next_post', 'use_blame', 'blame_blind_count', 'show_post_ip',
+            'content_target_blank', 'use_auto_url', 'use_copy_post_url', 'use_url_qrcode',
+            'use_attached_url_qrcode', 'need_like_for_download', 'need_comment_for_download',
+            'show_user_thumb_in_post', 'show_user_icon_in_post', 'use_post_user_regdate'
+        ];
+
+        // 게시판 정보 조회
+        $boardData = $this->boardModel->find($posts['board_id']);
+
+        // 메타 정보 저장
+        $metaData = [
+            'use_bookmark' => $posts['use_bookmark'],
+            'use_post_like' => $posts['use_post_like'],
+            'use_post_dislike' => $posts['use_post_dislike'],
+            'use_print' => $posts['use_print'],
+            'use_sns' => $posts['use_sns'],
+            'use_prev_next_post' => $posts['use_prev_next_post'],
+            'use_blame' => $posts['use_blame'],
+            'blame_blind_count' => $posts['blame_blind_count'],
+            'show_post_ip' => $posts['show_post_ip'],
+            'content_target_blank' => $posts['content_target_blank'],
+            'use_auto_url' => $posts['use_auto_url'],
+            'use_copy_post_url' => $posts['use_copy_post_url'],
+            'use_url_qrcode' => $posts['use_url_qrcode'],
+            'use_attached_url_qrcode' => $posts['use_attached_url_qrcode'],
+            'need_like_for_download' => $posts['need_like_for_download'],
+            'need_comment_for_download' => $posts['need_comment_for_download'],
+            'show_user_thumb_in_post' => $posts['show_user_thumb_in_post'],
+            'show_user_icon_in_post' => $posts['show_user_icon_in_post'],
+            'use_post_user_regdate' => $posts['use_post_user_regdate']
+        ];
+
+        DB::beginTransaction();
+
+        try
+        {
+            $this->boardMetaModel->save($boardID, $metaData);
+
+            /*
+             * 그룹, 전체 적용
+             */
+            $groupData = []; // 그룹적용
+            $allData = []; // 전체적용
+
+            foreach ($defaultData as $field) {
+                if (isset($posts['grp'][$field])) {
+                    $groupData[$field] = $posts[$field];
+                }
+                if (isset($posts['all'][$field])) {
+                    $allData[$field] = $posts[$field];
+                }
+            }
+
+            if ($groupData) {
+                $brdGroupData = $this->boardModel->findByGroup($boardData->board_group_id);
+                foreach ($brdGroupData as $bKey => $bVal) {
+                    if ($bVal->id === $boardID) {
+                        continue;
+                    }
+                    $this->boardMetaModel->save($bVal->id, $groupData);
+                }
+            }
+            if ($allData) {
+                $brdAllData = $this->boardModel->all();
+                foreach ($brdAllData as $bKey => $bVal) {
+                    if ($bVal->id === $boardID) {
+                        continue;
+                    }
+                    $this->boardMetaModel->save($bVal->id, $allData);
+                }
+            }
+
+            $message = '게시판 - 열람 정보가 저장되었습니다.';
+            DB::commit();
+        } catch (Exception $e) {
+            $message = $e->getMessage();
+            DB::rollBack();
+        }
+
+        return redirect()->route('admin.board.board.view.show', $boardID)->with('message', $message);
+    }
+}

+ 197 - 0
app/Http/Controllers/Admin/Board/Board/WriteController.php

@@ -0,0 +1,197 @@
+<?php
+
+namespace App\Http\Controllers\Admin\Board\Board;
+
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+use App\Http\Controllers\Controller;
+use App\Models\Board;
+use App\Models\BoardMeta;
+use Exception;
+
+class WriteController extends Controller
+{
+    private Board $boardModel;
+    private BoardMeta $boardMetaModel;
+
+    public function __construct(Board $board, BoardMeta $boardMeta)
+    {
+        $this->boardModel = $board;
+        $this->boardMetaModel = $boardMeta;
+    }
+
+    /**
+     * 게시판 수정 - 작성
+     * @method GET
+     * @see /admin/board/board/write/{pk}
+     */
+    public function show(int $boardID)
+    {
+        return view('admin.board.board.write', [
+            'boardID' => $boardID,
+            'actionURL' => route('admin.board.board.write.store'),
+
+            // 게시판 정보
+            'boardData' => $this->boardModel->find($boardID),
+
+            // 게시판 메타 정보
+            'boardMetaData' => $this->boardMetaModel->getAllMeta($boardID),
+
+            // 모든 게시판
+            'boardAllData' => $this->boardModel->all()
+        ]);
+    }
+
+    /**
+     * 게시판 수정 - 작성 저장
+     * @method GET
+     * @see /admin/board/board/write/store
+     */
+    public function store(Request $request)
+    {
+        $rules = [
+            'board_id' => 'required|numeric|exists:tb_board,id',
+            'write_header_content' => 'string|nullable',
+            'mobile_write_header_content' => 'string|nullable',
+            'post_default_subject' => 'string|nullable',
+            'post_default_content' => 'string|nullable',
+            'use_post_dhtml' => 'required|numeric|in:0,1',
+            'save_external_image' => 'required|numeric|in:0,1',
+            'post_subject_min_length' => 'required|numeric|min:0',
+            'post_subject_max_length' => 'required|numeric|min:0|max:255',
+            'post_content_min_length' => 'required|numeric|min:0',
+            'post_content_max_length' => 'required|numeric|min:0|max:4000',
+            'use_category_required' => 'nullable|numeric|in:1,0',
+            'use_post_secret' => 'nullable|numeric|in:1,2',
+            'use_post_secret_selected' => 'required|numeric|in:0,1',
+            'use_post_tag' => 'required|numeric|in:0,1',
+            'link_num' => 'required|numeric|min:0|max:10',
+            'use_upload_file' => 'required|numeric|in:0,1',
+            'upload_file_num' => 'required|numeric|min:0|max:10',
+            'upload_file_max_size' => 'required|numeric',
+            'upload_only_img_file' => 'string|numeric|in:0,1',
+            'upload_file_extension' => 'string|nullable|max:255',
+            'use_only_one_post' => 'required|numeric|in:0,1',
+            'use_post_captcha' => 'required|numeric|in:0,1',
+            'grp' => 'array',
+            'all' => 'array'
+        ];
+
+        $attributes = [
+            'board_id' => '게시판 PK',
+            'write_header_content' => '작성폼 상단 내용',
+            'mobile_write_header_content' => '모바일 작성폼 상단 내용',
+            'post_default_subject' => '글쓰기 시 기본 제목',
+            'post_default_content' => '글쓰기 시 기본 내용',
+            'use_post_dhtml' => '본문 에디터 사용',
+            'save_external_image' => '외부 이미지 가져오기',
+            'post_subject_min_length' => '최소 글수 제한 (제목)',
+            'post_subject_max_length' => '최대 글수 제한 (제목)',
+            'post_content_min_length' => '최소 글수 제한 (내용)',
+            'post_content_max_length' => '최대 글수 제한 (내용)',
+            'use_category_required' => '분류 필수 선택',
+            'use_post_secret' => '비밀글 사용',
+            'use_post_secret_selected' => '비밀글 기본 선택',
+            'use_post_tag' => '태그 사용',
+            'link_num' => 'URL Link 개수',
+            'use_upload_file' => '첨부파일 사용',
+            'upload_file_num' => '첨부파일 개수 제한',
+            'upload_file_max_size' => '첨부파일 용량 제한',
+            'upload_only_img_file' => '첨부파일 이미지 허용',
+            'upload_file_extension' => '첨부파일 허용 확장자',
+            'use_only_one_post' => '하루에 한 글만 작성',
+            'use_post_captcha' => 'Captcha 사용',
+            'grp' => '그룹 적용',
+            'all' => '전체 적용'
+        ];
+
+        $posts = $this->validate($request, $rules, [], $attributes);
+        $boardID = $posts['board_id'];
+
+        $defaultData = [ // 그룹, 전체 적용 값
+            'write_header_content', 'mobile_write_header_content', 'post_default_subject', 'post_default_content',
+            'use_post_dhtml', 'save_external_image', 'post_subject_min_length', 'post_subject_max_length',
+            'post_content_min_length', 'post_content_max_length', 'use_category_required', 'use_post_secret', 'use_post_secret_selected',
+            'use_post_tag', 'link_num', 'use_upload_file', 'upload_file_num', 'upload_file_max_size',
+            'upload_only_img_file', 'upload_file_extension', 'use_only_one_post', 'use_post_captcha'
+        ];
+
+        // 게시판 정보 조회
+        $boardData = $this->boardModel->find($boardID);
+
+        // 메타 정보 저장
+        $metaData = [
+            'write_header_content' => $posts['write_header_content'],
+            'mobile_write_header_content' => $posts['mobile_write_header_content'],
+            'post_default_subject' => $posts['post_default_subject'],
+            'post_default_content' => $posts['post_default_content'],
+            'use_post_dhtml' => $posts['use_post_dhtml'],
+            'save_external_image' => $posts['save_external_image'],
+            'post_subject_min_length' => $posts['post_subject_min_length'],
+            'post_subject_max_length' => $posts['post_subject_max_length'],
+            'post_content_min_length' => $posts['post_content_min_length'],
+            'post_content_max_length' => $posts['post_content_max_length'],
+            'use_category_required' => $posts['use_category_required'],
+            'use_post_secret' => $posts['use_post_secret'],
+            'use_post_secret_selected' => $posts['use_post_secret_selected'],
+            'link_num' => $posts['link_num'],
+            'use_post_tag' => $posts['use_post_tag'],
+            'use_upload_file' => $posts['use_upload_file'],
+            'upload_file_num' => $posts['upload_file_num'],
+            'upload_file_max_size' => $posts['upload_file_max_size'],
+            'upload_only_img_file' => $posts['upload_only_img_file'],
+            'upload_file_extension' => $posts['upload_file_extension'],
+            'use_only_one_post' => $posts['use_only_one_post'],
+            'use_post_captcha' => $posts['use_post_captcha']
+        ];
+
+        DB::beginTransaction();
+
+        try
+        {
+            $this->boardMetaModel->save($boardID, $metaData);
+
+            /*
+             * 그룹, 전체 적용
+             */
+            $groupData = []; // 그룹적용
+            $allData = []; // 전체적용
+
+            foreach ($defaultData as $field) {
+                if (isset($posts['grp'][$field])) {
+                    $groupData[$field] = $posts[$field];
+                }
+                if (isset($posts['all'][$field])) {
+                    $allData[$field] = $posts[$field];
+                }
+            }
+
+            if ($groupData) {
+                $brdGroupData = $this->boardModel->findByGroup($boardData->board_group_id);
+                foreach ($brdGroupData as $bKey => $bVal) {
+                    if ($bVal->id === $boardID) {
+                        continue;
+                    }
+                    $this->boardMetaModel->save($bVal->id, $groupData);
+                }
+            }
+            if ($allData) {
+                $brdAllData = $this->boardModel->all();
+                foreach ($brdAllData as $bKey => $bVal) {
+                    if ($bVal->id === $boardID) {
+                        continue;
+                    }
+                    $this->boardMetaModel->save($bVal->id, $allData);
+                }
+            }
+
+            $message = '게시판 - 작성 정보가 저장되었습니다.';
+            DB::commit();
+        } catch (Exception $e) {
+            $message = $e->getMessage();
+            DB::rollBack();
+        }
+
+        return redirect()->route('admin.board.board.write.show', $boardID)->with('message', $message);
+    }
+}

+ 87 - 0
app/Http/Controllers/Admin/Board/CommentController.php

@@ -0,0 +1,87 @@
+<?php
+
+namespace App\Http\Controllers\Admin\Board;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Models\Board;
+use App\Models\Comment;
+use App\Models\DTO\SearchData;
+use Exception;
+
+class CommentController extends Controller
+{
+    private Board $boardModel;
+    private Comment $commentModel;
+
+    public function __construct(Board $board, Comment $comment)
+    {
+        $this->boardModel = $board;
+        $this->commentModel = $comment;
+    }
+
+    /**
+     * 게시판 관리 > 댓글 관리
+     * @method GET
+     * @see /admin/board/comment
+     */
+    public function index(Request $request)
+    {
+        $params = SearchData::fromRequest($request);
+        $params->boardID = $request->get('board_id');
+
+        $commentData = $this->commentModel->data($params);
+
+        if ($commentData->rows > 0) {
+            $num = listNum($commentData->total, $params->page, $params->perPage);
+            foreach ($commentData->list as $i => $row) {
+                $row->num = $num--;
+                $row->postURL = route('board.post.view', [$row->code, $row->post_id]);
+                $row->page = $row->findPageNumber($row->board_id, $row->post_id, $row->id);
+                $row->device = (MAP_DEVICE_ICON_TYPE[$row->device_type] ?? '');
+
+                $commentData->list[$i] = $row;
+            }
+        }
+
+        return view('admin.board.comment.index', [
+            'commentData' => $commentData,
+            'boardData' => $this->boardModel->all(),
+            'params' => $params
+        ]);
+    }
+
+    /**
+     * 게시판 관리 > 댓글 삭제
+     * @method DELETE
+     * @see /admin/board/comment/destroy
+     */
+    public function destroy(Request $request)
+    {
+        try {
+
+            $chk = $request->post('chk');
+
+            if ($chk) {
+
+                $user = $request->user();
+                foreach ($chk as $commentID) {
+                    $comment = $this->commentModel->findOrNew($commentID);
+
+                    if(!$comment->exists) {
+                        throw new Exception($commentID . '번 댓글은 존재하지 않습니다.');
+                    }
+
+                    if (!$this->commentModel->remove($comment, $user)) {
+                        throw new Exception($commentID . "번 댓글을 삭제할 수 없습니다.");
+                    }
+                }
+            }
+
+            $message = '댓글이 삭제되었습니다.';
+            return redirect()->route('admin.board.comment.index')->with('message', $message);
+        } catch (Exception $e) {
+            return back()->withErrors($e->getMessage())->withInput();
+        }
+    }
+}

+ 133 - 0
app/Http/Controllers/Admin/Board/File/DownloadController.php

@@ -0,0 +1,133 @@
+<?php
+
+namespace App\Http\Controllers\Admin\Board\File;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Http\Traits\AgentTrait;
+use App\Models\Board;
+use App\Models\PostFile;
+use App\Models\PostFileDownLog;
+use App\Models\DTO\SearchData;
+use Exception;
+
+class DownloadController extends Controller
+{
+    use AgentTrait;
+
+    private Board $boardModel;
+    private PostFile $postFileModel;
+    private PostFileDownLog $postFileDownloadLogModel;
+
+    public function __construct(Board $board, PostFile $postFile, PostFileDownLog $postFileDownLog)
+    {
+        $this->boardModel = $board;
+        $this->postFileModel = $postFile;
+        $this->postFileDownloadLogModel = $postFileDownLog;
+    }
+
+    /**
+     * 게시판 > 첨부파일 (다운로드)
+     * @method GET
+     * @see /admin/board/file/download
+     */
+    public function index(Request $request)
+    {
+        $params = SearchData::fromRequest($request);
+        $params->boardID = $request->get('board_id');
+
+        $postFileLogData = $this->postFileDownloadLogModel->data($params);
+
+        if ($postFileLogData->rows > 0) {
+            $num = listNum($postFileLogData->total, $params->page, $params->perPage);
+            foreach ($postFileLogData->list as $i => $row) {
+                $row->num = $num--;
+                $row->postURL = route('board.post.view', [$row->code, $row->post_id]);
+                $row->fileLink = route('admin.board.file.download.show', $row->post_file_id);
+                $row->editURL = route('admin.board.file.download.edit', $row->id);
+                $row->fileSize = byteFormat($row->file_size, 2);
+                $row->fileExists = file_exists($row->file_path);
+                $row->browser = $this->browser($row->user_agent);
+                $row->platform = $this->platform($row->user_agent);
+                $row->download = number_format($row->download);
+                $row->createdAt = dateBr($row->created_at);
+
+                $postFileLogData->list[$i] = $row;
+            }
+        }
+
+        return view('admin.board.file.download.index', [
+            'postFileLogData' => $postFileLogData,
+            'params' => $params,
+            'boardData' => $this->boardModel->all()
+        ]);
+    }
+
+    /**
+     * 게시판 > 첨부파일 (다운로드) 삭제
+     * @method DELETE
+     * @see /admin/board/file/download/destroy
+     */
+    public function destroy(Request $request)
+    {
+        try {
+
+            $chk = $request->post('chk');
+
+            if ($chk) {
+                foreach ($chk as $postFileDownLogID) {
+                    $postFileDownloadLog = $this->postFileDownloadLogModel->findOrNew($postFileDownLogID);
+
+                    if(!$postFileDownloadLog->exists) {
+                        throw new Exception($postFileDownLogID . '번 다운로드 이력은 존재하지 않습니다.');
+                    }
+
+                    if(!$postFileDownloadLog->delete()) {
+                        throw new Exception($postFileDownLogID . '번 다운로드 기록을 삭제할 수 없습니다.');
+                    }
+                }
+            }
+
+            $message = '다운로드 기록이 삭제되었습니다.';
+            return redirect()->route('admin.board.file.download.index')->with('message', $message);
+        } catch (Exception $e) {
+            return back()->withErrors($e->getMessage())->withInput();
+        }
+    }
+
+    /**
+     * 게시판 > 첨부파일(다운로드) 실행
+     * @method GET
+     * @see /admin/board/file/download/show/{pk}
+     */
+    public function show(int $postFileID)
+    {
+        $fileModel = $this->postFileModel->findOrFail($postFileID);
+
+        if(!file_exists($fileModel->file_path)) {
+            return back()->with('message', '해당 파일이 존재하지 않습니다.');
+        }
+
+        return response()->download($fileModel->file_path, $fileModel->origin_name);
+    }
+
+    /**
+     * 게시판 > 첨부파일(다운로드) 상세 보기
+     * @method GET
+     * @see /admin/board/file/download/{pk}/edit
+     */
+    public function edit(int $postFileDownLogID)
+    {
+        $log = $this->postFileDownloadLogModel->findOrFail($postFileDownLogID);
+        $log->device = $this->device($log->user_agent);
+        $log->platform = $this->platform($log->user_agent);
+        $log->browser = $this->browser($log->user_agent);
+        $log->fileLink = route('admin.board.file.download.show', $postFileDownLogID);
+        $log->fileSize = byteFormat($log->postFile->file_size, 2);
+        $log->download = number_format($log->postFile->download);
+
+        return view('admin.board.file.download.edit', [
+            'log' => $log
+        ]);
+    }
+}

+ 111 - 0
app/Http/Controllers/Admin/Board/File/UploadController.php

@@ -0,0 +1,111 @@
+<?php
+
+namespace App\Http\Controllers\Admin\Board\File;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Http\Traits\AgentTrait;
+use App\Models\Board;
+use App\Models\PostFile;
+use App\Models\DTO\SearchData;
+use Exception;
+
+class UploadController extends Controller
+{
+    use AgentTrait;
+
+    private Board $boardModel;
+    private PostFile $postFileModel;
+
+    public function __construct(Board $board, PostFile $postFile)
+    {
+        $this->boardModel = $board;
+        $this->postFileModel = $postFile;
+    }
+
+    /**
+     * 게시판 > 첨부파일(Upload)
+     * @method GET
+     * @see /admin/board/file/upload
+     */
+    public function index(Request $request)
+    {
+        $params = SearchData::fromRequest($request);
+        $params->boardID = $request->get('board_id');
+
+        $postFileData = $this->postFileModel->data($params);
+
+        if ($postFileData->rows > 0) {
+            $num = listNum($postFileData->total, $params->page, $params->perPage);
+            foreach ($postFileData->list as $i => $row) {
+                $row->num = $num--;
+                $row->postURL = route('board.post.view', [$row->code, $row->post_id]);
+                $row->fileLink = route('admin.board.file.download.show', $row->id);
+                $row->editURL = route('admin.board.file.upload.edit', $row->id);
+                $row->fileSize = byteFormat($row->file_size, 2);
+                $row->fileExists = file_exists($row->file_path);
+                $row->download = number_format($row->download);
+
+                $postFileData->list[$i] = $row;
+            }
+        }
+
+        return view('admin.board.file.upload.index', [
+            'postFileData' => $postFileData,
+            'params' => $params,
+            'boardData' => $this->boardModel->all()
+        ]);
+    }
+
+    /**
+     * 게시판 > 첨부파일(Upload) 삭제
+     * @method DELETE
+     * @see /admin/board/file/upload/destroy
+     */
+    public function destroy(Request $request)
+    {
+        try {
+
+            $chk = $request->post('chk');
+
+            if ($chk) {
+                foreach ($chk as $postFileID) {
+                    $postFile = $this->postFileModel->findOrNew($postFileID);
+
+                    if(!$postFile->exists) {
+                        throw new Exception($postFileID . '번 파일은 존재하지 않습니다.');
+                    }
+
+                    if(!$this->postFileModel->remove($postFileID)) {
+                        throw new Exception($postFileID . "번 파일을 삭제할 수 없습니다.");
+                    }
+                }
+            }
+
+            $message = '첨부파일이 삭제되었습니다.';
+            return redirect()->route('admin.board.file.upload.index')->with('message', $message);
+        } catch (Exception $e) {
+            return back()->withErrors($e->getMessage())->withInput();
+        }
+    }
+
+    /**
+     * 게시판 > 첨부파일(Upload) 상세 보기
+     * @method GET
+     * @see /admin/board/file/upload/{pk}/edit
+     */
+    public function edit(int $fileID)
+    {
+        $file = $this->postFileModel->findOrFail($fileID);
+        $file->device = $this->device($file->user_agent);
+        $file->platform = $this->platform($file->user_agent);
+        $file->browser = $this->browser($file->user_agent);
+        $file->fileLink = route('admin.board.file.download.show', $file->file_id);
+        $file->fileSize = byteFormat($file->file_size, 2);
+        $file->download = number_format($file->download);
+
+        return view('admin.board.file.upload.edit', [
+            'file' => $file
+        ]);
+    }
+}

+ 267 - 0
app/Http/Controllers/Admin/Board/Group/ListController.php

@@ -0,0 +1,267 @@
+<?php
+
+namespace App\Http\Controllers\Admin\Board\Group;
+
+use Illuminate\Http\Request;
+use Illuminate\Validation\Rule;
+use Illuminate\Support\Facades\Validator;
+use App\Http\Controllers\Controller;
+use App\Models\Board;
+use App\Models\BoardGroup;
+use App\Models\BoardGroupMeta;
+use App\Models\DTO\SearchData;
+use App\Models\DTO\ResponseData;
+use Exception;
+
+class ListController extends Controller
+{
+    private Board $boardModel;
+    private BoardGroup $boardGroupModel;
+    private BoardGroupMeta $boardGroupMetaModel;
+
+    public function __construct(Board $board, BoardGroup $boardGroup, BoardGroupMeta $boardGroupMeta)
+    {
+        $this->boardModel = $board;
+        $this->boardGroupModel = $boardGroup;
+        $this->boardGroupMetaModel = $boardGroupMeta;
+    }
+
+    /**
+     * 게시판 그룹 관리
+     * @method GET
+     * @see /admin/board/group/list
+     */
+    public function index(Request $request)
+    {
+        $params = SearchData::fromRequest($request);
+        $boardGroupData = $this->boardGroupModel->data($params);
+
+        if ($boardGroupData->rows > 0) {
+            $num = listNum($boardGroupData->total, $params->page, $params->perPage);
+            foreach ($boardGroupData->list as $i => $row) {
+                $row->num = $num--;
+                $row->boardRows = number_format($row->boardRows);
+                $row->postRows = number_format($row->postRows);
+                $row->commentRows = number_format($row->commentRows);
+                $row->createdAt = dateBr($row->created_at, '-');
+                $row->editURL = route('admin.board.group.list.edit', $row->id);
+
+                $boardGroupData->list[$i] = $row;
+            }
+        }
+
+        return view('admin.board.group.index', [
+            'boardGroupData' => $boardGroupData,
+            'params' => $params
+        ]);
+    }
+
+    /**
+     * 게시판 그룹 등록
+     * @method GET
+     * @see /admin/board/group/list/create
+     */
+    public function create()
+    {
+        return view('admin.board.group.write', [
+            'actionURL' => route('admin.board.group.list.store'),
+            'boardGroupData' => [],
+            'boardGroupMetaData' => [],
+            'boardGroupID' => null
+        ]);
+    }
+
+    /**
+     * 게시판 그룹 수정
+     * @method GET
+     * @see /admin/board/group/list/{pk}/edit
+     */
+    public function edit(int $boardGroupID)
+    {
+        return view('admin.board.group.write', [
+            'actionURL' => route('admin.board.group.list.update', $boardGroupID),
+            'boardGroupData' => $this->boardGroupModel->find($boardGroupID),
+            'boardGroupMetaData' => $this->boardGroupMetaModel->getAllMeta($boardGroupID),
+            'boardGroupID' => $boardGroupID
+        ]);
+    }
+
+    /**
+     * 게시판 그룹 등록 저장
+     * @method POST
+     * @see /admin/board/group/list
+     */
+    public function store(Request $request, ResponseData $response)
+    {
+        $rules = [
+            'code' => [
+                'required',
+                'string',
+                'min:3',
+                'max:50',
+                'alpha_dash',
+            ],
+            'name' => 'required|max:255',
+            'sort' => 'required|numeric',
+            'pc_header_content' => 'string|nullable',
+            'pc_footer_content' => 'string|nullable',
+            'mobile_header_content' => 'string|nullable',
+            'mobile_footer_content' => 'string|nullable'
+        ];
+
+        $attributes = [
+            'code' => '주소',
+            'name' => '제목',
+            'sort' => '순서',
+            'pc_header_content' => 'PC 상단 내용',
+            'pc_footer_content' => 'PC 하단 내용',
+            'mobile_header_content' => '모바일 상단 내용',
+            'mobile_footer_content' => '모바일 하단 내용'
+        ];
+
+        $this->validate($request, $rules, [], $attributes);
+
+        $result = $this->boardGroupModel->register($request, $response);
+
+        if(!$result->success) {
+            return back()->withErrors($result->message)->withInput();
+        }
+
+        $message = '게시판 그룹이 등록되었습니다.';
+        return redirect()->route('admin.board.group.list.index')->with('message', $message);
+    }
+
+    /**
+     * 게시판 그룹 수정 저장
+     * @method PUT
+     * @see /admin/board/group/list/{pk}
+     */
+    public function update(Request $request, ResponseData $response)
+    {
+        $boardGroupID = $request->post('board_group_id');
+
+        $rules = [
+            'board_group_id' => 'required|numeric|exists:tb_board_group,id',
+            'code' => [
+                'required',
+                'string',
+                'min:3',
+                'max:50',
+                'alpha_dash',
+                Rule::unique('tb_board_group', 'code')->ignore($boardGroupID, 'id')
+            ],
+            'name' => 'required|max:255',
+            'sort' => 'required|numeric',
+            'pc_header_content' => 'string|nullable',
+            'pc_footer_content' => 'string|nullable',
+            'mobile_header_content' => 'string|nullable',
+            'mobile_footer_content' => 'string|nullable'
+        ];
+
+        $attributes = [
+            'board_group_id' => 'PK',
+            'code' => '주소',
+            'name' => '제목',
+            'sort' => '순서',
+            'pc_header_content' => 'PC 상단 내용',
+            'pc_footer_content' => 'PC 하단 내용',
+            'mobile_header_content' => '모바일 상단 내용',
+            'mobile_footer_content' => '모바일 하단 내용'
+        ];
+
+        $this->validate($request, $rules, [], $attributes);
+
+        $result = $this->boardGroupModel->updater($boardGroupID, $request, $response);
+
+        if(!$result->success) {
+            return back()->withErrors($result->message)->withInput();
+        }
+
+        $message = '게시판 그룹이 수정되었습니다.';
+        return redirect()->route('admin.board.group.list.edit', $boardGroupID)->with('message', $message);
+    }
+
+    /**
+     * 게시판 그룹 수정
+     * @method POST
+     * @see /admin/board/group/list/listUpdate
+     */
+    public function listUpdate(Request $request)
+    {
+        $posts = $request->all();
+
+        if($posts['chk']) {
+            foreach($posts['chk'] as $boardGroupID) {
+                $updateData = [
+                    'code' => ($posts['code'][$boardGroupID] ?? ''),
+                    'name' => ($posts['name'][$boardGroupID] ?? ''),
+                    'sort' => ($posts['sort'][$boardGroupID] ?? ''),
+                ];
+
+                $rules = [
+                    'code' => [
+                        'required',
+                        'string',
+                        'min:3',
+                        'max:50',
+                        'alpha_dash',
+                        Rule::unique('tb_board_group', 'code')->ignore($boardGroupID, 'id')
+                    ],
+                    'name' => 'required|max:255',
+                    'sort' => 'required|numeric'
+                ];
+
+                $attributes = [
+                    'code' => 'CODE',
+                    'name' => '제목',
+                    'sort' => '순서'
+                ];
+
+                $validator = Validator::make($updateData, $rules, [], $attributes);
+                if($validator->fails()) {
+                    return back()->withErrors($validator)->withInput();
+                }
+
+                $this->boardGroupModel->find($boardGroupID)->update($updateData);
+            }
+        }
+
+        $message = '게시판 그룹 정보가 변경되었습니다.';
+        return redirect()->route('admin.board.group.list.index')->with('message', $message);
+    }
+
+    /**
+     * 게시판 그룹 삭제
+     * @method DELETE
+     * @see /admin/board/group/list/destroy
+     */
+    public function destroy(Request $request)
+    {
+        try {
+
+            $chk = $request->post('chk');
+
+            if ($chk) {
+                foreach ($chk as $boardGroupID) {
+
+                    $boardGroup = $this->boardGroupModel->findOrNew($boardGroupID);
+
+                    if (!$boardGroup->exists) {
+                        throw new Exception($boardGroupID . '번 게시판 그룹은 존재하지 않습니다.');
+                    }
+
+                    if ($boardGroup->board->count() > 0) {
+                        return back()->withErrors($boardGroupID . "번 게시판 그룹은 삭제할 수 없습니다.")->withInput();
+                    }
+
+                    $this->boardGroupModel->remove($boardGroupID);
+                }
+            }
+
+            $message = '게시판 그룹이 삭제되었습니다.';
+            return redirect()->route('admin.board.group.list.index')->with('message', $message);
+        } catch (Exception $e) {
+            return back()->withErrors($e->getMessage())->withInput();
+        }
+    }
+}

+ 101 - 0
app/Http/Controllers/Admin/Board/Group/ManagerController.php

@@ -0,0 +1,101 @@
+<?php
+
+namespace App\Http\Controllers\Admin\Board\Group;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Models\User;
+use App\Models\BoardGroupAdmin;
+
+class ManagerController extends Controller
+{
+    private BoardGroupAdmin $boardGroupAdminModel;
+    private User $userModel;
+
+    public function __construct(BoardGroupAdmin $boardGroupAdmin, User $user)
+    {
+        $this->boardGroupAdminModel = $boardGroupAdmin;
+        $this->userModel = $user;
+    }
+
+    /**
+     * 게시판 그룹 관리자
+     * @method GET
+     * @see /admin/board/group/manager
+     */
+    public function show(int $boardGroupID)
+    {
+        $boardGroupAdminData = $this->boardGroupAdminModel->data();
+
+        if ($boardGroupAdminData->rows > 0) {
+            foreach ($boardGroupAdminData->list as $i => $row) {
+                $boardGroupAdminData->list[$i] = $row;
+            }
+        }
+
+        return view('admin.board.group.manager', [
+            'actionURL' => route('admin.board.group.manager.store'),
+            'boardGroupAdminData' => $boardGroupAdminData,
+            'boardGroupID' => $boardGroupID
+        ]);
+    }
+
+    /**
+     * 게시판 그룹 관리자 등록 저장
+     * @method POST
+     * @see /admin/board/group/manager
+     */
+    public function store(Request $request)
+    {
+        $rules = [
+            'board_group_id' => 'required|numeric|exists:tb_board_group,id',
+            'sid' => 'required|exists:users,sid'
+        ];
+
+        $attributes = [
+            'board_group_id' => '게시판 그룹 PK',
+            'sid' => '회원 ID'
+        ];
+
+        $posts = $this->validate($request, $rules, [], $attributes);
+
+        $boardGroupID = $posts['board_group_id'];
+        $sid = $posts['sid'];
+        $userID = $this->userModel->where('sid', $sid)->value('id');
+
+        // 이미 등록된 관리자인지 확인
+        $isBoardAdmin = $this->boardGroupAdminModel->where('user_id', $userID)->count();
+        if($isBoardAdmin) {
+            return back()->withErrors(['sid' => "{$sid}는 이미 등록된 관리자 입니다."])->withInput();
+        }
+
+        $this->boardGroupAdminModel->insert([
+            'board_group_id' => $boardGroupID,
+            'user_id' => $userID,
+            'created_at' => now()
+        ]);
+
+        $message = '게시판 그룹 관리자가 새로 등록되었습니다.';
+        return redirect()->route('admin.board.group.manager.show', $boardGroupID)->with('message', $message);
+    }
+
+    /**
+     * 게시판 그룹 관리자 삭제
+     * @method DELETE
+     * @see /admin/board/group/manager/destroy
+     */
+    public function destroy(Request $request)
+    {
+        $boardGroupID = $request->post('board_group_id');
+        $chk = $request->post('chk');
+
+        if ($chk) {
+            foreach ($chk as $boardGroupAdminID) {
+                $this->boardGroupAdminModel->find($boardGroupAdminID)->delete();
+            }
+        }
+
+        $message = '게시판 그룹 관리자 정보가 삭제되었습니다.';
+        return redirect()->route('admin.board.group.manager.show', $boardGroupID)->with('message', $message);
+    }
+}

+ 83 - 0
app/Http/Controllers/Admin/Board/History/CommentController.php

@@ -0,0 +1,83 @@
+<?php
+
+namespace App\Http\Controllers\Admin\Board\History;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Models\Board;
+use App\Models\CommentHistory;
+use App\Models\DTO\SearchData;
+use Exception;
+
+class CommentController extends Controller
+{
+    private Board $boardModel;
+    private CommentHistory $commentHistoryModel;
+
+    public function __construct(Board $board, CommentHistory $commentHistory)
+    {
+        $this->boardModel = $board;
+        $this->commentHistoryModel = $commentHistory;
+    }
+
+    /**
+     * 게시판 > 댓글 변경 이력
+     * @method GET
+     * @see /admin/board/history/comment
+     */
+    public function index(Request $request)
+    {
+        $params = SearchData::fromRequest($request);
+        $params->boardID = $request->get('board_id');
+
+        $commentHistoryData = $this->commentHistoryModel->data($params);
+
+        if ($commentHistoryData->rows > 0) {
+            $num = listNum($commentHistoryData->total, $params->page, $params->perPage);
+            foreach ($commentHistoryData->list as $i => $row) {
+                $row->num = $num--;
+                $row->createdAt = dateBr($row->created_at, '-');
+
+                $commentHistoryData->list[$i] = $row;
+            }
+        }
+
+        return view('admin.board.history.comment', [
+            'commentHistoryData' => $commentHistoryData,
+            'params' => $params,
+            'boardData' => $this->boardModel->all()
+        ]);
+    }
+
+    /**
+     * 게시판 > 댓글 변경 이력 삭제
+     * @method DELETE
+     * @see /admin/board/history/comment/destroy
+     */
+    public function destroy(Request $request)
+    {
+        try {
+            $chk = $request->post('chk');
+
+            if ($chk) {
+                foreach ($chk as $i => $historyID) {
+
+                    $commentHistory = $this->commentHistoryModel->findOrNew($historyID);
+
+                    if(!$commentHistory->exists) {
+                        throw new Exception($i . "번 변경 이력이 존재하지 않습니다.");
+                    }
+
+                    if(!$commentHistory->delete()) {
+                        throw new Exception($i . "번 변경 이력을 삭제할 수 없습니다.");
+                    }
+                }
+            }
+
+            $message = '댓글 변경 이력이 삭제되었습니다.';
+            return redirect()->route('admin.board.history.comment.index')->with('message', $message);
+        } catch (Exception $e) {
+            return back()->withErrors($e->getMessage())->withInput();
+        }
+    }
+}

+ 83 - 0
app/Http/Controllers/Admin/Board/History/PostController.php

@@ -0,0 +1,83 @@
+<?php
+
+namespace App\Http\Controllers\Admin\Board\History;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Models\Board;
+use App\Models\PostHistory;
+use App\Models\DTO\SearchData;
+use Exception;
+
+class PostController extends Controller
+{
+    private Board $boardModel;
+    private PostHistory $postHistoryModel;
+
+    public function __construct(Board $board, PostHistory $postHistory)
+    {
+        $this->boardModel = $board;
+        $this->postHistoryModel = $postHistory;
+    }
+
+    /**
+     * 게시판 >  게시글 변경 이력
+     * @method GET
+     * @see /admin/board/history/post
+     */
+    public function index(Request $request)
+    {
+        $params = SearchData::fromRequest($request);
+        $params->boardID = $request->get('board_id');
+
+        $postHistoryData = $this->postHistoryModel->data($params);
+
+        if ($postHistoryData->rows > 0) {
+            $num = listNum($postHistoryData->total, $params->page, $params->perPage);
+            foreach ($postHistoryData->list as $i => $row) {
+                $row->num = $num--;
+                $row->createdAt = dateBr($row->created_at, '-');
+
+                $postHistoryData->list[$i] = $row;
+            }
+        }
+
+        return view('admin.board.history.post', [
+            'postHistoryData' => $postHistoryData,
+            'params' => $params,
+            'boardData' => $this->boardModel->all()
+        ]);
+    }
+
+    /**
+     * 게시판 > 게시글 변경 이력 삭제
+     * @method DELETE
+     * @see /admin/board/history/post/destroy
+     */
+    public function destroy(Request $request)
+    {
+        try {
+            $chk = $request->post('chk');
+
+            if ($chk) {
+                foreach ($chk as $i => $historyID) {
+
+                    $postHistory = $this->postHistoryModel->findOrNew($historyID);
+
+                    if (!$postHistory->exists) {
+                        throw new Exception($i . '번 변경 이력이 존재하지 않습니다.');
+                    }
+
+                    if (!$postHistory->delete()) {
+                        throw new Exception($i . "번 변경 이력을 삭제할 수 없습니다.");
+                    }
+                }
+            }
+
+            $message = '게시물 변경 이력이 삭제되었습니다.';
+            return redirect()->route('admin.board.history.post.index')->with('message', $message);
+        } catch (Exception $e) {
+            return back()->withErrors($e->getMessage())->withInput();
+        }
+    }
+}

+ 123 - 0
app/Http/Controllers/Admin/Board/ImageController.php

@@ -0,0 +1,123 @@
+<?php
+
+namespace App\Http\Controllers\Admin\Board;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Http\Traits\AgentTrait;
+use App\Models\Board;
+use App\Models\EditorImage;
+use App\Models\DTO\SearchData;
+use Exception;
+
+class ImageController extends Controller
+{
+    use AgentTrait;
+
+    private Board $boardModel;
+    private EditorImage $editorImageModel;
+
+    public function __construct(Board $board, EditorImage $editorImage)
+    {
+        $this->boardModel = $board;
+        $this->editorImageModel = $editorImage;
+    }
+
+    /**
+     * 게시판 > 이미지 관리
+     * @method GET
+     * @see /admin/board/image
+     */
+    public function index(Request $request)
+    {
+        $params = SearchData::fromRequest($request);
+        $editorImageData = $this->editorImageModel->data($params);
+
+        if ($editorImageData->rows > 0) {
+            $num = listNum($editorImageData->total, $params->page, $params->perPage);
+            foreach ($editorImageData->list as $i => $row) {
+                $row->num = $num--;
+                $row->fileExists = file_exists($row->file_path);
+                $row->targetStr = MAP_EDITOR_IMG_TYPE[$row->target_type];
+                $row->editURL = route('admin.board.image.edit', $row->id);
+                $row->fileLink = route('admin.board.image.show', $row->id);
+
+                $editorImageData->list[$i] = $row;
+            }
+        }
+
+        return view('admin.board.image.index', [
+            'editorImageData' => $editorImageData,
+            'params' => $params
+        ]);
+    }
+
+    /**
+     * 게시판 > 이미지 관리 삭제
+     * @method DELETE
+     * @see /admin/board/image/destroy
+     */
+    public function destroy(Request $request)
+    {
+        try {
+
+            $chk = $request->post('chk');
+            $fileName = $request->post('file_name');
+
+            if ($chk) {
+                foreach ($chk as $editorImageID) {
+                    $editorImage = $this->editorImageModel->findOrNew($editorImageID);
+
+                    if(!$editorImage->exists) {
+                        throw new Exception($editorImageID . '번 이미지는 존재하지 않습니다.');
+                    }
+
+                    if(!$this->editorImageModel->remove($editorImageID)) {
+                        throw new Exception($editorImageID . '번 ' . $fileName[$editorImageID] . " 파일을 삭제할 수 없습니다.");
+                    }
+                }
+            }
+
+            $message = '에디터 이미지가 삭제되었습니다.';
+            return redirect()->route('admin.board.image.index')->with('message', $message);
+        } catch (Exception $e) {
+            return back()->withErrors($e->getMessage())->withInput();
+        }
+    }
+
+    /**
+     * 게시판 > 이미지 관리 상세보기
+     * @method GET
+     * @see /admin/board/image/show/{pk}
+     */
+    public function show(int $editorImageID)
+    {
+        $imageModel = $this->editorImageModel->findOrFail($editorImageID);
+
+        if(!file_exists($imageModel->file_path)) {
+            return back()->with('message', '해당 파일이 존재하지 않습니다.');
+        }
+
+        return response()->download($imageModel->file_path, $imageModel->origin_name);
+    }
+
+    /**
+     * 게시판 > 이미지 정보 상세 보기
+     * @method GET
+     * @see /admin/board/image/{pk}/edit
+     */
+    public function edit(int $editorImageID)
+    {
+        $image = $this->editorImageModel->findOrFail($editorImageID);
+        $image->device = $this->device($image->user_agent);
+        $image->platform = $this->platform($image->user_agent);
+        $image->browser = $this->browser($image->user_agent);
+        $image->fileLink = route('admin.board.image.show', $image->id);
+        $image->fileSize = byteFormat($image->file_size, 2);
+        $image->download = number_format($image->download);
+
+        return view('admin.board.image.edit', [
+            'image' => $image
+        ]);
+    }
+}

+ 103 - 0
app/Http/Controllers/Admin/Board/Like/CommentController.php

@@ -0,0 +1,103 @@
+<?php
+
+namespace App\Http\Controllers\Admin\Board\Like;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Http\Traits\AgentTrait;
+use App\Models\Board;
+use App\Models\Comment;
+use App\Models\CommentLike;
+use App\Models\DTO\SearchData;
+
+class CommentController extends Controller
+{
+    use AgentTrait;
+
+    private Board $boardModel;
+    private CommentLike $commentLikeModel;
+
+    public function __construct(Board $board, CommentLike $commentLike)
+    {
+        $this->boardModel = $board;
+        $this->commentLikeModel = $commentLike;
+    }
+
+    /**
+     * 댓글 > 추천/비추 조회
+     * @method GET
+     * @see /admin/board/like/comment
+     */
+    public function index(Request $request)
+    {
+        $params = SearchData::fromRequest($request);
+        $params->boardID = $request->post('board_id');
+
+        $likeData = $this->commentLikeModel->data($params);
+
+        if ($likeData->rows > 0) {
+            $num = listNum($likeData->total, $params->page, $params->perPage);
+            foreach ($likeData->list as $i => $row) {
+                $row->num = $num--;
+                $row->status = ($row->type == 1 ? '추천' : '비추천');
+                $row->postURL = route('board.post.view', [$row->code, $row->post_id]);
+                $row->editURL = route('admin.board.like.comment.edit', $row->id);
+
+                $likeData->list[$i] = $row;
+            }
+        }
+
+        return view('admin.board.like.comment.index', [
+            'likeData' => $likeData,
+            'params' => $params,
+            'boardData' => $this->boardModel->all()
+        ]);
+    }
+
+    /**
+     * 댓글 > 추천/비추 삭제
+     * @method DELETE
+     * @see /admin/board/like/comment/destroy
+     */
+    public function destroy(Request $request, Comment $commentModel)
+    {
+        $chk = $request->post('chk');
+
+        if ($chk) {
+            foreach ($chk as $i => $likeID) {
+                $like = $this->commentLikeModel->find($likeID);
+
+                // 추천 수 감소
+                if($like->type == 1) {
+                    $commentModel->decreaseLike($like->comment_id);
+                }else{
+                    $commentModel->decreaseDisLike($like->comment_id);
+                }
+
+                if(!$like->delete()) {
+                    return back()->withErrors("{$i}번 추천/비추 기록을 삭제할 수 없습니다.")->withInput();
+                }
+            }
+        }
+
+        $message = '댓글 추천/비추 기록이 삭제되었습니다.';
+        return redirect()->route('admin.board.like.comment.index')->with('message', $message);
+    }
+
+    /**
+     * 댓글 > 추천/비추천 상세 보기
+     * @method GET
+     * @see /admin/board/like/{pk}/edit
+     */
+    public function edit(int $likeID)
+    {
+        $like = $this->commentLikeModel->findOrFail($likeID);
+        $like->device = $this->device($like->user_agent);
+        $like->platform = $this->platform($like->user_agent);
+        $like->browser = $this->browser($like->user_agent);
+
+        return view('admin.board.like.comment.edit', [
+            'like' => $like
+        ]);
+    }
+}

+ 112 - 0
app/Http/Controllers/Admin/Board/Like/PostController.php

@@ -0,0 +1,112 @@
+<?php
+
+namespace App\Http\Controllers\Admin\Board\Like;
+
+use Illuminate\Http\Request;
+use App\Http\Traits\AgentTrait;
+use App\Http\Controllers\Controller;
+use App\Models\Board;
+use App\Models\Post;
+use App\Models\PostLike;
+use App\Models\DTO\SearchData;
+use Exception;
+
+class PostController extends Controller
+{
+    use AgentTrait;
+
+    private Board $boardModel;
+    private PostLike $postLikeModel;
+
+    public function __construct(Board $board, PostLike $postLike)
+    {
+        $this->boardModel = $board;
+        $this->postLikeModel = $postLike;
+    }
+
+    /**
+     * 게시판 > 추천/비추 조회
+     * @method GET
+     * @see /admin/board/like/post
+     */
+    public function index(Request $request)
+    {
+        $params = SearchData::fromRequest($request);
+        $params->boardID = $request->post('board_id');
+
+        $likeData = $this->postLikeModel->data($params);
+
+        if ($likeData->rows > 0) {
+            $num = listNum($likeData->total, $params->page, $params->perPage);
+            foreach ($likeData->list as $i => $row) {
+                $row->num = $num--;
+                $row->postURL = route('board.post.view', [$row->code, $row->post_id]);
+                $row->editURL = route('admin.board.like.post.edit', $row->id);
+
+                $likeData->list[$i] = $row;
+            }
+        }
+
+        return view('admin.board.like.post.index', [
+            'likeData' => $likeData,
+            'params' => $params,
+            'boardData' => $this->boardModel->all()
+        ]);
+    }
+
+    /**
+     * 게시판 > 추천/비추 삭제
+     * @method DELETE
+     * @see /admin/board/like/post/destroy
+     */
+    public function destroy(Request $request, Post $postModel)
+    {
+        try {
+
+            $chk = $request->post('chk');
+
+            if ($chk) {
+                foreach ($chk as $postLikeID) {
+                    $postLike = $this->postLikeModel->findOrNew($postLikeID);
+
+                    if(!$postLike->exists) {
+                        throw new Exception($postLikeID . '번 추천/비추 기록이 존재하지 않습니다.');
+                    }
+
+                    if(!$postLike->delete()) {
+                        throw new Exception($postLikeID . '번 추천/비추 기록을 삭제할 수 없습니다.');
+                    }
+
+                    // 게시글 추천/비추천 개수 갱신
+                    if($postLike->type == 1) {
+                        $postModel->decreaseLike($postLike->post_id);
+                    }else{
+                        $postModel->decreaseDisLike($postLike->post_id);
+                    }
+                }
+            }
+
+            $message = '게시글 추천/비추 기록이 삭제되었습니다.';
+            return redirect()->route('admin.board.like.post.index')->with('message', $message);
+        } catch (Exception $e) {
+            return back()->withErrors($e->getMessage())->withInput();
+        }
+    }
+
+    /**
+     * 게시판 > 추천/비추천 상세 보기
+     * @method GET
+     * @see /admin/board/like/{pk}/edit
+     */
+    public function edit(int $postLikeID)
+    {
+        $like = $this->postLikeModel->findOrFail($postLikeID);
+        $like->device = $this->device($like->user_agent);
+        $like->platform = $this->platform($like->user_agent);
+        $like->browser = $this->browser($like->user_agent);
+
+        return view('admin.board.like.post.edit', [
+            'like' => $like
+        ]);
+    }
+}

+ 84 - 0
app/Http/Controllers/Admin/Board/Link/ListController.php

@@ -0,0 +1,84 @@
+<?php
+
+namespace App\Http\Controllers\Admin\Board\Link;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Models\Board;
+use App\Models\PostLink;
+use App\Models\DTO\SearchData;
+use Exception;
+
+class ListController extends Controller
+{
+    private Board $boardModel;
+    private PostLink $postLinkModel;
+
+    public function __construct(Board $board, PostLink $postLink)
+    {
+        $this->boardModel = $board;
+        $this->postLinkModel = $postLink;
+    }
+
+    /**
+     * 게시판 > URL 클릭 > 목록
+     * @method GET
+     * @see /admin/board/link/list
+     */
+    public function index(Request $request)
+    {
+        $params = SearchData::fromRequest($request);
+        $params->boardID = $request->get('board_id');
+
+        $postLinkData = $this->postLinkModel->data($params);
+
+        if ($postLinkData->rows > 0) {
+            $num = listNum($postLinkData->total, $params->page, $params->perPage);
+            foreach ($postLinkData->list as $i => $row) {
+                $row->num = $num--;
+                $row->hits = number_format($row->hits);
+                $row->postURL = route('board.post.view', [$row->code, $row->post_id]);
+
+                $postLinkData->list[$i] = $row;
+            }
+        }
+
+        return view('admin.board.link.list', [
+            'postLinkData' => $postLinkData,
+            'params' => $params,
+            'boardData' => $this->boardModel->all()
+        ]);
+    }
+
+    /**
+     * 게시판 > URL 클릭 > 목록 삭제
+     * @method DELETE
+     * @see /admin/board/link/list/destroy
+     */
+    public function destroy(Request $request)
+    {
+        try {
+
+            $chk = $request->post('chk');
+
+            if ($chk) {
+                foreach ($chk as $postLinkID) {
+                    $postLink = $this->postLinkModel->findOrNew($postLinkID);
+
+                    if(!$postLink->exists) {
+                        throw new Exception($postLinkID . '번 URL은 존재하지 않습니다.');
+                    }
+
+                    if(!$postLink->remove($postLinkID)) {
+                        throw new Exception($postLinkID . "번 URL은 삭제할 수 없습니다.");
+                    }
+                }
+            }
+
+            $message = 'URL이 삭제되었습니다.';
+            return redirect()->route('admin.board.link.list.index')->with('message', $message);
+        } catch (Exception $e) {
+            return back()->withErrors($e->getMessage())->withInput();
+        }
+    }
+}

+ 89 - 0
app/Http/Controllers/Admin/Board/Link/LogController.php

@@ -0,0 +1,89 @@
+<?php
+
+namespace App\Http\Controllers\Admin\Board\Link;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Http\Traits\AgentTrait;
+use App\Models\Board;
+use App\Models\PostLinkClickLog;
+use App\Models\DTO\SearchData;
+use Exception;
+
+class LogController extends Controller
+{
+    use AgentTrait;
+
+    private Board $boardModel;
+    private PostLinkClickLog $postLinkClickLogModel;
+
+    public function __construct(Board $board, PostLinkClickLog $postLinkClickLog)
+    {
+        $this->boardModel = $board;
+        $this->postLinkClickLogModel = $postLinkClickLog;
+    }
+
+    /**
+     * 게시판 > URL 클릭 > 기록
+     * @method GET
+     * @see /admin/board/link/log
+     */
+    public function index(Request $request)
+    {
+        $params = SearchData::fromRequest($request);
+        $params->boardID = $request->get('board_id');
+
+        $postLinkClickLogData = $this->postLinkClickLogModel->data($params);
+
+        if ($postLinkClickLogData->rows > 0) {
+            $num = listNum($postLinkClickLogData->total, $params->page, $params->perPage);
+            foreach ($postLinkClickLogData->list as $i => $row) {
+                $row->num = $num--;
+                $row->postURL = route('board.post.view', [$row->code, $row->post_id]);
+                $row->device = $this->device($row->user_agent);
+                $row->browser = $this->browser($row->user_agent);
+                $row->platform = $this->platform($row->user_agent);
+
+                $postLinkClickLogData->list[$i] = $row;
+            }
+        }
+
+        return view('admin.board.link.log', [
+            'postLinkClickLogData' => $postLinkClickLogData,
+            'params' => $params,
+            'boardData' => $this->boardModel->all()
+        ]);
+    }
+
+    /**
+     * 게시판 > URL 클릭 기록 삭제
+     * @method DELETE
+     * @see /admin/board/link/log/destroy
+     */
+    public function destroy(Request $request)
+    {
+        try {
+
+            $chk = $request->post('chk');
+
+            if ($chk) {
+                foreach ($chk as $postLinkClickLogID) {
+                    $postLinkClickLog = $this->postLinkClickLogModel->findOrNew($postLinkClickLogID);
+
+                    if(!$postLinkClickLog->exists) {
+                        throw new Exception($postLinkClickLogID . "번 URL 클릭 기록은 존재하지 않습니다.");
+                    }
+
+                    if(!$postLinkClickLog->delete()) {
+                        throw new Exception($postLinkClickLogID . '번 URL 클릭 기록을 삭제할 수 없습니다.');
+                    }
+                }
+            }
+
+            $message = 'URL 클릭 기록이 삭제되었습니다.';
+            return redirect()->route('admin.board.link.log.index')->with('message', $message);
+        } catch (Exception $e) {
+            return back()->withErrors($e->getMessage())->withInput();
+        }
+    }
+}

+ 93 - 0
app/Http/Controllers/Admin/Board/PostController.php

@@ -0,0 +1,93 @@
+<?php
+
+namespace App\Http\Controllers\Admin\Board;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Models\Board;
+use App\Models\BoardCategory;
+use App\Models\Post;
+use App\Models\DTO\SearchData;
+use Exception;
+
+class PostController extends Controller
+{
+    private Board $boardModel;
+    private BoardCategory $boardCategoryModel;
+    private Post $postModel;
+
+    public function __construct(Board $board, BoardCategory $boardCategory, Post $post)
+    {
+        $this->boardModel = $board;
+        $this->boardCategoryModel = $boardCategory;
+        $this->postModel = $post;
+    }
+
+    /**
+     * 게시판 > 게시글 관리
+     * @method GET
+     * @see /admin/board/post
+     */
+    public function index(Request $request)
+    {
+        $params = SearchData::fromRequest($request);
+        $params->boardID = $request->get('board_id');
+
+        $postData = $this->postModel->data($params);
+
+        if ($postData->rows > 0) {
+            $num = listNum($postData->total, $params->page, $params->perPage);
+            foreach ($postData->list as $i => $row) {
+                $row->num = $num--;
+                $row->postURL = route('board.post.view', [$row->code, $row->id]);
+                $row->hits = number_format($row->hit);
+                $row->hvFile = ($row->file_rows > 0);
+                $row->hvImage = ($row->image_rows > 0);
+                $row->device = MAP_DEVICE_ICON_TYPE[$row->device_type];
+                $row->secret = ($row->is_secret > 0);
+
+                $postData->list[$i] = $row;
+            }
+        }
+
+        return view('admin.board.post.index', [
+            'postData' => $postData,
+            'params' => $params,
+            'boardData' => $this->boardModel->all()
+        ]);
+    }
+
+    /**
+     * 게시글 관리 > 게시물 삭제
+     * @method DELETE
+     * @see /admin/board/post/destroy
+     */
+    public function destroy(Request $request)
+    {
+        try {
+
+            $chk = $request->post('chk');
+
+            if ($chk) {
+
+                $user = $request->user();
+                foreach ($chk as $postID) {
+                    $post = $this->postModel->findOrNew($postID);
+
+                    if(!$post->exists) {
+                        throw new Exception($postID . '번 게시물은 존재하지 않습니다.');
+                    }
+
+                    if (!$this->postModel->remove($post, $user)) {
+                        throw new Exception($postID . "번 게시물을 삭제할 수 없습니다.");
+                    }
+                }
+            }
+
+            $message = '게시물이 삭제되었습니다.';
+            return redirect()->route('admin.board.post.index')->with('message', $message);
+        } catch (Exception $e) {
+            return back()->withErrors($e->getMessage())->withInput();
+        }
+    }
+}

+ 87 - 0
app/Http/Controllers/Admin/Board/TagController.php

@@ -0,0 +1,87 @@
+<?php
+
+namespace App\Http\Controllers\Admin\Board;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Models\Board;
+use App\Models\Post;
+use App\Models\PostTag;
+use App\Models\DTO\SearchData;
+use Exception;
+
+class TagController extends Controller
+{
+    private Board $boardModel;
+    private PostTag $postTagModel;
+
+    public function __construct(Board $board, PostTag $postTag)
+    {
+        $this->boardModel = $board;
+        $this->postTagModel = $postTag;
+    }
+
+    /**
+     * 게시판 >  태그 관리
+     * @method GET
+     * @see /admin/board/tag
+     */
+    public function index(Request $request)
+    {
+        $params = SearchData::fromRequest($request);
+        $params->boardID = $request->get('board_id');
+
+        $postTagData = $this->postTagModel->data($params);
+
+        if ($postTagData->rows > 0) {
+            $num = listNum($postTagData->total, $params->page, $params->perPage);
+            foreach ($postTagData->list as $i => $row) {
+                $row->num = $num--;
+                $row->postURL = route('board.post.view', [$row->code, $row->post_id]);
+
+                $postTagData->list[$i] = $row;
+            }
+        }
+
+        return view('admin.board.tag.index', [
+            'postTagData' => $postTagData,
+            'params' => $params,
+            'boardData' => $this->boardModel->all()
+        ]);
+    }
+
+    /**
+     * 게시판 > 태그 삭제
+     * @method DELETE
+     * @see /admin/board/tag/destroy
+     */
+    public function destroy(Request $request, Post $postModel)
+    {
+        try {
+
+            $chk = $request->post('chk');
+
+            if ($chk) {
+                foreach ($chk as $tagID) {
+                    $postTag = $this->postTagModel->findOrNew($tagID);
+
+                    if (!$postTag->exists) {
+                        throw new Exception($tagID . '번 태그는 존재하지 않습니다.');
+                    }
+
+                    if (!$postTag->delete()) {
+                        throw new Exception($tagID . "번 태그를 삭제할 수 없습니다.");
+                    }
+
+                    // 게시글 태그 수 갱신
+                    $postModel->updateTagRows($postTag->post_id);
+                }
+            }
+
+            $message = '태그가 삭제되었습니다.';
+            return redirect()->route('admin.board.tag.index')->with('message', $message);
+        } catch (Exception $e) {
+            return back()->withErrors($e->getMessage())->withInput();
+        }
+    }
+}

+ 127 - 0
app/Http/Controllers/Admin/Board/Trash/CommentController.php

@@ -0,0 +1,127 @@
+<?php
+
+namespace App\Http\Controllers\Admin\Board\Trash;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Http\Traits\AgentTrait;
+use App\Models\Board;
+use App\Models\CommentDeleted;
+use App\Models\DTO\SearchData;
+
+class CommentController extends Controller
+{
+    use AgentTrait;
+
+    private Board $boardModel;
+    private CommentDeleted $commentDeletedModel;
+
+    public function __construct(Board $board, CommentDeleted $commentDeleted)
+    {
+        $this->boardModel = $board;
+        $this->commentDeletedModel = $commentDeleted;
+    }
+
+    /**
+     * 게시판 > 휴지통 관리(댓글)
+     * @method GET
+     * @see /admin/board/trash/comment
+     */
+    public function index(Request $request)
+    {
+        $params = SearchData::fromRequest($request);
+        $params->boardID = $request->get('board_id');
+
+        $commentDeletedData = $this->commentDeletedModel->data($params);
+
+        if ($commentDeletedData->rows > 0) {
+            $num = listNum($commentDeletedData->total, $params->page, $params->perPage);
+            foreach ($commentDeletedData->list as $i => $row) {
+                $row->num = $num--;
+                $row->editURL = route('admin.board.trash.comment.edit', $row->id);
+
+                $commentDeletedData->list[$i] = $row;
+            }
+        }
+
+        return view('admin.board.trash.comment.index', [
+            'commentDeletedData' => $commentDeletedData,
+            'params' => $params,
+            'boardData' => $this->boardModel->all()
+        ]);
+    }
+
+    /**
+     * 게시판 > 휴지통 관리(댓글) > 전체삭제
+     * @method GET
+     * @see /admin/board/trash/comment/truncate
+     */
+    public function truncate()
+    {
+        if(!$this->commentDeletedModel->truncate()) {
+            return back()->withErrors("휴지통 전체 삭제에 실패하였습니다.")->withInput();
+        }
+
+        $message = '휴지통에 댓글을 전체 삭제하였습니다.';
+        return redirect()->route('admin.board.trash.comment.index')->with('message', $message);
+    }
+
+    /**
+     * 게시판 > 휴지통 관리(댓글) > 선택삭제
+     * @method GET
+     * @see /admin/board/trash/comment/destroy
+     */
+    public function destroy(Request $request)
+    {
+        $chk = $request->post('chk');
+
+        if ($chk) {
+            foreach ($chk as $deletedID) {
+                if(!$this->commentDeletedModel->remove($deletedID)) {
+                    return back()->withErrors($deletedID . "번 댓글을 삭제할 수 없습니다.")->withInput();
+                }
+            }
+        }
+
+        $message = '댓글이 삭제되었습니다.';
+        return redirect()->route('admin.board.trash.comment.index')->with('message', $message);
+    }
+
+    /**
+     * 게시판 > 휴지통 관리(댓글) > 복원하기
+     * @method POST
+     * @see /admin/board/trash/comment/recovery
+     */
+    public function recovery(Request $request)
+    {
+        $chk = $request->post('chk');
+
+        if ($chk) {
+            foreach ($chk as $deletedID) {
+                if(!$this->commentDeletedModel->recovery($deletedID)) {
+                    return back()->withErrors($deletedID . "번 댓글을 복원할 수 없습니다.")->withInput();
+                }
+            }
+        }
+
+        $message = '댓글이 복원되었습니다.';
+        return redirect()->route('admin.board.trash.comment.index')->with('message', $message);
+    }
+
+    /**
+     * 게시판 > 휴지통 관리(댓글) > 상세보기
+     * @method GET
+     * @see /admin/board/trash/comment/{pk}/edit
+     */
+    public function edit(int $deletedID)
+    {
+        $deleted = $this->commentDeletedModel->findOrFail($deletedID);
+        $deleted->device = $this->device($deleted->user_agent);
+        $deleted->platform = $this->platform($deleted->user_agent);
+        $deleted->browser = $this->browser($deleted->user_agent);
+
+        return view('admin.board.trash.comment.edit', [
+            'deleted' => $deleted
+        ]);
+    }
+}

+ 127 - 0
app/Http/Controllers/Admin/Board/Trash/PostController.php

@@ -0,0 +1,127 @@
+<?php
+
+namespace App\Http\Controllers\Admin\Board\Trash;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Http\Traits\AgentTrait;
+use App\Models\Board;
+use App\Models\PostDeleted;
+use App\Models\DTO\SearchData;
+
+class PostController extends Controller
+{
+    use AgentTrait;
+
+    private Board $boardModel;
+    private PostDeleted $postDeletedModel;
+
+    public function __construct(Board $board, PostDeleted $postDeleted)
+    {
+        $this->boardModel = $board;
+        $this->postDeletedModel = $postDeleted;
+    }
+
+    /**
+     * 게시판 > 휴지통 관리 (게시글)
+     * @method GET
+     * @see /admin/board/trash/post
+     */
+    public function index(Request $request)
+    {
+        $params = SearchData::fromRequest($request);
+        $params->boardID = $request->get('board_id');
+
+        $postDeletedData = $this->postDeletedModel->data($params);
+
+        if ($postDeletedData->rows > 0) {
+            $num = listNum($postDeletedData->total, $params->page, $params->perPage);
+            foreach ($postDeletedData->list as $i => $row) {
+                $row->num = $num--;
+                $row->editURL = route('admin.board.trash.post.edit', $row->id);
+
+                $postDeletedData->list[$i] = $row;
+            }
+        }
+
+        return view('admin.board.trash.post.index', [
+            'postDeletedData' => $postDeletedData,
+            'params' => $params,
+            'boardData' => $this->boardModel->all()
+        ]);
+    }
+
+    /**
+     * 게시판 > 휴지통 관리(게시글) > 전체삭제
+     * @method GET
+     * @see /admin/board/trash/post/truncate
+     */
+    public function truncate()
+    {
+        if(!$this->postDeletedModel->truncate()) {
+            return back()->withErrors("휴지통 전체 삭제에 실패하였습니다.")->withInput();
+        }
+
+        $message = '휴지통에 게시글을 전체 삭제하였습니다.';
+        return redirect()->route('admin.board.trash.post.index')->with('message', $message);
+    }
+
+    /**
+     * 게시판 > 휴지통 관리(게시글) > 선택삭제
+     * @method GET
+     * @see /admin/board/trash/post/destroy
+     */
+    public function destroy(Request $request)
+    {
+        $chk = $request->post('chk');
+
+        if ($chk) {
+            foreach ($chk as $deletedID) {
+                if(!$this->postDeletedModel->remove($deletedID)) {
+                    return back()->withErrors($deletedID . "번 게시글을 삭제할 수 없습니다.")->withInput();
+                }
+            }
+        }
+
+        $message = '게시글이 삭제되었습니다.';
+        return redirect()->route('admin.board.trash.post.index')->with('message', $message);
+    }
+
+    /**
+     * 게시판 > 휴지통 관리(게시글) > 복원하기
+     * @method POST
+     * @see /admin/board/trash/post/recovery
+     */
+    public function recovery(Request $request)
+    {
+        $chk = $request->post('chk');
+
+        if ($chk) {
+            foreach ($chk as $deletedID) {
+                if(!$this->postDeletedModel->recovery($deletedID)) {
+                    return back()->withErrors($deletedID . "번 게시글을 복원할 수 없습니다.")->withInput();
+                }
+            }
+        }
+
+        $message = '게시글이 복원되었습니다.';
+        return redirect()->route('admin.board.trash.post.index')->with('message', $message);
+    }
+
+    /**
+     * 게시판 > 휴지통 관리(게시글) > 상세보기
+     * @method GET
+     * @see /admin/board/trash/post/{pk}/edit
+     */
+    public function edit(int $deletedID)
+    {
+        $deleted = $this->postDeletedModel->findOrFail($deletedID);
+        $deleted->device = $this->device($deleted->user_agent);
+        $deleted->platform = $this->platform($deleted->user_agent);
+        $deleted->browser = $this->browser($deleted->user_agent);
+
+        return view('admin.board.trash.post.edit', [
+            'deleted' => $deleted
+        ]);
+    }
+}

+ 104 - 0
app/Http/Controllers/Admin/Config/Form/EmailController.php

@@ -0,0 +1,104 @@
+<?php
+
+namespace App\Http\Controllers\Admin\Config\Form;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Models\Config;
+
+class EmailController extends Controller
+{
+    private Config $configModel;
+
+    public function __construct(Config $config)
+    {
+        $this->configModel = $config;
+    }
+
+    /**
+     * 이메일 양식
+     * @method GET
+     * @see /admin/config/form/email
+     */
+    public function index()
+    {
+        return view('admin.config.form.email', []);
+    }
+
+    /**
+     * 이메일 양식 저장
+     * @method POST
+     * @see /admin/config/form/email
+     */
+    public function store(Request $request)
+    {
+        $rules = [
+            'send_email_register_form_title' => 'string|nullable',
+            'send_email_register_form_content' => 'string|nullable',
+            'send_email_changepw_form_title' => 'string|nullable',
+            'send_email_changepw_form_content' => 'string|nullable',
+            'send_email_withdraw_form_title' => 'string|nullable',
+            'send_email_withdraw_form_content' => 'string|nullable',
+            'send_email_auth_form_title' => 'string|nullable',
+            'send_email_auth_form_content' => 'string|nullable',
+            'send_email_find_form_title' => 'string|nullable',
+            'send_email_find_form_content' => 'string|nullable',
+            'send_email_post_form_title' => 'string|nullable',
+            'send_email_post_form_content' => 'string|nullable',
+            'send_email_comment_form_title' => 'string|nullable',
+            'send_email_comment_form_content' => 'string|nullable',
+            'send_email_post_blame_form_title' => 'string|nullable',
+            'send_email_post_blame_form_content' => 'string|nullable',
+            'send_email_comment_blame_form_title' => 'string|nullable',
+            'send_email_comment_blame_form_content' => 'string|nullable',
+            'send_email_post_personal_form_title' => 'string|nullable',
+            'send_email_post_personal_form_content' => 'string|nullable',
+            'send_email_post_personal_reply_form_title' => 'string|nullable',
+            'send_email_post_personal_reply_form_content' => 'string|nullable'
+        ];
+
+        $attributes = [
+            'send_email_register_form_title' => '회원가입 - 제목',
+            'send_email_register_form_content' => '회원가입 - 내용',
+            'send_email_withdraw_form_title' => '회원탈퇴 - 제목',
+            'send_email_withdraw_form_content' => '회원탈퇴 - 내용',
+            'send_email_changepw_form_title' => '비밀번호 변경 - 제목',
+            'send_email_changepw_form_content' => '비밀번호 변경 - 내용',
+            'send_email_auth_form_title' => '이메일 인증',
+            'send_email_auth_form_content' => '이메일 인증',
+            'send_email_verify_code_form_title' => '인증번호 - 제목',
+            'send_email_verify_code_form_content' => '인증번호 - 내용',
+            'send_email_find_form_title' => '회원정보 찾기 - 제목',
+            'send_email_find_form_content' => '회원정보 찾기 - 내용',
+            'send_email_post_form_title' => '게시글 작성 - 제목',
+            'send_email_post_form_content' => '게시글 작성 - 내용',
+            'send_email_comment_form_title' => '댓글 작성 - 제목',
+            'send_email_comment_form_content' => '댓글 작성 - 내용',
+            'send_email_post_blame_form_title' => '게시글 신고 - 제목',
+            'send_email_post_blame_form_content' => '게시글 신고 - 내용',
+            'send_email_comment_blame_form_title' => '댓글 신고 - 제목',
+            'send_email_comment_blame_form_content' => '댓글 신고 - 내용',
+            'send_email_post_personal_form_title' => '1:1 문의 접수 - 제목',
+            'send_email_post_personal_form_content' => '1:1 문의 접수 - 내용',
+            'send_email_post_personal_reply_form_title' => '1:1 문의 답변 - 제목',
+            'send_email_post_personal_reply_form_content' => '1:1 문의 답변 - 내용'
+        ];
+
+        $posts = $this->validate($request, $rules, [], $attributes);
+
+        $this->configModel->save($posts, $attributes);
+
+        $message = '이메일 양식 정보가 저장되었습니다.';
+        return redirect()->route('admin.config.form.email.index')->with('message', $message);
+    }
+
+    /**
+     * 이메일 레이아웃 보기
+     * @method GET
+     * @see /admin/config/form/email/layout
+     */
+    public function layout()
+    {
+        return view('component.email');
+    }
+}

+ 52 - 0
app/Http/Controllers/Admin/Config/Form/TelegramController.php

@@ -0,0 +1,52 @@
+<?php
+
+namespace App\Http\Controllers\Admin\Config\Form;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Models\Config;
+
+class TelegramController extends Controller
+{
+    private Config $configModel;
+
+    public function __construct(Config $config)
+    {
+        $this->configModel = $config;
+    }
+
+    /**
+     * 텔레그램 양식
+     * @method GET
+     * @see /admin/config/form/telegram
+     */
+    public function index()
+    {
+        return view('admin.config.form.telegram', []);
+    }
+
+    /**
+     * 텔레그램 양식 저장
+     * @method POST
+     * @see /admin/config/form/telegram
+     */
+    public function store(Request $request)
+    {
+        $rules = [
+            'send_telegram_crawling_form_content' => 'string|nullable', // 영화 정보 수집 시
+            'send_telegram_daliy_visits_form_content' => 'string|nullable' // 일 방문 통계(24시간)
+        ];
+
+        $attributes = [
+            'send_telegram_crawling_form_content' => '영화 정보 수집 시',
+            'send_telegram_daliy_visits_form_content' => '일 방문 통계(24시간)'
+        ];
+
+        $posts = $this->validate($request, $rules, [], $attributes);
+
+        $this->configModel->save($posts);
+
+        $message = '텔레그램 양식 정보가 저장되었습니다.';
+        return redirect()->route('admin.config.form.telegram.index')->with('message', $message);
+    }
+}

+ 84 - 0
app/Http/Controllers/Admin/Config/Layout/LogoController.php

@@ -0,0 +1,84 @@
+<?php
+
+namespace App\Http\Controllers\Admin\Config\Layout;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Models\Config;
+use App\Models\FileLib;
+
+class LogoController extends Controller
+{
+    private Config $configModel;
+
+    public function __construct(Config $config)
+    {
+        $this->configModel = $config;
+    }
+
+    /**
+     * 로고
+     * @method GET
+     * @see /admin/config/layout/logo
+     */
+    public function index()
+    {
+        return view('admin.config.layout.logo', []);
+    }
+
+    /**
+     * 로고 저장
+     * @method POST
+     * @see /admin/config/layout/logo
+     */
+    public function store(Request $request, FileLib $fileLib)
+    {
+        $rules = [
+            'site_favicon' => 'mimes:ico|max:1192',
+            'site_logo' => 'mimes:jpg,jpeg,gif,png|max:2192'
+        ];
+
+        $attributes = [
+            'site_favicon' => '사이트 파비콘',
+            'site_logo' => '사이트 로고',
+        ];
+
+        $this->validate($request, $rules, [], $attributes);
+
+        // 파일 저장
+        $storage = (UPLOAD_PATH_STORAGE . DIRECTORY_SEPARATOR);
+        $updateData = [];
+        if($request->hasFile('site_favicon')) {
+            $siteFavicon = $request->file('site_favicon');
+            $siteFavicon->store(UPLOAD_PATH_PUBLIC . DIRECTORY_SEPARATOR . UPLOAD_PATH_FAVICON);
+            $updateData['site_favicon'] = ($storage . UPLOAD_PATH_LOGO . DIRECTORY_SEPARATOR . $siteFavicon->hashName());
+        }
+        if($request->hasFile('site_logo')) {
+            $siteLogo = $request->file('site_logo');
+            $siteLogo->store(UPLOAD_PATH_PUBLIC . DIRECTORY_SEPARATOR . UPLOAD_PATH_LOGO);
+            $updateData['site_logo'] = ($storage . UPLOAD_PATH_LOGO . DIRECTORY_SEPARATOR . $siteLogo->hashName());
+        }
+
+        // 파일 삭제
+        if($request->get('site_favicon_del')) {
+            $faviconPath = ($request->get('site_favicon_url'));
+            if(file_exists($faviconPath)) {
+                unlink($faviconPath);
+            }
+            $updateData['site_favicon'] = '';
+        }
+
+        if($request->get('site_logo_del')) {
+            $logoPath = $request->get('site_logo_url');
+            if(file_exists($logoPath)) {
+                unlink($logoPath);
+            }
+            $updateData['site_logo'] = '';
+        }
+
+        $this->configModel->save($updateData, $attributes);
+
+        $message = '로고 정보가 저장되었습니다.';
+        return redirect()->route('admin.config.layout.logo.index')->with('message', $message);
+    }
+}

+ 64 - 0
app/Http/Controllers/Admin/Config/Layout/MetaController.php

@@ -0,0 +1,64 @@
+<?php
+
+namespace App\Http\Controllers\Admin\Config\Layout;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Models\Config;
+
+class MetaController extends Controller
+{
+    private Config $configModel;
+
+    public function __construct(Config $config)
+    {
+        $this->configModel = $config;
+    }
+
+    /**
+     * 메타 태그
+     * @method GET
+     * @see /admin/config/layout/meta
+     */
+    public function index()
+    {
+        return view('admin.config.layout.meta', []);
+    }
+
+    /**
+     * 메타 태그 저장
+     * @method POST
+     * @see /admin/config/layout/meta
+     */
+    public function store(Request $request)
+    {
+        $rules = [
+            'meta_keywords' => 'string|nullable|max:500',
+            'meta_description' => 'string|nullable|max:500',
+            'meta_author' => 'string|nullable|max:500',
+            'meta_viewport' => 'string|nullable|max:500',
+            'meta_application_name' => 'string|nullable|max:500',
+            'meta_generator' => 'string|nullable|max:500',
+            'meta_robots' => 'string|nullable|max:500',
+            'meta_adds_info' => 'string|nullable|max:2000'
+        ];
+
+        $attributes = [
+            'meta_keywords' => 'Meta keywords',
+            'meta_description' => 'Meta description',
+            'meta_author' => 'Meta author',
+            'meta_viewport' => 'Meta viewport',
+            'meta_application_name' => 'Meta Application Name',
+            'meta_generator' => 'Meta Generator',
+            'meta_robots' => 'Meta Robots',
+            'meta_adds_info' => 'Meta Adds Info'
+        ];
+
+        $posts = $this->validate($request, $rules, [], $attributes);
+
+        $this->configModel->save($posts, $attributes);
+
+        $message = '메타 정보가 저장되었습니다.';
+        return redirect()->route('admin.config.layout.meta.index')->with('message', $message);
+    }
+}

+ 59 - 0
app/Http/Controllers/Admin/Config/OptimizeController.php

@@ -0,0 +1,59 @@
+<?php
+
+namespace App\Http\Controllers\Admin\Config;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Models\Config;
+use Artisan;
+
+class OptimizeController extends Controller
+{
+    private Config $configModel;
+
+    public function __construct(Config $config)
+    {
+        $this->configModel = $config;
+    }
+
+    /**
+     * 복구 / 최적화
+     * @method GET
+     * @see /admin/config/optimize
+     */
+    public function index()
+    {
+        return view('admin.config.optimize.index', []);
+    }
+
+    /**
+     * 1: Clear Application Cache
+     * 2: Clear Route Cache
+     * 3: Clear Configuration Cache
+     * 4: Clear Compiled Views Cache
+     * @method GET
+     * @see /admin/config/optimize
+     */
+    public function clear(Request $request)
+    {
+        $cmd = $request->segment(5);
+        switch($cmd) {
+            case 'appCache' :
+                $cmd = 'cache:clear';
+                break;
+            case 'routeCache' :
+                $cmd = 'route:clear';
+                break;
+            case 'configCache' :
+                $cmd = 'config:clear';
+                break;
+            case 'viewCache' :
+                $cmd = 'view:clear';
+                break;
+        }
+
+        Artisan::call($cmd);
+        $message = Artisan::output();
+        return redirect()->route('admin.config.optimize')->with('message', $message);
+    }
+}

+ 86 - 0
app/Http/Controllers/Admin/Config/Register/BasicController.php

@@ -0,0 +1,86 @@
+<?php
+
+namespace App\Http\Controllers\Admin\Config\Register;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Models\Config;
+
+class BasicController extends Controller
+{
+    private Config $configModel;
+
+    public function __construct(Config $config)
+    {
+        $this->configModel = $config;
+    }
+
+    /**
+     * 기본
+     * @method GET
+     * @see /admin/config/register/basic
+     */
+    public function index()
+    {
+        return view('admin.config.register.basic', []);
+    }
+
+    /**
+     * 기본 저장
+     * @method POST
+     * @see /admin/config/register/basic
+     */
+    public function store(Request $request)
+    {
+        $rules = [
+            'use_register_block' => 'numeric|nullable',
+            'use_register_email_auth' => 'numeric|nullable',
+            'password_min_length' => 'numeric|nullable',
+            'password_uppercase_length' => 'numeric|nullable',
+            'password_numbers_length' => 'numeric|nullable',
+            'password_specialchars_length' => 'numeric|nullable',
+            'use_user_thumb' => 'numeric|nullable',
+            'user_thumb_width' => 'numeric|nullable',
+            'user_thumb_height' => 'numeric|nullable',
+            'use_user_icon' => 'numeric|nullable',
+            'user_icon_width' => 'numeric|nullable',
+            'user_icon_height' => 'numeric|nullable',
+            'denied_nickname_list' => 'string|nullable',
+            'denied_userid_list' => 'string|nullable',
+            'denied_email_list' => 'string|nullable',
+            'user_register_policy1' => 'string|nullable',
+            'user_register_policy2' => 'string|nullable'
+        ];
+
+        $attributes = [
+            'use_register_block' => '회원가입 차단',
+            'use_register_email_auth' => '회원가입시 메일 인증 사용',
+            'password_min_length' => '비밀번호 보안수준 - 비밀번호 최소',
+            'password_uppercase_length' => '비밀번호 보안수준 - 대문자',
+            'password_numbers_length' => '비밀번호 보안수준 - 숫자',
+            'password_specialchars_length' => '비밀번호 보안수준 - 특수문자',
+            'use_user_thumb' => '회원 프로필 사진',
+            'user_thumb_width' => '회원 프로필 - 가로 길이',
+            'user_thumb_height' => '회원 프로필 - 세로 길이',
+            'use_user_icon' => '회원 아이콘',
+            'user_icon_width' => '회원 아이콘 - 가로 길이',
+            'user_icon_height' => '회원 아이콘 - 세로 길이',
+            'denied_nickname_list' => '금지 이름',
+            'denied_userid_list' => '금지 ID',
+            'denied_email_list' => '금지 E-mail',
+            'user_register_policy1' => '회원가입약관',
+            'user_register_policy2' => '개인정보취급방침'
+        ];
+
+        $posts = $this->validate($request, $rules, [], $attributes);
+        $posts['use_register_block'] ??= '0';
+        $posts['use_register_email_auth'] ??= '0';
+        $posts['use_user_thumb'] ??= '0';
+        $posts['use_user_icon'] ??= '0';
+
+        $this->configModel->save($posts, $attributes);
+
+        $message = '기본 정보가 저장되었습니다.';
+        return redirect()->route('admin.config.register.basic.index')->with('message', $message);
+    }
+}

+ 58 - 0
app/Http/Controllers/Admin/Config/Register/LoginController.php

@@ -0,0 +1,58 @@
+<?php
+
+namespace App\Http\Controllers\Admin\Config\Register;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Models\Config;
+
+class LoginController extends Controller
+{
+    private Config $configModel;
+
+    public function __construct(Config $config)
+    {
+        $this->configModel = $config;
+    }
+
+    /**
+     * 로그인
+     * @method GET
+     * @see /admin/config/register/login
+     */
+    public function index()
+    {
+        return view('admin.config.register.login', []);
+    }
+
+    /**
+     * 로그인 저장
+     * @method POST
+     * @see /admin/config/register/login
+     */
+    public function store(Request $request)
+    {
+        $rules = [
+            'change_password_day' => 'required|numeric|min:0|max:365',
+            'max_login_try_count' => 'required|numeric|min:0',
+            'max_login_try_limit_second' => 'required|numeric|min:0|max:86400',
+            'url_after_login' => 'string|nullable',
+            'url_after_logout' => 'string|nullable'
+        ];
+
+        $attributes = [
+            'change_password_day' => '비밀번호 갱신주기',
+            'max_login_try_count' => '로그인 시도 제한 횟수',
+            'max_login_try_limit_second' => '로그인 시도 제한시간',
+            'url_after_login' => '로그인 후 이동할 주소',
+            'url_after_logout' => '로그아웃 후 이동할 주소'
+        ];
+
+        $posts = $this->validate($request, $rules, [], $attributes);
+
+        $this->configModel->save($posts, $attributes);
+
+        $message = '정보 수정시 정보가 저장되었습니다.';
+        return redirect()->route('admin.config.register.login.index')->with('message', $message);
+    }
+}

+ 52 - 0
app/Http/Controllers/Admin/Config/Register/ModifyController.php

@@ -0,0 +1,52 @@
+<?php
+
+namespace App\Http\Controllers\Admin\Config\Register;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Models\Config;
+
+class ModifyController extends Controller
+{
+    private Config $configModel;
+
+    public function __construct(Config $config)
+    {
+        $this->configModel = $config;
+    }
+
+    /**
+     * 정보 수정시
+     * @method GET
+     * @see /admin/config/register/modify
+     */
+    public function index()
+    {
+        return view('admin.config.register.modify', []);
+    }
+
+    /**
+     * 정보 수정시 저장
+     * @method POST
+     * @see /admin/config/register/modify
+     */
+    public function store(Request $request)
+    {
+        $rules = [
+            'change_nickname_day' => 'required|numeric|min:0|max:365',
+            'change_email_day' => 'required|numeric|min:0|max:365'
+        ];
+
+        $attributes = [
+            'change_nickname_day' => '이름',
+            'change_email_day' => 'E-mail'
+        ];
+
+        $posts = $this->validate($request, $rules, [], $attributes);
+
+        $this->configModel->save($posts, $attributes);
+
+        $message = '정보 수정시 정보가 저장되었습니다.';
+        return redirect()->route('admin.config.register.modify.index')->with('message', $message);
+    }
+}

+ 84 - 0
app/Http/Controllers/Admin/Config/Register/NotifyController.php

@@ -0,0 +1,84 @@
+<?php
+
+namespace App\Http\Controllers\Admin\Config\Register;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Models\Config;
+
+class NotifyController extends Controller
+{
+    private Config $configModel;
+
+    public function __construct(Config $config)
+    {
+        $this->configModel = $config;
+    }
+
+    /**
+     * 알림
+     * @method GET
+     * @see /admin/config/register/notify
+     */
+    public function index()
+    {
+        return view('admin.config.register.notify', []);
+    }
+
+    /**
+     * 알림 저장
+     * @method POST
+     * @see /admin/config/register/notify
+     */
+    public function store(Request $request)
+    {
+        $rules = [
+            'send_email_register_admin' => 'numeric|nullable',
+            'send_email_register_user' => 'numeric|nullable',
+            'send_telegram_register_admin' => 'numeric|nullable',
+            'send_telegram_register_user' => 'numeric|nullable',
+            'send_email_changepw_admin' => 'numeric|nullable',
+            'send_email_changepw_user' => 'numeric|nullable',
+            'send_telegram_changepw_admin' => 'numeric|nullable',
+            'send_telegram_changepw_user' => 'numeric|nullable',
+            'send_email_withdraw_admin' => 'numeric|nullable',
+            'send_email_withdraw_user' => 'numeric|nullable',
+            'send_telegram_withdraw_admin' => 'numeric|nullable',
+            'send_telegram_withdraw_user' => 'numeric|nullable'
+        ];
+
+        $attributes = [
+            'send_email_register_admin' => '회원가입 시 - 최고관리자에게 이메일을 발송합니다.',
+            'send_email_register_user' => '회원가입 시 - 회원에게 이메일을 발송합니다.',
+            'send_telegram_register_admin' => '회원가입 시 - 최고관리자에게 텔레그램를 발송합니다.',
+            'send_telegram_register_user' => '회원가입 시 - 회원에게 텔레그램를 발송합니다.',
+            'send_email_changepw_admin' => '비밀번호 변경 시 - 최고관리자에게 이메일을 발송합니다.',
+            'send_email_changepw_user' => '비밀번호 변경 시 - 회원에게 이메일을 발송합니다.',
+            'send_telegram_changepw_admin' => '비밀번호 변경 시 - 최고관리자에게 텔레그램를 발송합니다.',
+            'send_telegram_changepw_user' => '비밀번호 변경 시 - 회원에게 텔레그램를 발송합니다.',
+            'send_email_withdraw_admin' => '회원탈퇴 시 - 최고관리자에게 이메일을 발송합니다.',
+            'send_email_withdraw_user' => '회원탈퇴 시 - 회원에게 이메일을 발송합니다.',
+            'send_telegram_withdraw_admin' => '회원탈퇴 시 - 최고관리자에게 텔레그램를 발송합니다.',
+            'send_telegram_withdraw_user' => '회원탈퇴 시 - 회원에게 텔레그램를 발송합니다.'
+        ];
+
+        $posts = $this->validate($request, $rules, [], $attributes);
+        $posts['send_email_register_admin'] ??= '0';
+        $posts['send_email_register_user'] ??= '0';
+        $posts['send_telegram_register_admin'] ??= '0';
+        $posts['send_telegram_register_user'] ??= '0';
+        $posts['send_email_changepw_admin'] ??= '0';
+        $posts['send_email_changepw_user'] ??= '0';
+        $posts['send_telegram_changepw_admin'] ??= '0';
+        $posts['send_telegram_changepw_user'] ??= '0';
+        $posts['send_email_withdraw_admin'] ??= '0';
+        $posts['send_email_withdraw_user'] ??= '0';
+        $posts['send_telegram_withdraw_admin'] ??= '0';
+        $posts['send_telegram_withdraw_user'] ??= '0';
+
+        $this->configModel->save($posts, $attributes);
+
+        $message = '알림 정보가 저장되었습니다.';
+        return redirect()->route('admin.config.register.notify.index')->with('message', $message);
+    }
+}

+ 359 - 0
app/Http/Controllers/Admin/Config/ServerController.php

@@ -0,0 +1,359 @@
+<?php
+
+namespace App\Http\Controllers\Admin\Config;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+
+class ServerController extends Controller
+{
+    public function index()
+    {
+        $this->server();
+    }
+
+    /**
+     * 서버 상태
+     * @method GET
+     * @see /admin/config/server
+     */
+    public function server()
+    {
+        // 서버 자원 정보
+        return view('admin.config.server.index', [
+            'subject' => '서버상태',
+            'networkInfo' => $this->getNetworkInfo(),
+            'memInfo' => $this->getMemoryInfo(),
+            'cpuInfo' => $this->getCpuInfo(),
+            'sysInfo' => $this->getSysInfo(),
+            'os' => explode(" ", php_uname()) // 서버 운영체제 정보
+        ]);
+    }
+
+    /**
+     * 관리자 페이지 메인 ajax 호출
+     */
+    public function ajaxData(Request $request)
+    {
+        if (!$request->ajax()) {
+            exit('No direct script access allowed');
+        }
+
+        $sysInfo = $this->getSysInfo();
+
+        echo json_encode([
+            'networkInfo' => $this->getNetworkInfo(),
+            'sysInfo' => $sysInfo,
+            'upTime' => $sysInfo['uptime'],
+            'sTime' => date('Y-m-d H:i:s'),
+            'cpuInfo' => $this->getCpuInfo(),
+            'memInfo' => $this->getMemoryInfo()
+        ]);
+    }
+
+    /**
+     * 운영체제 및 서버 시스템 정보
+     */
+    private function getSysInfo()
+    {
+        $sysInfo = [null];
+        switch (PHP_OS) {
+            case "Linux":
+
+                // CPU
+                if (false === ($str = @file("/proc/cpuinfo"))) {
+                    return false;
+                }
+                $str = implode("", $str);
+
+                @preg_match_all("/model\s+name\s{0,}\:+\s{0,}([\w\s\)\(\@.-]+)([\r\n]+)/s", $str, $model);
+                @preg_match_all("/cpu\s+MHz\s{0,}\:+\s{0,}([\d\.]+)[\r\n]+/", $str, $mhz);
+                @preg_match_all("/cache\s+size\s{0,}\:+\s{0,}([\d\.]+\s{0,}[A-Z]+[\r\n]+)/", $str, $cache);
+                @preg_match_all("/bogomips\s{0,}\:+\s{0,}([\d\.]+)[\r\n]+/", $str, $bogomips);
+
+                if (false !== is_array($model[1])) {
+                    $sysInfo['cpu']['num'] = sizeof($model[1]);
+
+                    for ($i = 0; $i < $sysInfo['cpu']['num']; $i++) {
+                        $sysInfo['cpu']['model'][] = $model[1][$i] . '&nbsp;(' . $mhz[1][$i] . ')';
+                        $sysInfo['cpu']['mhz'][] = $mhz[1][$i];
+                        $sysInfo['cpu']['cache'][] = $cache[1][$i];
+                        $sysInfo['cpu']['bogomips'][] = $bogomips[1][$i];
+                    }
+
+                    if ($sysInfo['cpu']['num'] == 1) {
+                        $x1 = '';
+                    } else {
+                        $x1 = ' Г—' . $sysInfo['cpu']['num'];
+                    }
+
+                    $mhz[1][0] = ' | Frequency:' . $mhz[1][0];
+                    $cache[1][0] = ' | Secondary cache:' . $cache[1][0];
+                    $bogomips[1][0] = ' | Bogomips:' . $bogomips[1][0];
+                    $sysInfo['cpu']['model'][] = $model[1][0] . $mhz[1][0] . $cache[1][0] . $bogomips[1][0] . $x1;
+                    if (false !== is_array($sysInfo['cpu']['model'])) $sysInfo['cpu']['model'] = implode("<br />", $sysInfo['cpu']['model']);
+                    if (false !== is_array($sysInfo['cpu']['mhz'])) $sysInfo['cpu']['mhz'] = implode("<br />", $sysInfo['cpu']['mhz']);
+                    if (false !== is_array($sysInfo['cpu']['cache'])) $sysInfo['cpu']['cache'] = implode("<br />", $sysInfo['cpu']['cache']);
+                    if (false !== is_array($sysInfo['cpu']['bogomips'])) $sysInfo['cpu']['bogomips'] = implode("<br />", $sysInfo['cpu']['bogomips']);
+                }
+
+                // NETWORK, UPTIME
+                if (false === ($str = @file("/proc/uptime"))) {
+                    return false;
+                }
+                $str = explode(" ", implode("", $str));
+                $str = trim($str[0]);
+                $min = $str / 60;
+                $hours = $min / 60;
+                $days = floor($hours / 24);
+                $hours = floor($hours - ($days * 24));
+                $min = floor($min - ($days * 60 * 24) - ($hours * 60));
+                if ($days !== 0) $sysInfo['uptime'] = $days . " Day";
+                if ($hours !== 0) $sysInfo['uptime'] .= $hours . " Hour";
+                $sysInfo['uptime'] .= $min . " Minute";
+
+                // MEMORY
+                if (false === ($str = @file("/proc/meminfo"))) {
+                    return false;
+                }
+                $str = implode("", $str);
+                preg_match_all("/MemTotal\s{0,}\:+\s{0,}([\d\.]+).+?MemFree\s{0,}\:+\s{0,}([\d\.]+).+?Cached\s{0,}\:+\s{0,}([\d\.]+).+?SwapTotal\s{0,}\:+\s{0,}([\d\.]+).+?SwapFree\s{0,}\:+\s{0,}([\d\.]+)/s", $str, $buf);
+                preg_match_all("/Buffers\s{0,}\:+\s{0,}([\d\.]+)/s", $str, $buffers);
+                $sysInfo['memTotal'] = round($buf[1][0] / 1024, 2);
+                $sysInfo['memFree'] = round($buf[2][0] / 1024, 2);
+                $sysInfo['memBuffers'] = round($buffers[1][0] / 1024, 2);
+                $sysInfo['memCached'] = round($buf[3][0] / 1024, 2);
+                $sysInfo['memUsed'] = $sysInfo['memTotal'] - $sysInfo['memFree'];
+                $sysInfo['memPercent'] = (floatval($sysInfo['memTotal']) != 0) ? round($sysInfo['memUsed'] / $sysInfo['memTotal'] * 100, 2) : 0;
+                $sysInfo['memRealUsed'] = $sysInfo['memTotal'] - $sysInfo['memFree'] - $sysInfo['memCached'] - $sysInfo['memBuffers'];
+                $sysInfo['memRealFree'] = $sysInfo['memTotal'] - $sysInfo['memRealUsed'];
+                $sysInfo['memRealPercent'] = (floatval($sysInfo['memTotal']) != 0) ? round($sysInfo['memRealUsed'] / $sysInfo['memTotal'] * 100, 2) : 0;
+                $sysInfo['memCachedPercent'] = (floatval($sysInfo['memCached']) != 0) ? round($sysInfo['memCached'] / $sysInfo['memTotal'] * 100, 2) : 0;
+                $sysInfo['swapTotal'] = round($buf[4][0] / 1024, 2);
+                $sysInfo['swapFree'] = round($buf[5][0] / 1024, 2);
+                $sysInfo['swapUsed'] = round($sysInfo['swapTotal'] - $sysInfo['swapFree'], 2);
+                $sysInfo['swapPercent'] = (floatval($sysInfo['swapTotal']) != 0) ? round($sysInfo['swapUsed'] / $sysInfo['swapTotal'] * 100, 2) : 0;
+
+                // LOAD AVG
+                if (false === ($str = @file("/proc/loadavg"))) {
+                    return false;
+                }
+                $str = explode(" ", implode("", $str));
+                $str = array_chunk($str, 4);
+                $sysInfo['loadAvg'] = implode(" ", $str[0]);
+                break;
+
+            case "FreeBSD":
+
+                // CPU
+                if (false === ($sysInfo['cpu']['num'] = getKey("hw.ncpu"))) {
+                    return false;
+                }
+                $sysInfo['cpu']['model'] = getKey("hw.model");
+
+                // LOAD AVG
+                if (false === ($sysInfo['loadAvg'] = getKey("vm.loadavg"))) {
+                    return false;
+                }
+
+                // UPTIME
+                if (false === ($buf = getKey("kern.boottime"))) {
+                    return false;
+                }
+                $buf = explode(' ', $buf);
+                $sys_ticks = time() - intval($buf[3]);
+                $min = $sys_ticks / 60;
+                $hours = $min / 60;
+                $days = floor($hours / 24);
+                $hours = floor($hours - ($days * 24));
+                $min = floor($min - ($days * 60 * 24) - ($hours * 60));
+                if ($days !== 0) $sysInfo['uptime'] = $days . " Day";
+                if ($hours !== 0) $sysInfo['uptime'] .= $hours . " Hour";
+                $sysInfo['uptime'] .= $min . " Minute";
+
+                // MEMORY
+                if (false === ($buf = getKey("hw.physmem"))) {
+                    return false;
+                }
+
+                $sysInfo['memTotal'] = round($buf / 1024 / 1024, 2);
+                $str = getKey("vm.vmtotal");
+                preg_match_all("/\nVirtual Memory[\:\s]*\(Total[\:\s]*([\d]+)K[\,\s]*Active[\:\s]*([\d]+)K\)\n/i", $str, $buff, PREG_SET_ORDER);
+                preg_match_all("/\nReal Memory[\:\s]*\(Total[\:\s]*([\d]+)K[\,\s]*Active[\:\s]*([\d]+)K\)\n/i", $str, $buf, PREG_SET_ORDER);
+                $sysInfo['memRealUsed'] = round($buf[0][2] / 1024, 2);
+                $sysInfo['memCached'] = round($buff[0][2] / 1024, 2);
+                $sysInfo['memUsed'] = round($buf[0][1] / 1024, 2) + $sysInfo['memCached'];
+                $sysInfo['memFree'] = $sysInfo['memTotal'] - $sysInfo['memUsed'];
+                $sysInfo['memPercent'] = (floatval($sysInfo['memTotal']) != 0) ? round($sysInfo['memUsed'] / $sysInfo['memTotal'] * 100, 2) : 0;
+                $sysInfo['memRealPercent'] = (floatval($sysInfo['memTotal']) != 0) ? round($sysInfo['memRealUsed'] / $sysInfo['memTotal'] * 100, 2) : 0;
+                break;
+
+            default:
+                break;
+        }
+
+        if(in_array(PHP_OS, ['Linux', 'FreeBSD'])) {
+            if ($sysInfo['memTotal'] < 1024) {
+                $memTotal = $sysInfo['memTotal'] . " M";
+                $mt = $sysInfo['memTotal'] . " M";
+                $mu = $sysInfo['memUsed'] . " M";
+                $mf = $sysInfo['memFree'] . " M";
+                $mc = $sysInfo['memCached'] . " M";
+                $mb = $sysInfo['memBuffers'] . " M";
+                $st = $sysInfo['swapTotal'] . " M";
+                $su = $sysInfo['swapUsed'] . " M";
+                $sf = $sysInfo['swapFree'] . " M";
+                $swapPercent = ($sysInfo['swapPercent']);
+                $memRealUsed = $sysInfo['memRealUsed'] . " M";
+                $memRealFree = $sysInfo['memRealFree'] . " M";
+                $memRealPercent = ($sysInfo['memRealPercent']);
+                $memPercent = ($sysInfo['memPercent']);
+                $memCachedPercent = ($sysInfo['memCachedPercent']);
+            } else {
+                $memTotal = round($sysInfo['memTotal'] / 1024, 3) . " G";
+                $mt = round($sysInfo['memTotal'] / 1024, 3) . " G";
+                $mu = round($sysInfo['memUsed'] / 1024, 3) . " G";
+                $mf = round($sysInfo['memFree'] / 1024, 3) . " G";
+                $mc = round($sysInfo['memCached'] / 1024, 3) . " G";
+                $mb = round($sysInfo['memBuffers'] / 1024, 3) . " G";
+                $st = round($sysInfo['swapTotal'] / 1024, 3) . " G";
+                $su = round($sysInfo['swapUsed'] / 1024, 3) . " G";
+                $sf = round($sysInfo['swapFree'] / 1024, 3) . " G";
+                $swapPercent = ($sysInfo['swapPercent']);
+                $memRealUsed = round($sysInfo['memRealUsed'] / 1024, 3) . " G";
+                $memRealFree = round($sysInfo['memRealFree'] / 1024, 3) . " G";
+                $memRealPercent = ($sysInfo['memRealPercent']);
+                $memPercent = ($sysInfo['memPercent']);
+                $memCachedPercent = ($sysInfo['memCachedPercent']);
+            }
+
+            $sysInfo = array_merge($sysInfo, [
+                'memTotal' => $memTotal,
+                'mt' => $mt,
+                'mu' => $mu,
+                'mf' => $mf,
+                'mc' => $mc,
+                'mb' => $mb,
+                'st' => $st,
+                'su' => $su,
+                'sf' => $sf,
+                'swapPercent' => $swapPercent,
+                'memRealUsed' => $memRealUsed,
+                'memRealFree' => $memRealFree,
+                'memRealPercent' => $memRealPercent,
+                'memPercent' => $memPercent,
+                'memCachedPercent' => $memCachedPercent
+            ]);
+        }
+
+        return $sysInfo;
+    }
+
+    /**
+     * CPU 상세정보
+     */
+    private function getCpuInfo(): string
+    {
+        $stat1 = GetCoreInformation();
+        sleep(1);
+        $stat2 = GetCoreInformation();
+        $data = GetCpuPercentages($stat1, $stat2);
+
+        $ret = '';
+        if(isset($data['cpu0']['user'])) {
+            $ret .= $data['cpu0']['user'] . "%us,  ";
+        }
+        if(isset($data['cpu0']['sys'])) {
+            $ret .= $data['cpu0']['sys'] . "%sy,  ";
+        }
+        if(isset($data['cpu0']['nice'])) {
+            $ret .= $data['cpu0']['nice'] . "%ni, ";
+        }
+        if(isset($data['cpu0']['idle'])) {
+            $ret .= $data['cpu0']['idle'] . "%id,  ";
+        }
+        if(isset($data['cpu0']['iowait'])) {
+            $ret .= $data['cpu0']['iowait'] . "%wa,  ";
+        }
+        if(isset($data['cpu0']['irq'])) {
+            $ret .= $data['cpu0']['irq'] . "%irq,  ";
+        }
+        if(isset($data['cpu0']['softirq'])) {
+            $ret .= $data['cpu0']['softirq'] . "%softirq";
+        }
+
+        return $ret;
+    }
+
+    /**
+     * 사용 메모리 정보
+     */
+    private function getMemoryInfo(): array
+    {
+        $dt = round(@disk_total_space(".") / (1024 * 1024 * 1024), 3);
+        $df = round(@disk_free_space(".") / (1024 * 1024 * 1024), 3);
+        $du = ($dt - $df); //е·Із”Ё
+        $hdPercent = ((floatval($dt) != 0) ? round($du / $dt * 100, 2) : 0);
+
+        return ['dt' => $dt, 'df' => $df, 'du' => $du, 'hdPercent' => $hdPercent];
+    }
+
+    /**
+     * 네트워크 정보 조회
+     */
+    private function getNetworkInfo(): array
+    {
+        // 네트워크 정보 조회
+        $networkResult = [];
+        $networkData = @file("/proc/net/dev");
+
+        if($networkData) {
+            for ($i = 2; $i < count($networkData); $i++) {
+                preg_match_all("/([^\s]+):[\s]{0,}(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/", $networkData[$i], $info);
+                $networkResult[$i - 2] = [
+                    'networkName' => $info[1][0],
+                    'NetOutSpeed' => $info[10][0],
+                    'NetInputSpeed' => $info[2][0],
+                    'NetInput' => byteFormat($info[2][0]),
+                    'NetOut' => byteFormat($info[10][0]),
+                ];
+            }
+        }
+
+        return $networkResult;
+    }
+
+    /**
+     * PHP 정보
+     * @method GET
+     * @see /admin/config/server/php
+     */
+    public function php()
+    {
+        ob_start();
+        phpinfo();
+        $phpinfo = ob_get_contents();
+        ob_end_clean();
+        $phpinfo = preg_replace('%^.*<body>(.*)</body>.*$%ms', '$1', $phpinfo);
+        ob_clean();
+
+        return view('admin.config.php.index', [
+            'subject' => 'PHP 정보',
+            'phpinfo' => $phpinfo
+        ]);
+    }
+
+    /**
+     * DB 정보
+     * @method GET
+     * @see /admin/config/server/db
+     */
+    public function db()
+    {
+        return view('admin.config.db.index', [
+            'subject' => 'DB 정보',
+            'database' => config('database'),
+            'drivers' => join(', ', \Illuminate\Support\Facades\DB::availableDrivers())
+        ]);
+    }
+}

+ 60 - 0
app/Http/Controllers/Admin/Config/Setting/AccessController.php

@@ -0,0 +1,60 @@
+<?php
+
+namespace App\Http\Controllers\Admin\Config\Setting;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Models\Config;
+use App\Models\FileLib;
+
+class AccessController extends Controller
+{
+    private Config $configModel;
+
+    public function __construct(Config $config)
+    {
+        $this->configModel = $config;
+    }
+
+    /**
+     * 접근
+     * @method GET
+     * @see /admin/config/setting/access
+     */
+    public function index()
+    {
+        return view('admin.config.setting.access', []);
+    }
+
+    /**
+     * 접근 저장
+     * @method POST
+     * @see /admin/config/setting/access
+     */
+    public function store(Request $request, FileLib $fileLib)
+    {
+        $rules = [
+            'admin_ip_whitelist' => 'string|nullable',
+            'site_ip_blacklist' => 'string|nullable',
+            'site_ip_whitelist' => 'string|nullable',
+            'site_blacklist_title' => 'string|nullable',
+            'site_blacklist_content' => 'string|nullable',
+        ];
+
+        $attributes = [
+            'admin_ip_whitelist' => '관리자 페이지 접근 가능 IP',
+            'site_ip_blacklist' => '사이트 접근 불가 IP',
+            'site_ip_whitelist' => '사이트 접근 가능 IP',
+            'site_blacklist_title' => '사이트 차단시 안내문 제목',
+            'site_blacklist_content' => '사이트 차단시 안내문 내용'
+        ];
+
+        $posts = $this->validate($request, $rules, [], );
+        $posts['site_blacklist_content'] = $fileLib->saveAsImage($posts['site_blacklist_content'], UPLOAD_PATH_EDITOR);
+
+        $this->configModel->save($posts, $attributes);
+
+        $message = '접근 정보가 저장되었습니다.';
+        return redirect()->route('admin.config.setting.access.index')->with('message', $message);
+    }
+}

+ 88 - 0
app/Http/Controllers/Admin/Config/Setting/BasicController.php

@@ -0,0 +1,88 @@
+<?php
+
+namespace App\Http\Controllers\Admin\Config\Setting;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Models\Config;
+use App\Models\User;
+
+class BasicController extends Controller
+{
+    private Config $configModel;
+    private User $userModel;
+
+    public function __construct(Config $config, User $user)
+    {
+        $this->configModel = $config;
+        $this->userModel = $user;
+    }
+
+    /**
+     * 기본
+     * @method GET
+     * @see /admin/config/setting/basic
+     */
+    public function index()
+    {
+        // 관리자 회원
+        return view('admin.config.setting.basic', [
+            'admins' => $this->userModel->getAdmins()
+        ]);
+    }
+
+    /**
+     * 기본 저장
+     * @method POST
+     * @see /admin/config/setting/basic
+     */
+    public function store(Request $request)
+    {
+        $rules = [
+            'admin_title' => 'string|nullable',
+            'site_title' => 'string|nullable',
+            'master_key' => 'string|nullable',
+            'master_email' => 'email|nullable',
+            'master_name' => 'string|nullable',
+            'footer_script' => 'string|nullable|max:2000',
+            'spam_word' => 'string|nullable',
+            'white_iframe' => 'string|nullable|max:2000',
+            'ip_display_style' => 'string|nullable',
+            'new_post_second' => 'required|numeric|min:0',
+            'open_current_visitor' => 'nullable|numeric',
+            'current_visitor_minute' => 'required|numeric|min:0',
+            'cache_expire_time' => 'required|numeric|min:1|max:86400',
+            'verify_expires_at' => 'required|numeric|min:1|max:3600',
+            'verify_send_limit' => 'required|numeric|min:0',
+            'use_post_copy_log' => 'required|numeric|in:0,1',
+            'use_header_search_log' => 'required|numeric|in:0,1'
+        ];
+
+        $attributes = [
+            'admin_title' => '관리자 제목',
+            'site_title' => '홈페이지 제목',
+            'master_key' => '최고관리자',
+            'master_email' => '관리자 이메일',
+            'master_name' => '관리자 이메일 이름',
+            'footer_script' => '하단 Script',
+            'spam_word' => '금지 단어',
+            'white_iframe' => '허용 Iframe',
+            'ip_display_style' => 'IP 공개시 표시 형식',
+            'new_post_second' => '새로운 글쓰기 시간',
+            'open_current_visitor' => '현재 접속자 공개 여부',
+            'current_visitor_minute' => '현재 접속자 기준',
+            'cache_expire_time' => '캐시 생성 시간',
+            'verify_expires_at' => '인증번호 만료시간',
+            'verify_send_limit' => '인증번호 발송 분당 제한 수',
+            'use_post_copy_log' => '게시물 이동 및 복사 기록',
+            'use_header_search_log' => '검색기록 저장 여부'
+        ];
+
+        $posts = $this->validate($request, $rules, [], $attributes);
+
+        $this->configModel->save($posts, $attributes);
+
+        $message = '기본 정보가 저장되었습니다.';
+        return redirect()->route('admin.config.setting.basic.index')->with('message', $message);
+    }
+}

+ 82 - 0
app/Http/Controllers/Admin/Config/Setting/CompanyController.php

@@ -0,0 +1,82 @@
+<?php
+
+namespace App\Http\Controllers\Admin\Config\Setting;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Models\Config;
+
+class CompanyController extends Controller
+{
+    private Config $configModel;
+
+    public function __construct(Config $config)
+    {
+        $this->configModel = $config;
+    }
+
+    /**
+     * 회사
+     * @method GET
+     * @see /admin/config/setting/company
+     */
+    public function index()
+    {
+        return view('admin.config.setting.company', [
+            'config' => $this->configModel->getAllMeta()
+        ]);
+    }
+
+    /**
+     * 회사 저장
+     * @method POST
+     * @see /admin/config/setting/company
+     */
+    public function store(Request $request)
+    {
+        $rules = [
+            'company_name' => 'string|nullable',
+            'company_reg_no' => 'string|nullable',
+            'company_owner' => 'string|nullable',
+            'company_phone' => 'string|nullable',
+            'company_fax' => 'string|nullable',
+            'company_retail_sale_no' => 'string|nullable',
+            'company_added_sale_no' => 'string|nullable',
+            'company_zip_code' => 'string|nullable',
+            'company_address' => 'string|nullable',
+            'company_hosting' => 'string|nullable',
+            'company_admin_name' => 'string|nullable',
+            'company_admin_email' => 'string|nullable',
+            'company_site_url' => 'string|nullable',
+            'company_bank_name' => 'string|nullable|max:20',
+            'company_bank_owner' => 'string|nullable|max:20',
+            'company_bank_number' => 'string|nullable|max:20'
+        ];
+
+        $attributes = [
+            'company_name' => '회사명',
+            'company_reg_no' => '사업자등록번호',
+            'company_owner' => '대표자명',
+            'company_phone' => '대표전화번호',
+            'company_fax' => 'FAX 번호',
+            'company_retail_sale_no' => '통신판매업신고번호',
+            'company_added_sale_no' => '부가통신 사업자번호',
+            'company_zip_code' => '사업장 우편번호',
+            'company_address' => '사업장 주소',
+            'company_hosting' => '호스팅서비스',
+            'company_admin_name' => '정보관리책임자명',
+            'company_admin_email' => '정보관리책임자 이메일',
+            'company_site_url' => '홈페이지 주소',
+            'company_bank_name' => '회사 계좌 정보 - 은행',
+            'company_bank_owner' => '회사 계좌 정보 - 예금주',
+            'company_bank_number' => '회사 계좌 정보 - 계좌번호'
+        ];
+
+        $posts = $this->validate($request, $rules, [], $attributes);
+
+        $this->configModel->save($posts, $attributes);
+
+        $message = '회사 정보가 저장되었습니다.';
+        return redirect()->route('admin.config.setting.company.index')->with('message', $message);
+    }
+}

+ 62 - 0
app/Http/Controllers/Admin/Config/Setting/ExpController.php

@@ -0,0 +1,62 @@
+<?php
+
+namespace App\Http\Controllers\Admin\Config\Setting;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Models\Config;
+
+class ExpController extends Controller
+{
+    private Config $configModel;
+
+    public function __construct(Config $config)
+    {
+        $this->configModel = $config;
+    }
+
+    /**
+     * 경험치
+     * @method GET
+     * @see /admin/config/setting/exp
+     */
+    public function index()
+    {
+        return view('admin.config.setting.exp', []);
+    }
+
+    /**
+     * 경험치 저장
+     * @method POST
+     * @see /admin/config/setting/exp
+     */
+    public function store(Request $request)
+    {
+        $rules = [
+            'use_exp' => 'numeric|nullable',
+            'exp_register' => 'numeric|nullable',
+            'exp_login' => 'numeric|nullable',
+            'exp_recommended' => 'numeric|nullable',
+            'exp_recommender' => 'numeric|nullable',
+            'exp_attendance' => 'numeric|nullable',
+            'exp_poll' => 'numeric|nullable'
+        ];
+
+        $attributes = [
+            'use_exp' => '경험치 기능',
+            'exp_register' => '회원가입 시 경험치',
+            'exp_login' => '로그인 시 경험치',
+            'exp_recommended' => '회원가입 시 추천인에게 경험치',
+            'exp_recommender' => '추천인 존재 시 가입자에게 경험치',
+            'exp_attendance' => '출석 시 경험치',
+            'exp_poll' => '설문조사 시 경험치'
+        ];
+
+        $posts = $this->validate($request, $rules, [], $attributes);
+
+        $this->configModel->save($posts, $attributes);
+
+        $message = '경험치 정보가 저장되었습니다.';
+        return redirect()->route('admin.config.setting.exp.index')->with('message', $message);
+    }
+}

+ 60 - 0
app/Http/Controllers/Admin/Config/Setting/NotifyController.php

@@ -0,0 +1,60 @@
+<?php
+
+namespace App\Http\Controllers\Admin\Config\Setting;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Models\Config;
+
+class NotifyController extends Controller
+{
+    private Config $configModel;
+
+    public function __construct(Config $config)
+    {
+        $this->configModel = $config;
+    }
+
+    /**
+     * 알림
+     * @method GET
+     * @see /admin/config/setting/notify
+     */
+    public function index()
+    {
+        return view('admin.config.setting.notify', []);
+    }
+
+    /**
+     * 알림 저장
+     * @method POST
+     * @see /admin/config/setting/notify
+     */
+    public function store(Request $request)
+    {
+        $rules = [
+            'use_notification' => 'numeric|nullable|in:0,1',
+            'notification_post_reply' => 'numeric|nullable|in:0,1',
+            'notification_post_comment' => 'numeric|nullable|in:0,1',
+            'notification_post_comment_reply' => 'numeric|nullable|in:0,1'
+        ];
+
+        $attributes = [
+            'use_notification' => '알림 기능',
+            'notification_post_reply' => '내 글에 답변글이 달렸을 때 알림',
+            'notification_post_comment' => '내 글에 댓글이 달렸을 때 알림',
+            'notification_post_comment_reply' => '내 댓글에 댓글이 달렸을 때 알림'
+        ];
+
+        $posts = $this->validate($request, $rules, [], $attributes);
+        $posts['use_notification'] ??= '0';
+        $posts['notification_post_reply'] ??= '0';
+        $posts['notification_post_comment'] ??= '0';
+        $posts['notification_post_comment_reply'] ??= '0';
+
+        $this->configModel->save($posts, $attributes);
+
+        $message = '알림 정보가 저장되었습니다.';
+        return redirect()->route('admin.config.setting.notify.index')->with('message', $message);
+    }
+}

+ 61 - 0
app/Http/Controllers/Admin/Config/Test/EmailController.php

@@ -0,0 +1,61 @@
+<?php
+
+namespace App\Http\Controllers\Admin\Config\Test;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Models\EmailLib;
+
+class EmailController extends Controller
+{
+    private EmailLib $emailLib;
+
+    public function __construct(EmailLib $emailLib)
+    {
+        $this->emailLib = $emailLib;
+    }
+
+    /**
+     * 이메일 발송 확인
+     * @method GET
+     * @see /admin/config/test/email
+     */
+    public function index()
+    {
+        return view('admin.config.test.email', [
+            'subject' => '이메일 발송 확인',
+            'mailHost' => env('MAIL_HOST'),
+            'mailName' => env('MAIL_USERNAME')
+        ]);
+    }
+
+    /**
+     * 이메일 발송 확인 실행
+     * @method POST
+     * @see /admin/config/test/email
+     */
+    public function store(Request $request)
+    {
+        $rules = [
+            'receive_email' => 'string|email',
+            'receive_name' => 'string',
+        ];
+
+        $attributes = [
+            'receive_email' => '받는 이메일 주소',
+            'receive_name' => '받는 이메일 이름'
+        ];
+
+        $posts = $this->validate($request, $rules, [], $attributes);
+
+        // 이메일 발송
+        $this->emailLib->test([
+            'today' => now(),
+            'email' => $posts['receive_email'],
+            'name' => $posts['receive_name']
+        ]);
+
+        $message = '이메일을 발송하였습니다.';
+        return redirect()->route('admin.config.test.email.index')->with('message', $message);
+    }
+}

+ 53 - 0
app/Http/Controllers/Admin/Config/Test/TelegramController.php

@@ -0,0 +1,53 @@
+<?php
+
+namespace App\Http\Controllers\Admin\Config\Test;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Http\Traits\TelegramTrait;
+use App\Models\Config;
+
+class TelegramController extends Controller
+{
+    use TelegramTrait;
+
+    private Config $configModel;
+
+    public function __construct(Config $config)
+    {
+        $this->configModel = $config;
+    }
+
+    /**
+     * 텔레그램 발송 확인
+     * @method GET
+     * @see /admin/config/test/telegram
+     */
+    public function index()
+    {
+        return view('admin.config.test.telegram', []);
+    }
+
+    /**
+     * 텔레그램 발송 확인 실행
+     * @method POST
+     * @see /admin/config/test/telegram
+     */
+    public function store(Request $request)
+    {
+        $rules = [
+            'message' => 'required|string',
+        ];
+
+        $attributes = [
+            'message' => '보낼 내용'
+        ];
+
+        $posts = $this->validate($request, $rules, [], $attributes);
+
+        $this->sendMessage($posts['message']);
+
+        $message = '문자를 발송하였습니다.';
+        return redirect()->route('admin.config.test.telegram.index')->with('message', $message);
+    }
+}

+ 99 - 0
app/Http/Controllers/Admin/Config/TestController.php

@@ -0,0 +1,99 @@
+<?php
+
+namespace App\Http\Controllers\Admin\Config;
+
+use App\Http\Controllers\Controller;
+use Illuminate\Http\Request;
+
+use App\Models\Config;
+use Illuminate\Support\Facades\Mail;
+
+class TestController extends Controller
+{
+    private $configModel;
+    
+    public function __construct()
+    {
+        $this->configModel = new Config();
+    }
+    
+    public function index() {
+        return $this->email();
+    }
+    
+    /*
+     * 이메일 발송 확인
+     */
+    public function email()
+    {
+        $data = [];
+        $data['subject'] = '이메일 발송 확인';
+        $data['mailHost'] = env('MAIL_HOST');
+        $data['mailName'] = env('MAIL_USERNAME');
+        
+        return view('admin.config.test.email', $data);
+    }
+    
+    /*
+     * 이메일 발송 확인 실행
+     */
+    public function emailSave(Request $request)
+    {
+        $rules = [
+            'receive_email' => 'string|email',
+            'receive_name' => 'string',
+        ];
+        
+        $attributes = Config('attributes');
+        $this->validate($request, $rules, [], $attributes);
+        
+        // 이메일 발송
+        $data = [];
+        $data['server'] = $_SERVER;
+    
+        $user = [
+            'email' => $request->post('receive_email'),
+            'name' => $request->post('receive_name'),
+        ];
+        
+        Mail::send('admin.config.test.emailForm', $data, function ($message) use ($user) {
+            $message->to($user['email'], $user['name'])->subject('Test Email');
+        });
+    
+        $message = '이메일을 발송하였습니다.';
+        return redirect('/admin/config/test/email')->with('message', $message);
+    }
+    
+    /*
+     * SMS 발송 확인
+     */
+    public function sms()
+    {
+        $data = [];
+        $data['subject'] = '문자 발송 확인';
+        $data['smsNumber'] = env('SMS_NUMBER');
+        
+        return view('admin.config.test.sms', $data);
+    }
+    
+    /*
+     * 문자 발송 확인 실행
+     */
+    public function smsSave(Request $request)
+    {
+        $rules = [
+            'receive_number' => 'string|numeric',
+        ];
+        
+        $attributes = Config('attributes');
+        $this->validate($request, $rules, [], $attributes);
+        
+        // 문자 발송
+        $data = [
+            'content' => '테스트 문자 입니다.',
+        ];
+        
+        $message = '문자를 발송하였습니다.';
+        return redirect('/admin/config/test/email')->with('message', $message);
+    }
+}

+ 40 - 0
app/Http/Controllers/Admin/ExchangeController.php

@@ -0,0 +1,40 @@
+<?php
+
+namespace App\Http\Controllers\Admin;
+
+use Illuminate\Http\Request;
+use App\Http\Traits\FinancialTrait;
+use App\Http\Controllers\Controller;
+
+class ExchangeController extends Controller
+{
+    use FinancialTrait;
+
+    /*
+     * 환율 정보
+     */
+    public function index(Request $request)
+    {
+        $data = [
+            'subject' => '도매 관리 - 환율 정보'
+        ];
+
+        $searchDate = $request->get("search_date", date('Y-m-d', time()));
+        $exchangeList = $this->exchangeList($searchDate);
+
+        if ($exchangeList) {
+            foreach ($exchangeList as $idx => $exchange) {
+                if (in_array($exchange['cur_unit'], ["USD", "EUR"])) {
+                    array_unshift($exchangeList, $exchange);
+                    unset($exchangeList[$idx + 1]);
+                }
+            }
+        }
+
+        $data['searchDate'] = $searchDate;
+        $data['exchangeList'] = $exchangeList;
+        $data['exchangeRows'] = count($exchangeList);
+
+        return view('admin.exchange', $data);
+    }
+}

+ 151 - 0
app/Http/Controllers/Admin/Page/Banner/GroupController.php

@@ -0,0 +1,151 @@
+<?php
+
+namespace App\Http\Controllers\Admin\Page\Banner;
+
+use Illuminate\Support\Facades\Validator;
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Models\BannerGroup;
+use App\Models\DTO\SearchData;
+use Exception;
+
+class GroupController extends Controller
+{
+    private BannerGroup $bannerGroupModel;
+
+    public function __construct(BannerGroup $bannerGroup)
+    {
+        $this->bannerGroupModel = $bannerGroup;
+    }
+
+    /**
+     * 배너 그룹 관리
+     * @method GET
+     * @see /admin/page/banner/group
+     */
+    public function index(Request $request)
+    {
+        $params = SearchData::fromRequest($request);
+        $bannerGroupData = $this->bannerGroupModel->data($params);
+
+        if($bannerGroupData->rows > 0) {
+            $num = listNum($bannerGroupData->total, $params->page, $params->perPage);
+            foreach($bannerGroupData->list as $i => $row) {
+                $row->num = $num++;
+                $row->bannerCount = $row->banner->count();
+
+                $bannerGroupData->list[$i] = $row;
+            }
+        }
+
+        return view('admin.page.banner.group.index', [
+            'actionURL' => route('admin.page.banner.group.store'),
+            'bannerGroupData' => $bannerGroupData
+        ]);
+    }
+
+    /**
+     * 배너 그룹 저장
+     * @method POST
+     * @see /admin/page/banner/group
+     */
+    public function store(Request $request)
+    {
+        $rules = [
+            'name' => 'required|string|unique:tb_banner_group,name',
+        ];
+
+        $attributes = [
+            'name' => '배너 위치명',
+        ];
+
+        $posts = $this->validate($request, $rules, [], $attributes);
+
+        $this->bannerGroupModel->insert([
+            'name' => $posts['name'],
+            'ip_address' => IP_ADDRESS,
+            'user_agent' => USER_AGENT,
+            'updated_user_id' => null,
+            'updated_at' => null,
+            'created_at' => now()
+        ]);
+
+        $message = '배너 그룹이 등록되었습니다.';
+        return redirect()->route('admin.page.banner.group.index')->with('message', $message);
+    }
+
+    /**
+     * 배너 그룹 수정
+     * @method PUT
+     * @see /admin/page/banner/group/{pk}
+     */
+    public function update(Request $request)
+    {
+        $chk = $request->post('chk');
+        $name = $request->post('name');
+
+        if($chk) {
+            foreach($chk as $groupID) {
+                $updateData = [
+                    'name' => ($name[$groupID] ?? ''),
+                    'ip_address' => IP_ADDRESS,
+                    'user_agent' => USER_AGENT,
+                    'updated_user_id' => UID
+                ];
+
+                $rules = [
+                    'name' => 'required|string',
+                ];
+
+                $attributes = [
+                    'name' => '위치명',
+                ];
+
+                $validator = Validator::make($updateData, $rules, [], $attributes);
+                if($validator->fails()) {
+                    return back()->withErrors($validator)->withInput();
+                }
+
+                $this->bannerGroupModel->find($groupID)->update($updateData);
+            }
+        }
+
+        $message = '배너 그룹 정보가 변경되었습니다.';
+        return redirect()->route('admin.page.banner.group.index')->with('message', $message);
+    }
+
+    /**
+     * 배너 그룹 삭제
+     * @method DELETE
+     * @see /admin/page/banner/group/destroy
+     */
+    public function destroy(Request $request)
+    {
+        try {
+
+            $chk = $request->post('chk');
+
+            if ($chk) {
+                foreach ($chk as $bannerGroupID) {
+                    $bannerGroup = $this->bannerGroupModel->findOrNew($bannerGroupID);
+
+                    if(!$bannerGroup->exists)
+                    {
+                        throw new Exception($bannerGroupID . "번 배너 그룹은 존재하지 않습니다.");
+                    }
+
+                    if($bannerGroup->banner->count() > 0) {
+                        throw new Exception($bannerGroupID . "번 배너 그룹은 삭제할 수 없습니다.");
+                    }
+
+                    $bannerGroup->delete();
+                }
+            }
+
+            $message = '배너 그룹 정보가 삭제되었습니다.';
+            return redirect()->route('admin.page.banner.group.index')->with('message', $message);
+        } catch (Exception $e) {
+            return back()->withErrors($e->getMessage())->withInput();
+        }
+    }
+}

+ 286 - 0
app/Http/Controllers/Admin/Page/Banner/ListController.php

@@ -0,0 +1,286 @@
+<?php
+
+namespace App\Http\Controllers\Admin\Page\Banner;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Models\Banner;
+use App\Models\BannerGroup;
+use App\Models\DTO\SearchData;
+use Exception;
+
+class ListController extends Controller
+{
+    private Banner $bannerModel;
+    private BannerGroup $bannerGroupModel;
+
+    public function __construct(Banner $banner, BannerGroup $bannerGroup)
+    {
+        $this->bannerModel = $banner;
+        $this->bannerGroupModel = $bannerGroup;
+    }
+
+    /**
+     * 배너 관리
+     * @method GET
+     * @see /admin/page/banner
+     */
+    public function index(Request $request)
+    {
+        $params = SearchData::fromRequest($request);
+        $params->activated =  $request->get('activated');
+
+        $bannerData = $this->bannerModel->data($params);
+
+        if($bannerData->total > 0) {
+            $num = listNum($bannerData->total, $params->page, $params->perPage);
+            foreach($bannerData->list as $i => $row) {
+                $row->num = $num--;
+                $row->creater = $row->user->find($row->user_id);
+                $row->updater = $row->user->find($row->updated_user_id);
+                $row->editURL = route('admin.page.banner.edit', $row->id);
+
+                $bannerData->list[$i] = $row;
+            }
+        }
+
+        return view('admin.page.banner.index', [
+            'bannerData' => $bannerData,
+            'params' => $params
+        ]);
+    }
+
+    /**
+     * 배너 등록
+     * @method GET
+     * @see /admin/page/banner
+     */
+    public function create()
+    {
+        return view('admin.page.banner.write', [
+            'actionURL' => route('admin.page.banner.store'),
+            'bannerGroupData' => $this->bannerGroupModel->all(),
+            'bannerData' => [],
+            'bannerID' => null
+        ]);
+    }
+
+    /**
+     * 배너 수정
+     * @method GET
+     * @see /admin/page/banner/{pk}/edit
+     */
+    public function edit(int $bannerID)
+    {
+        return view('admin.page.banner.write', [
+            'actionURL' => route('admin.page.banner.update', $bannerID),
+            'bannerGroupData' => $this->bannerGroupModel->all(),
+            'bannerData' => $this->bannerModel->find($bannerID),
+            'bannerID' => $bannerID
+        ]);
+    }
+
+    /**
+     * 배너 등록 저장
+     * @method POST
+     * @see /admin/page/banner
+     */
+    public function store(Request $request)
+    {
+        $rules = [
+            'banner_group_id' => 'required|numeric|exists:tb_banner_group,id',
+            'image' => 'image|max:3192',
+            'subject' => 'nullable|string|max:50',
+            'summary' => 'nullable|string|max:255',
+            'start_date' => 'required|date',
+            'end_date' => 'required|date',
+            'url' => 'nullable|string',
+            'target' => 'required|numeric|in:0,1',
+            'device' => 'required|numeric|in:0,1,2',
+            'width' => 'required|numeric',
+            'height' => 'required|numeric',
+            'order' => 'required|numeric',
+            'activated' => 'required|numeric'
+        ];
+
+        $attributes = [
+            'banner_group_id' => '위치',
+            'image' => '이미지',
+            'subject' => '제목',
+            'summary' => '요약 설명',
+            'start_date' => '시작일',
+            'end_date' => '종료일',
+            'url' => 'URL',
+            'target' => '새창 여부',
+            'device' => '표시기기',
+            'width' => '가로 크기',
+            'height' => '세로 크기',
+            'order' => '정렬',
+            'activated' => '활성화 여부'
+        ];
+
+        $posts = $this->validate($request, $rules, [], $attributes);
+
+        $saveData = [
+            'banner_group_id' => $posts['banner_group_id'],
+            'user_id' => UID,
+            'start_date' => $posts['start_date'],
+            'end_date' => $posts['end_date'],
+            'subject' => $posts['subject'],
+            'summary' => $posts['summary'],
+            'url' => $posts['url'],
+            'target' => $posts['target'],
+            'device' => $posts['device'],
+            'width' => $posts['width'],
+            'height' => $posts['height'],
+            'views' => 0,
+            'order' => $posts['order'],
+            'activated' => $posts['activated'],
+            'ip_address' => IP_ADDRESS,
+            'user_agent' => USER_AGENT,
+            'updated_user_id' => null,
+            'updated_at' => null,
+            'created_at' => now()
+        ];
+
+        // 파일 저장
+        if($request->hasFile('image')) {
+            $siteFavicon = $request->file('image');
+            $siteFavicon->store(UPLOAD_PATH_PUBLIC . DIRECTORY_SEPARATOR . UPLOAD_PATH_BANNER);
+            $saveData['image'] = (UPLOAD_PATH_STORAGE . DIRECTORY_SEPARATOR . UPLOAD_PATH_BANNER . DIRECTORY_SEPARATOR .$siteFavicon->hashName());
+        }
+
+        // 파일 삭제
+        if($request->get('image_del')) {
+            $path = $request->get('image_url');
+            if(file_exists($path)) {
+                unlink($request->get('image_url'));
+            }
+            $saveData['image'] = null;
+        }
+
+        $this->bannerModel->insert($saveData);
+
+        $message = '배너가 새로 등록되었습니다.';
+        return redirect()->route('admin.page.banner.index')->with('message', $message);
+    }
+
+    /**
+     * 배너 수정 저장
+     * @method PUT
+     * @see /admin/page/banner/{pk}
+     */
+    public function update(Request $request)
+    {
+        $rules = [
+            'banner_id' => 'required|numeric|exists:tb_banner,id',
+            'banner_group_id' => 'required|numeric|exists:tb_banner_group,id',
+            'image' => 'mimes:jpg,jpeg,gif,png|max:3192',
+            'subject' => 'nullable|string|max:50',
+            'summary' => 'nullable|string|max:255',
+            'start_date' => 'required|date',
+            'end_date' => 'required|date',
+            'url' => 'nullable|string',
+            'target' => 'required|numeric|in:0,1',
+            'device' => 'required|numeric|in:0,1,2',
+            'width' => 'required|numeric',
+            'height' => 'required|numeric',
+            'order' => 'required|numeric',
+            'activated' => 'required|numeric'
+        ];
+
+        $attributes = [
+            'banner_id' => '번호',
+            'banner_group_id' => '위치',
+            'image' => '이미지',
+            'subject' => '제목',
+            'summary' => '요약 설명',
+            'start_date' => '시작일',
+            'end_date' => '종료일',
+            'url' => 'URL',
+            'target' => '새창 여부',
+            'device' => '표시기기',
+            'width' => '가로 크기',
+            'height' => '세로 크기',
+            'order' => '정렬',
+            'activated' => '활성화 여부'
+        ];
+
+        $posts = $this->validate($request, $rules, [], $attributes);
+
+        $saveData = [
+            'banner_group_id' => $posts['banner_group_id'],
+            'start_date' => $posts['start_date'],
+            'end_date' => $posts['end_date'],
+            'subject' => $posts['subject'],
+            'summary' => $posts['summary'],
+            'url' => $posts['url'],
+            'target' => $posts['target'],
+            'device' => $posts['device'],
+            'width' => $posts['width'],
+            'height' => $posts['height'],
+            'order' => $posts['order'],
+            'activated' => $posts['activated'],
+            'ip_address' => IP_ADDRESS,
+            'user_agent' => USER_AGENT,
+            'updated_user_id' => UID,
+            'updated_at' => now()
+        ];
+
+        // 파일 저장
+        if($request->hasFile('image')) {
+            $siteFavicon = $request->file('image');
+            $siteFavicon->store(UPLOAD_PATH_PUBLIC . DIRECTORY_SEPARATOR . UPLOAD_PATH_BANNER);
+            $saveData['image'] = (UPLOAD_PATH_STORAGE . DIRECTORY_SEPARATOR . UPLOAD_PATH_BANNER . DIRECTORY_SEPARATOR . $siteFavicon->hashName());
+        }
+
+        // 파일 삭제
+        if($request->get('image_del')) {
+            $path = $request->get('image_url');
+            if(file_exists($path)) {
+                unlink($request->get('image_url'));
+            }
+            $saveData['image'] = null;
+        }
+
+        $this->bannerModel->find($posts['banner_id'])->update($saveData);
+
+        $message = '배너 정보가 수정되었습니다.';
+        return redirect()->route('admin.page.banner.edit', $posts['banner_id'])->with('message', $message);
+    }
+
+    /**
+     * 배너 삭제
+     * @method DELETE
+     * @see /admin/page/banner
+     */
+    public function destroy(Request $request)
+    {
+        try {
+
+            $chk = $request->post('chk');
+
+            if ($chk) {
+                foreach ($chk as $bannerID) {
+                    $banner = $this->bannerModel->findOrNew($bannerID);
+
+                    if (!$banner->exists) {
+                        throw new Exception($bannerID . "번 배너는 존재하지 않습니다.");
+                    }
+
+                    // 이미지 삭제
+                    if (file_exists($banner->image)) {
+                        unlink($banner->image);
+                    }
+
+                    $banner->delete();
+                }
+            }
+
+            $message = '배너 정보가 삭제되었습니다.';
+            return redirect()->route('admin.page.banner.index')->with('message', $message);
+        } catch (Exception $e) {
+            return back()->withErrors($e->getMessage())->withInput();
+        }
+    }
+}

+ 205 - 0
app/Http/Controllers/Admin/Page/DocumentController.php

@@ -0,0 +1,205 @@
+<?php
+
+namespace App\Http\Controllers\Admin\Page;
+
+use Illuminate\Http\Request;
+use Illuminate\Validation\Rule;
+use App\Http\Controllers\Controller;
+use App\Http\Traits\CommonTrait;
+use App\Models\Document;
+use App\Models\DTO\SearchData;
+use App\Models\FileLib;
+use Exception;
+
+class DocumentController extends Controller
+{
+    use CommonTrait;
+
+    private Document $documentModel;
+
+    public function __construct(Document $document)
+    {
+        $this->documentModel = $document;
+    }
+
+    /**
+     * 문서 관리
+     * @method GET
+     * @see /admin/page/document
+     */
+    public function index(Request $request)
+    {
+        $params = SearchData::fromRequest($request);
+
+        $documentData = $this->documentModel->data($params);
+
+        if ($documentData->total > 0) {
+            $num = listNum($documentData->total, $params->page, $params->perPage);
+            foreach ($documentData->list as $i => $row) {
+                $row->num = $num--;
+                $row->url = route('document', $row->code);
+                $row->views = number_format($row->views);
+                $row->creater = $row->user->find($row->user_id);
+                $row->updater = $row->user->find($row->updated_user_id);
+                $row->editURL = route('admin.page.document.edit', $row->id);
+
+                $documentData->list[$i] = $row;
+            }
+        }
+
+        return view('admin.page.document.index', [
+            'documentData' => $documentData,
+            'params' => $params
+        ]);
+    }
+
+    /**
+     * 문서 등록
+     * @method GET
+     * @see /admin/page/document/create
+     */
+    public function create()
+    {
+        return view('admin.page.document.write', [
+            'actionURL' => route('admin.page.document.store'),
+            'documentData' => [],
+            'documentID' => null
+        ]);
+    }
+
+    /**
+     * 문서 수정
+     * @method GET
+     * @see /admin/page/document/{pk}/edit
+     */
+    public function edit(int $documentID)
+    {
+        return view('admin.page.document.write', [
+            'actionURL' => route('admin.page.document.update', $documentID),
+            'documentData' => $this->documentModel->find($documentID),
+            'documentID' => $documentID
+        ]);
+    }
+
+    /**
+     * 문서 등록 저장
+     * @method POST
+     * @see /admin/page/document
+     */
+    public function store(Request $request, FileLib $fileLib)
+    {
+        $rules = [
+            'code' => 'string|min:3|max:50|unique:tb_document,code',
+            'subject' => 'string|max:150',
+            'content' => 'string|nullable',
+            'mobile_content' => 'string|nullable'
+        ];
+
+        $attributes = [
+            'code' => '주소',
+            'subject' => '제목',
+            'content' => 'PC 내용',
+            'mobile_content' => '모바일 내용'
+        ];
+
+        $posts = $this->validate($request, $rules, [], $attributes);
+        $posts['content'] = $fileLib->saveAsImage($posts['content'], UPLOAD_PATH_DOCUMENT);
+
+        $this->documentModel->insert([
+            'user_id' => UID,
+            'code' => $posts['code'],
+            'subject' => $posts['subject'],
+            'content' => $posts['content'],
+            'views' => 0,
+            'ip_address' => IP_ADDRESS,
+            'user_agent' => USER_AGENT,
+            'updated_user_id' => null,
+            'updated_at' => null,
+            'created_at' => now()
+        ]);
+
+        $message = '문서가 등록되었습니다.';
+        return redirect()->route('admin.page.document.index')->with('message', $message);
+    }
+
+    /**
+     * 문서 수정 저장
+     * @method PUT
+     * @see /admin/page/document/{pk}
+     */
+    public function update(Request $request, FileLib $fileLib)
+    {
+        $documentID = $request->post('document_id');
+
+        $rules = [
+            'document_id' => 'required|numeric|exists:tb_document,id',
+            'code' => [
+                'string',
+                'min:3',
+                'max:50',
+                Rule::unique('tb_document', 'code')->ignore($documentID, 'id'),
+            ],
+            'subject' => 'string|max:150',
+            'content' => 'string|nullable'
+        ];
+
+        $attributes = [
+            'document_id' => '번호',
+            'code' => '주소',
+            'subject' => '제목',
+            'content' => '내용'
+        ];
+
+        $posts = $this->validate($request, $rules, [], $attributes);
+        $posts['content'] = $fileLib->saveAsImage($posts['content'], UPLOAD_PATH_DOCUMENT);
+
+        $this->documentModel->find($documentID)->update([
+            'code' => $posts['code'],
+            'subject' => $posts['subject'],
+            'content' => $posts['content'],
+            'ip_address' => IP_ADDRESS,
+            'user_agent' => USER_AGENT,
+            'updated_user_id' => UID,
+            'updated_at' => now()
+        ]);
+
+        $message = '문서 정보가 수정되었습니다.';
+        return redirect()->route(
+            'admin.page.document.edit', $documentID
+        )->with('message', $message);
+    }
+
+    /**
+     * 문서 삭제
+     * @method DELETE
+     * @see /admin/page/document/destroy
+     */
+    public function destroy(Request $request)
+    {
+        try {
+
+            $chk = $request->post('chk');
+
+            if ($chk) {
+                foreach ($chk as $documentID) {
+                    $document = $this->documentModel->findOrNew($documentID);
+
+                    if(!$document->exists)
+                    {
+                        throw new Exception($documentID . "번 문서는 존재하지 않습니다.");
+                    }
+
+                    // 이미지 삭제
+                    $this->deleteImageFromContent($document->content);
+
+                    $document->delete();
+                }
+            }
+
+            $message = '문서 정보가 삭제되었습니다.';
+            return redirect()->route('admin.page.document.index')->with('message', $message);
+        } catch (Exception $e) {
+            return back()->withErrors($e->getMessage())->withInput();
+        }
+    }
+}

+ 187 - 0
app/Http/Controllers/Admin/Page/MenuController.php

@@ -0,0 +1,187 @@
+<?php
+
+/*
+ * https://www.we-rc.com/blog/2015/07/19/nested-set-model-practical-examples-part-i
+ */
+namespace App\Http\Controllers\Admin\Page;
+
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Validator;
+use App\Http\Controllers\Controller;
+use App\Models\Menu;
+
+class MenuController extends Controller
+{
+    private Menu $menuModel;
+
+    public function __construct(Menu $menu)
+    {
+        $this->menuModel = $menu;
+    }
+
+    /**
+     * 메뉴 관리
+     * @method GET
+     * @see /admin/page/menu
+     */
+    public function index()
+    {
+        $menuData = $this->menuModel->data();
+
+        if ($menuData->rows > 0) {
+            foreach ($menuData->list as $i => $row) {
+                $row->upURL = route('admin.page.menu.up', $row->id);
+                $row->downURL = route('admin.page.menu.down', $row->id);
+
+                $menuData->list[$i] = $row;
+            }
+        }
+
+        return view('admin.page.menu.index', [
+            'actionURL' => route('admin.page.menu.store'),
+            'menuData' => $menuData
+        ]);
+    }
+
+    /**
+     * 메뉴 관리 저장
+     * @method POST
+     * @see /admin/page/menu
+     */
+    public function store(Request $request)
+    {
+        $rules = [
+            'menu_id' => 'nullable|numeric',
+            'name' => 'string',
+            'link' => 'string',
+            'target' => 'string|nullable',
+            'desktop' => 'numeric|in:1,0',
+            'mobile' => 'numeric|in:1,0',
+            'custom' => 'string|nullable',
+            'icon' => 'string|nullable',
+            'depth' => 'numeric'
+        ];
+
+        $attributes = [
+            'menu_id' => '메뉴 PK',
+            'name' => '메뉴명',
+            'link' => 'URL',
+            'target' => '새창 여부',
+            'desktop' => 'PC',
+            'mobile' => '모바일',
+            'custom' => '속성',
+            'icon' => '아이콘',
+            'depth' => '분류 깊이'
+        ];
+
+        $posts = $this->validate($request, $rules, [], $attributes);
+
+        $this->menuModel->register($posts);
+
+        $message = '메뉴 정보가 저장되었습니다.';
+        return redirect()->route('admin.page.menu.index')->with('message', $message);
+    }
+
+    /**
+     * 메뉴 관리 수정
+     * @method PUT
+     * @see /admin/page/menu/{pk}
+     */
+    public function update(Request $request)
+    {
+        $posts = $request->all();
+
+        if ($posts['chk']) {
+            foreach ($posts['chk'] as $menuID) {
+                $updateData = [
+                    'menu_id' => $menuID,
+                    'parent_id' => ($posts['parent_id'][$menuID] ?? ''),
+                    'name' => ($posts['name'][$menuID] ?? ''),
+                    'link' => ($posts['link'][$menuID] ?? ''),
+                    'target' => ($posts['target'][$menuID] ?? ''),
+                    'desktop' => ($posts['desktop'][$menuID] ?? ''),
+                    'mobile' => ($posts['mobile'][$menuID] ?? ''),
+                    'custom' => ($posts['custom'][$menuID] ?? ''),
+                    'icon' => ($posts['icon'][$menuID] ?? ''),
+                    'depth' => ($posts['depth'][$menuID] ?? '')
+                ];
+
+                $rules = [
+                    'menu_id' => 'required|numeric|exists:tb_menu,id',
+                    'parent_id' => 'required|numeric|exists:tb_menu,parent_id',
+                    'name' => 'string',
+                    'link' => 'string',
+                    'target' => 'numeric|in:1,0',
+                    'desktop' => 'numeric|in:1,0',
+                    'mobile' => 'numeric|in:1,0',
+                    'custom' => 'string|nullable',
+                    'icon' => 'string|nullable',
+                    'depth' => 'numeric'
+                ];
+
+                $attributes = [
+                    'menu_id' => '메뉴 PK',
+                    'parent_id' => '부모 메뉴 PK',
+                    'name' => '메뉴명',
+                    'link' => 'URL',
+                    'target' => '새창 여부',
+                    'desktop' => 'PC',
+                    'mobile' => '모바일',
+                    'custom' => '속성',
+                    'icon' => '아이콘',
+                    'depth' => '분류 깊이'
+                ];
+
+                $validator = Validator::make($updateData, $rules, [], $attributes);
+                if ($validator->fails()) {
+                    return back()->withErrors($validator)->withInput();
+                }
+                $this->menuModel->updater($updateData);
+            }
+        }
+
+        $message = '메뉴 정보가 변경되었습니다.';
+        return redirect()->route('admin.page.menu.index')->with('message', $message);
+    }
+
+    /**
+     * 메뉴 관리 삭제
+     * @method DELETE
+     * @see /admin/page/menu/destroy
+     */
+    public function destroy(Request $request)
+    {
+        $chk = $request->post('chk');
+
+        if ($chk) {
+            foreach ($chk as $menuID) {
+                $this->menuModel->remove($menuID);
+            }
+        }
+
+        $message = '메뉴 정보가 삭제되었습니다.';
+        return redirect()->route('admin.page.menu.index')->with('message', $message);
+    }
+
+    /**
+     * 메뉴 한 단계 위로
+     * @method GET
+     */
+    public function up(int $menuID)
+    {
+        $this->menuModel->orderUp($menuID);
+
+        return redirect()->route('admin.page.menu.index');
+    }
+
+    /**
+     * 메뉴 한 단계 아래로
+     * @method GET
+     */
+    public function down(int $menuID)
+    {
+        $this->menuModel->orderDown($menuID);
+
+        return redirect()->route('admin.page.menu.index');
+    }
+}

+ 249 - 0
app/Http/Controllers/Admin/Page/PopupController.php

@@ -0,0 +1,249 @@
+<?php
+
+namespace App\Http\Controllers\Admin\Page;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Http\Traits\CommonTrait;
+use App\Models\Popup;
+use App\Models\FileLib;
+use App\Models\DTO\SearchData;
+use Exception;
+
+class PopupController extends Controller
+{
+    use CommonTrait;
+
+    private Popup $popupModel;
+
+    public function __construct(Popup $popup)
+    {
+        $this->popupModel = $popup;
+    }
+
+    /**
+     * 팝업 관리
+     * @method GET
+     * @see /admin/page/popup
+     */
+    public function index(Request $request)
+    {
+        $params = SearchData::fromRequest($request);
+        $params->activated = $request->get('activated');
+
+        $popupData = $this->popupModel->data($params);
+
+        if ($popupData->total > 0) {
+            $num = listNum($popupData->total, $params->page, $params->perPage);
+            foreach ($popupData->list as $i => $row) {
+                $row->num = $num--;
+                $row->deviceStr = MAP_DEVICE_TYPE[$row->device];
+                $row->activatedStr = ($row->activated == '1' ? '활성' : '비활성');
+                $row->creater = $row->user->find($row->user_id);
+                $row->updater = $row->user->find($row->updated_user_id);
+                $row->editURL = route('admin.page.popup.edit', $row->id);
+
+                $popupData->list[$i] = $row;
+            }
+        }
+
+        return view('admin.page.popup.index', [
+            'popupData' => $popupData,
+            'params' => $params
+        ]);
+    }
+
+    /**
+     * 팝업 등록
+     * @method GET
+     * @see /admin/page/popup
+     */
+    public function create()
+    {
+        return view('admin.page.popup.write', [
+            'actionURL' => route('admin.page.popup.store'),
+            'popupData' => null,
+            'popupID' => null
+        ]);
+    }
+
+    /**
+     * 팝업 수정
+     * @method GET
+     * @see /admin/page/popup/{pk}/edit
+     */
+    public function edit(int $popupID)
+    {
+        return view('admin.page.popup.write', [
+            'actionURL' => route('admin.page.popup.update', $popupID),
+            'popupData' => $this->popupModel->find($popupID),
+            'popupID' => $popupID
+        ]);
+    }
+
+    /**
+     * 팝업 등록 저장
+     * @method POST
+     * @see /admin/page/popup
+     */
+    public function store(Request $request, FileLib $fileLib)
+    {
+        $rules = [
+            'subject' => 'required|string',
+            'start_date' => 'required|date',
+            'end_date' => 'required|date',
+            'is_center' => 'required|numeric|in:1,2',
+            'left' => 'required_if:is_center,2|present',
+            'top' => 'required_if:is_center,2|present',
+            'width' => 'required|numeric',
+            'height' => 'required|numeric',
+            'device' => 'required|numeric|in:0,1,2',
+            'disable_hours' => 'required|numeric',
+            'activated' => 'required|in:0,1',
+            'content' => 'string|nullable'
+        ];
+
+        $attributes = [
+            'subject' => '제목',
+            'start_date' => '시작일',
+            'end_date' => '종료일',
+            'is_center' => '화면 위치',
+            'left' => '좌측 위치',
+            'top' => '상단 위치',
+            'width' => '가로 크기',
+            'height' => '세로 크기',
+            'device' => '표시기기',
+            'disable_hours' => '닫기 유효시간',
+            'activated' => '활성화 여부',
+            'content' => '내용'
+        ];
+
+        $posts = $this->validate($request, $rules, [], $attributes);
+        $posts['content'] = $fileLib->saveAsImage($posts['content'], UPLOAD_PATH_POPUP);
+
+        $this->popupModel->insert([
+            'user_id' => UID,
+            'start_date' => $posts['start_date'],
+            'end_date' => $posts['end_date'],
+            'is_center' => $posts['is_center'],
+            'left' => $posts['left'],
+            'top' => $posts['top'],
+            'width' => $posts['width'],
+            'height' => $posts['height'],
+            'device' => $posts['device'],
+            'subject' => $posts['subject'],
+            'content' => $posts['content'],
+            'disable_hours' => $posts['disable_hours'],
+            'activated' => $posts['activated'],
+            'ip_address' => IP_ADDRESS,
+            'user_agent' => USER_AGENT,
+            'updated_user_id' => null,
+            'updated_at' => null,
+            'created_at' => now(),
+        ]);
+
+        $message = '팝업이 새로 등록되었습니다.';
+        return redirect()->route('admin.page.popup.index')->with('message', $message);
+    }
+
+    /**
+     * 팝업 수정 저장
+     * @method PUT
+     * @see /admin/page/popup/{pk}
+     */
+    public function update(Request $request, FileLib $fileLib)
+    {
+        $popupID = $request->post('popup_id');
+
+        $rules = [
+            'popup_id' => 'required|numeric|exists:tb_popup,id',
+            'subject' => 'required|string',
+            'start_date' => 'required|date',
+            'end_date' => 'required|date',
+            'is_center' => 'required|numeric|in:1,2',
+            'left' => 'required_if:is_center,2|present',
+            'top' => 'required_if:is_center,2|present',
+            'width' => 'required|numeric',
+            'height' => 'required|numeric',
+            'device' => 'required|numeric|in:0,1,2',
+            'disable_hours' => 'required|numeric',
+            'activated' => 'required|in:0,1',
+            'content' => 'string|nullable'
+        ];
+
+        $attributes = [
+            'popup_id' => '번호',
+            'subject' => '제목',
+            'start_date' => '시작일',
+            'end_date' => '종료일',
+            'is_center' => '화면 위치',
+            'left' => '좌측 위치',
+            'top' => '상단 위치',
+            'width' => '가로 크기',
+            'height' => '세로 크기',
+            'device' => '표시기기',
+            'disable_hours' => '닫기 유효시간',
+            'activated' => '활성화 여부',
+            'content' => '내용'
+        ];
+
+        $posts = $this->validate($request, $rules, [], $attributes);
+        $posts['content'] = $fileLib->saveAsImage($posts['content'], UPLOAD_PATH_POPUP);
+
+        $this->popupModel->find($popupID)->update([
+            'start_date' => $posts['start_date'],
+            'end_date' => $posts['end_date'],
+            'is_center' => $posts['is_center'],
+            'left' => $posts['left'],
+            'top' => $posts['top'],
+            'width' => $posts['width'],
+            'height' => $posts['height'],
+            'device' => $posts['device'],
+            'subject' => $posts['subject'],
+            'content' => $posts['content'],
+            'disable_hours' => $posts['disable_hours'],
+            'activated' => $posts['activated'],
+            'ip_address' => IP_ADDRESS,
+            'user_agent' => USER_AGENT,
+            'updated_user_id' => UID,
+            'updated_at' => now()
+        ]);
+
+        $message = '팝업 정보가 수정되었습니다.';
+        return redirect()->route('admin.page.popup.edit', $popupID)->with('message', $message);
+    }
+
+    /**
+     * 팝업 삭제
+     * @method DELETE
+     * @see /admin/page/popup/destroy
+     */
+    public function destroy(Request $request)
+    {
+        try {
+
+            $chk = $request->post('chk');
+
+            if ($chk) {
+                foreach ($chk as $popupID) {
+                    $popup = $this->popupModel->findOrNew($popupID);
+
+                    if(!$popup->exists)
+                    {
+                        throw new Exception($popupID . "번 팝업은 존재하지 않습니다.");
+                    }
+
+                    // 이미지 삭제
+                    $this->deleteImageFromContent($popup->content);
+
+                    $popup->delete();
+                }
+            }
+
+            $message = '팝업 정보가 삭제되었습니다.';
+            return redirect()->route('admin.page.popup.index')->with('message', $message);
+        } catch (Exception $e) {
+            return back()->withErrors($e->getMessage())->withInput();
+        }
+    }
+}

+ 89 - 0
app/Http/Controllers/Admin/Popup/UserController.php

@@ -0,0 +1,89 @@
+<?php
+
+namespace App\Http\Controllers\Admin\Popup;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Models\User;
+use App\Models\UserGroup;
+use App\Models\UserGrade;
+use App\Models\DTO\SearchData;
+
+class UserController extends Controller
+{
+    private User $userModel;
+    private UserGroup $userGroupModel;
+    private UserGrade $userGradeModel;
+
+    public function __construct(
+        User $user,
+        UserGroup $userGroup,
+        UserGrade $userGrade
+    ) {
+        $this->userModel = $user;
+        $this->userGroupModel = $userGroup;
+        $this->userGradeModel = $userGrade;
+    }
+
+    /**
+     * AJAX 회원 검색
+     * @method GET
+     * @see /admin/popup/user
+     */
+    public function index(Request $request)
+    {
+        $params = SearchData::fromRequest($request);
+        $params->activated = $request->get('activated');
+        $params->isAdmin = $request->get('is_admin');
+        $params->isDenied = $request->get('is_denied');
+        $params->isWithdraw = $request->get('is_withdraw');
+        $params->userGroupID = $request->get('user_group_id', []);
+        $params->userGradeID = $request->get('user_grade_id', []);
+
+        $userData = $this->userModel->data($params);
+
+        if ($userData->rows > 0) {
+            $num = listNum($userData->total, $params->page, $params->perPage);
+            foreach ($userData->list as $i => $row) {
+                $row->num = $num--;
+                $row->point = number_format($row->point);
+
+                $userData->list[$i] = $row;
+            }
+        }
+
+        return view('admin.popup.user.index', [
+            'userGroupData' => $this->userGroupModel->all(),
+            'userGradeData' => $this->userGradeModel->all(),
+            'userData' => $userData,
+            'params' => $params
+        ]);
+    }
+
+    /**
+     * AJAX 회원그룹 검색
+     * @method GET
+     * @see /admin/popup/user/group
+     */
+    public function group()
+    {
+        $userGroupData = $this->userGroupModel->data();
+        $userTotalCount = $this->userModel->count(); // 전체 회원 수
+
+        if($userGroupData->rows > 0) {
+            $num = 1;
+            foreach($userGroupData->list as $i =>$row) {
+                $row->num = $num++;
+                $row->userGroupCount = $row->user->count();
+                $row->strName = ($row->kor_name . ($row->eng_name ? ' (' . $row->eng_name . ')' : ''));
+                $row->strShare = (($row->userGroupCount > 0) ? round(($row->userGroupCount / $userTotalCount) * 100) : 0);
+
+                $userGroupData->list[$i] = $row;
+            }
+        }
+
+        return view('admin.popup.user.group', [
+            'userGroupData' => $userGroupData
+        ]);
+    }
+}

+ 70 - 0
app/Http/Controllers/Admin/User/Dormant/ConfigController.php

@@ -0,0 +1,70 @@
+<?php
+
+namespace App\Http\Controllers\Admin\User\Dormant;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Models\Config;
+
+class ConfigController extends Controller
+{
+    private Config $configModel;
+
+    public function __construct(Config $config)
+    {
+        $this->configModel = $config;
+    }
+
+    /**
+     * 휴면계정 - 환경설정
+     * @method GET
+     * @see /admin/user/point
+     */
+    public function index()
+    {
+        return view('admin.user.dormant.config', []);
+    }
+
+    /**
+     * 휴면계정 - 환경설정 저장
+     * @method POST
+     * @see /admin/user/point
+     */
+    public function store(Request $request)
+    {
+        $rules = [
+            'user_dormant_use' => 'required|numeric|in:0,1',
+            'user_auto_withdraw_day' => 'required|numeric|in:3,5,7,14,30',
+            'user_withdraw_shop_review_delete' => 'numeric|nullable',
+            'user_withdraw_shop_qna_delete' => 'numeric|nullable',
+            'user_withdraw_shop_order_delete' => 'numeric|nullable',
+            'user_withdraw_board_qna_delete' => 'numeric|nullable',
+            'user_withdraw_shop_wish_list_delete' => 'numeric|nullable',
+            'user_withdraw_shop_cart_delete' => 'numeric|nullable'
+        ];
+
+        $attributes = [
+            'user_dormant_use' => '휴면계정 전환 여부',
+            'user_auto_withdraw_day' => '탈퇴 요청회원 자동삭제 기한',
+            'user_withdraw_shop_review_delete' => '탈퇴 요청회원 자동삭제 자료 - 구매후기',
+            'user_withdraw_shop_qna_delete' => '탈퇴 요청회원 자동삭제 자료 - 상품문의',
+            'user_withdraw_shop_order_delete' => '탈퇴 요청회원 자동삭제 자료 - 주문내역',
+            'user_withdraw_board_qna_delete' => '탈퇴 요청회원 자동삭제 자료 - 1:1 상담',
+            'user_withdraw_shop_wish_list_delete' => '탈퇴 요청회원 자동삭제 자료 - 관심상품',
+            'user_withdraw_shop_cart_delete' => '탈퇴 요청회원 자동삭제 자료 - 장바구니'
+        ];
+
+        $posts = $this->validate($request, $rules, [], $attributes);
+        $posts['user_withdraw_shop_review_delete'] ??= 0;
+        $posts['user_withdraw_shop_qna_delete'] ??= 0;
+        $posts['user_withdraw_shop_order_delete'] ??= 0;
+        $posts['user_withdraw_board_qna_delete'] ??= 0;
+        $posts['user_withdraw_shop_wish_list_delete'] ??= 0;
+        $posts['user_withdraw_shop_cart_delete'] ??= 0;
+
+        $this->configModel->save($posts, $attributes);
+
+        $message = '환경설정이 저장되었습니다.';
+        return redirect()->route('admin.user.dormant.config.index')->with('message', $message);
+    }
+}

+ 60 - 0
app/Http/Controllers/Admin/User/Dormant/Form/EmailController.php

@@ -0,0 +1,60 @@
+<?php
+
+namespace App\Http\Controllers\Admin\User\Dormant\Form;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Models\Config;
+
+class EmailController extends Controller
+{
+    private Config $configModel;
+
+    public function __construct(Config $config)
+    {
+        $this->configModel = $config;
+    }
+
+    /**
+     * 휴면 계정 관리(이메일) - 알림 발송 양식
+     * @method GET
+     * @see /admin/user/dormant/form/email
+     */
+    public function index()
+    {
+        return view('admin.user.dormant.form.email', []);
+    }
+
+    /**
+     * 휴면 계정 관리(이메일) - 알림 발송 양식 저장
+     * @method POST
+     * @see /admin/user/dormant/form/email
+     */
+    public function store(Request $request)
+    {
+        $rules = [
+            'send_email_dormant_form_title' => 'string|nullable',
+            'send_email_dormant_form_content' => 'string|nullable',
+            'send_email_dormancy_form_title' => 'string|nullable',
+            'send_email_dormancy_form_content' => 'string|nullable',
+            'send_email_recover_form_title' => 'string|nullable',
+            'send_email_recover_form_content' => 'string|nullable'
+        ];
+
+        $attributes = [
+            'send_email_dormant_form_title' => '휴면 예정 - 제목',
+            'send_email_dormant_form_content' => '휴면 예정 - 내용',
+            'send_email_dormancy_form_title' => '휴면 전환 - 제목',
+            'send_email_dormancy_form_content' => '휴면 전환 - 내용',
+            'send_email_recover_form_title' => '휴면 해제 - 제목',
+            'send_email_recover_form_content' => '휴면 해제 - 내용'
+        ];
+
+        $posts = $this->validate($request, $rules, [], $attributes);
+
+        $this->configModel->save($posts, $attributes);
+
+        $message = '이메일 양식 정보가 저장되었습니다.';
+        return redirect()->route('admin.user.dormant.form.email.index')->with('message', $message);
+    }
+}

+ 54 - 0
app/Http/Controllers/Admin/User/Dormant/Form/SmsController.php

@@ -0,0 +1,54 @@
+<?php
+
+namespace App\Http\Controllers\Admin\User\Dormant\Form;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Models\Config;
+
+class SmsController extends Controller
+{
+    private Config $configModel;
+
+    public function __construct(Config $config)
+    {
+        $this->configModel = $config;
+    }
+
+    /**
+     * 휴면 계정 관리(문자) - 알림 발송 양식
+     * @method GET
+     * @see /admin/user/dormant/form/sms
+     */
+    public function index()
+    {
+        return view('admin.user.dormant.form.sms', []);
+    }
+
+    /**
+     * 휴면 계정 관리(문자) - 알림 발송 양식 저장
+     * @method POST
+     * @see /admin/user/dormant/form/sms
+     */
+    public function store(Request $request)
+    {
+        $rules = [
+            'send_sms_dormant_form_content' => 'string|nullable',
+            'send_sms_dormancy_form_content' => 'string|nullable',
+            'send_sms_recover_form_content' => 'string|nullable'
+        ];
+
+        $attributes = [
+            'send_sms_dormant_form_content' => '휴면 예정',
+            'send_sms_dormancy_form_content' => '휴면 전환',
+            'send_sms_recover_form_content' => '휴면 해제'
+        ];
+
+        $posts = $this->validate($request, $rules, [], $attributes);
+
+        $this->configModel->save($posts, $attributes);
+
+        $message = '문자 양식 정보가 저장되었습니다.';
+        return redirect()->route('admin.user.dormant.form.sms.index')->with('message', $message);
+    }
+}

+ 165 - 0
app/Http/Controllers/Admin/User/Dormant/ListController.php

@@ -0,0 +1,165 @@
+<?php
+
+namespace App\Http\Controllers\Admin\User\Dormant;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Models\User;
+use App\Models\UserDormant;
+use App\Models\UserDormantNotify;
+use App\Models\EmailLib;
+use App\Models\DTO\SearchData;
+
+class ListController extends Controller
+{
+    private User $userModel;
+    private UserDormant $userDormantModel;
+    private UserDormantNotify $userDormantNotifyModel;
+    private EmailLib $emailLib;
+
+    public function __construct(
+        User $user,
+        UserDormant $userDormant,
+        UserDormantNotify $userDormantNotify,
+        EmailLib $emailLib
+    ) {
+        $this->userModel = $user;
+        $this->userDormantModel = $userDormant;
+        $this->userDormantNotifyModel = $userDormantNotify;
+        $this->emailLib = $emailLib;
+    }
+
+    /**
+     * 휴면계정 관리
+     * @method GET|POST
+     * @see /admin/user/dormant/list
+     */
+    public function index(Request $request)
+    {
+        $params = SearchData::fromRequest($request);
+        $params->activated = $request->get('activated');
+        $params->userGroupID = $request->get('user_group_id', []);
+        $params->userGradeID = $request->get('user_grade_id', []);
+        $params->isDormancy = $request->get('is_dormant', 0);
+
+        if (!$params->isDormancy) { // 회원
+            $sModel = $this->userModel;
+            $userDormantData = $sModel->dormantUsers($params);
+        }else{ // 휴면회원
+            $sModel = $this->userDormantModel;
+            $userDormantData = $sModel->data($params);
+        }
+
+        $sTable = $sModel->getTable();
+
+        if ($userDormantData->total > 0) {
+            $num = listNum($userDormantData->total, $params->page, $params->perPage);
+            foreach ($userDormantData->list as $i => $row) {
+                $row->num = $num--;
+                $row->point = number_format($row->point);
+                $row->lastLoginAt = dateBr($row->last_login_at, '-');
+                $row->createdAt = dateBr($row->created_at, '-');
+                $row->dormantAt = dateBr($row->dormant_at, '-');
+
+                $userDormantData->list[$i] = $row;
+            }
+        }
+
+        return view('admin.user.dormant.index', [
+            'sTable' => $sTable,
+            'userDormantData' => $userDormantData,
+            'params' => $params
+        ]);
+    }
+
+    /**
+     * 휴면예정 알림 발송
+     * @method POST
+     * @see /admin/user/dormant/list/notify
+     */
+    public function notify(Request $request)
+    {
+        $chk = $request->post('chk');
+
+        if ($chk) {
+            foreach ($chk as $userID) {
+
+                // 휴면예정 알림 발송
+                if(!$this->userDormantNotifyModel->register(
+                    $this->userModel->find($userID)
+                )) {
+                    return back()->withErrors([
+                        'message' => sprintf('%d번 회원은 알림 발송이 거부되었습니다.', $userID)
+                    ])->withInput();
+                }
+            }
+        }
+
+        $message = '선택한 회원에게 알림이 발송되었습니다.';
+        return redirect()->route('admin.user.dormant.list.index')->with('message', $message);
+    }
+
+    /**
+     * 휴면 처리
+     * @method POST
+     * @see /admin/user/dormant/list/dormancy
+     */
+    public function dormancy(Request $request)
+    {
+        $chk = $request->post('chk');
+
+        if ($chk) {
+            foreach ($chk as $userID) {
+
+                // 휴면 처리
+                if($this->userDormantModel->dormancy($userID)) {
+
+                    // 휴면 알림 메일 발송
+                    $this->emailLib->send(
+                        SEND_MAIL_FORM_TYPE_12,
+                        $this->userDormantModel->find($userID)
+                    );
+
+                }else{
+                    return back()->withErrors([
+                        'message' => sprintf('%d번 회원 휴면 처리가 거부되었습니다.', $userID)
+                    ])->withInput();
+                }
+            }
+        }
+
+        $message = '선택한 회원이 휴면 처리되었습니다.';
+        return redirect()->route('admin.user.dormant.list.index')->with('message', $message);
+    }
+
+    /**
+     * 휴면 해제
+     * @method POST
+     * @see /admin/user/dormant/list/recover
+     */
+    public function recover(Request $request)
+    {
+        $chk = $request->post('chk');
+
+        if ($chk) {
+            foreach ($chk as $userID) {
+                if($this->userDormantModel->recover($userID)) {
+
+                    // 휴면 해제 메일 발송
+                    $this->emailLib->send(
+                        SEND_MAIL_FORM_TYPE_13,
+                        $this->userModel->find($userID)
+                    );
+
+                }else{
+                    return back()->withErrors([
+                        'message' => sprintf('%d번 회원 휴면 해제가 거부되었습니다.', $userID)
+                    ])->withInput();
+                }
+            }
+        }
+
+        $message = '선택한 회원이 휴면 해제되었습니다.';
+        return redirect()->route('admin.user.dormant.list.index')->with('message', $message);
+    }
+}

+ 80 - 0
app/Http/Controllers/Admin/User/Dormant/NotifyController.php

@@ -0,0 +1,80 @@
+<?php
+
+namespace App\Http\Controllers\Admin\User\Dormant;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Models\UserDormantNotify;
+use App\Models\DTO\SearchData;
+use Exception;
+
+class NotifyController extends Controller
+{
+    private UserDormantNotify $userDormantNotifyModel;
+
+    public function __construct(UserDormantNotify $userDormantNotify)
+    {
+        $this->userDormantNotifyModel = $userDormantNotify;
+    }
+
+    /**
+     * 휴면계정 관리 - 알림 발송 내역
+     * @method GET|POST
+     * @see /admin/user/dormant/notify
+     */
+    public function index(Request $request)
+    {
+        $params = SearchData::fromRequest($request);
+        $params->startDate = $request->get('start_date');
+        $params->endDate = $request->get('end_date');
+
+        $userDormantNotifyData = $this->userDormantNotifyModel->data($params);
+
+        if($userDormantNotifyData->rows > 0) {
+            $num = listNum($userDormantNotifyData->total, $params->page, $params->perPage);
+            foreach($userDormantNotifyData->list as $i => $row) {
+                $row->num = $num--;
+                $row->lastLoginAt = dateBr($row->last_login_at, '-');
+                $row->dormantAt = dateBr($row->dormant_at, '-');
+                $row->createdAt = dateBr($row->created_at, '-');
+
+                $userDormantNotifyData->list[$i] = $row;
+            }
+        }
+
+        return view('admin.user.dormant.notify', [
+            'userDormantNotifyData' => $userDormantNotifyData,
+            'params' => $params
+        ]);
+    }
+
+    /**
+     * 휴면계정 관리 - 알림 발송 내역 삭제
+     * @method POST
+     * @see /admin/user/dormant/notify/destroy
+     */
+    public function destroy(Request $request)
+    {
+        try{
+
+            $chk = $request->post('chk');
+
+            if ($chk) {
+                foreach ($chk as $notifyID) {
+                    $notify = $this->userDormantNotifyModel->findOrNew($notifyID);
+
+                    if(!$notify->exists) {
+                        throw new Exception($notifyID . '번 알림은 존재하지 않습니다.');
+                    }
+
+                    $notify->delete();
+                }
+            }
+
+            $message = '알림 발송 내역이 삭제되었습니다.';
+            return redirect()->route('admin.user.dormant.notify.index')->with('message', $message);
+        }catch(Exception $e) {
+            return back()->withErrors($e->getMessage())->withInput();
+        }
+    }
+}

+ 350 - 0
app/Http/Controllers/Admin/User/ListController.php

@@ -0,0 +1,350 @@
+<?php
+
+namespace App\Http\Controllers\Admin\User;
+
+use Illuminate\Http\Request;
+use Illuminate\Validation\Rule;
+use App\Http\Controllers\Controller;
+use App\Models\User;
+use App\Models\Config;
+use App\Models\UserRegister;
+use App\Models\DTO\SearchData;
+use App\Rules\NumberLength;
+use App\Rules\SpecialCharLength;
+use App\Rules\UppercaseLength;
+use App\Rules\DeniedEmail;
+
+class ListController extends Controller
+{
+    private User $userModel;
+    private UserRegister $userRegisterModel;
+
+    public function __construct(
+        User $user,
+        UserRegister $userRegister
+    ) {
+        $this->userModel = $user;
+        $this->userRegisterModel = $userRegister;
+    }
+
+    /**
+     * 회원 관리
+     * @method GET
+     * @see /admin/user/list
+     */
+    public function index(Request $request)
+    {
+        $params = SearchData::fromRequest($request);
+        $params->activated = $request->get('activated');
+        $params->isAdmin = $request->get('is_admin');
+        $params->isDenied = $request->get('is_denied');
+        $params->isWithdraw = $request->get('is_withdraw');
+
+        $userData = $this->userModel->data($params);
+
+        if ($userData->rows > 0) {
+            $num = listNum($userData->total, $params->page, $params->perPage);
+            foreach ($userData->list as $i => $row) {
+                $row->num = $num--;
+                $row->lastLoginAt = dateBr($row->last_login_at, '-');
+                $row->deletedAt = dateBr($row->deleted_at, '-');
+                $row->createdAt = dateBr($row->created_at);
+                $row->editURL = route('admin.user.list.edit', $row->id);
+
+                $userData->list[$i] = $row;
+            }
+        }
+
+        return view('admin.user.list.index', [
+            'userData' => $userData,
+            'params' => $params
+        ]);
+    }
+
+    /**
+     * 회원 등록
+     * @method GET
+     * @see /admin/user/create
+     */
+    public function create()
+    {
+        return view('admin.user.list.write', [
+            'actionURL' => route('admin.user.list.store'),
+            'userData' => [],
+            'uid' => null
+        ]);
+    }
+
+    /**
+     * 회원 수정
+     * @method GET
+     * @see /admin/user/{pk}/edit
+     */
+    public function edit(int $uid)
+    {
+        return view('admin.user.list.write', [
+            'actionURL' => route('admin.user.list.update', $uid),
+            'userData' => $this->userModel->find($uid),
+            'uid' => $uid
+        ]);
+    }
+
+    /**
+     * 회원 등록 저장
+     * @method POST
+     * @see /admin/user/list
+     */
+    public function store(Request $request, Config $config)
+    {
+        // 비밀번호 유효성 검사 규칙 지정
+        $passwordRule = ['required', 'confirmed'];
+        $passwordMinLength = $config->item('password_min_length');
+
+        // 비밀번호 최소 길이
+        if($passwordMinLength > 0) {
+            $passwordRule[] = 'min:' . $passwordMinLength;
+            $passwordRule[] = new UppercaseLength;
+            $passwordRule[] = new NumberLength;
+            $passwordRule[] = new SpecialCharLength;
+        }
+
+        $rules = [
+            'email' => ['required', 'email', 'unique:users,email', new DeniedEmail],
+            'name' => 'required|string|min:2|max:20',
+            'nickname' => 'required|string|min:2|max:20|unique:users,nickname',
+            'password' => $passwordRule,
+            'thumb_img' => 'nullable|mimes:jpg,jpeg,gif,png|max:3192',
+            'about_me' => 'string|nullable|max:500',
+            'receive_email' => 'nullable|numeric|in:0,1',
+            'is_denied' => 'nullable|numeric|in:0,1',
+            'is_withdraw' => 'nullable|numeric|in:0,1',
+            'is_admin' => 'nullable|numeric|in:0,1',
+            'is_open_profile' => 'nullable|numeric|in:0,1'
+        ];
+
+        $attributes = [
+            'email' => '이메일',
+            'name' => '이름',
+            'nickname' => '닉네임',
+            'password' => '비밀번호',
+            'thumb_img' => '프로필 이미지',
+            'about_me' => '자기소개',
+            'receive_email' => '이메일 수신 여부',
+            'is_denied' => '차단 여부',
+            'is_withdraw' => '탈퇴 여부',
+            'is_admin' => '관리자 여부',
+            'is_open_profile' => '정보 공개 여부'
+        ];
+
+        $posts = $this->validate($request, $rules, [], $attributes);
+        [$sid] = explode('@', $posts['email']);
+
+        $saveData = [
+            'sid' => $sid,
+            'name' => $posts['name'],
+            'nickname' => $posts['nickname'],
+            'email' => $posts['email'],
+            'email_verified_at' => now(),
+            'password' => bcrypt($posts['password']),
+            'thumb' => ($posts['thumb_img'] ?? 0),
+            'about_me' => $posts['about_me'],
+            'remember_token' => null,
+            'is_email_cert' => 1,
+            'is_denied' => ($posts['is_denied'] ?? 0),
+            'is_withdraw' => ($posts['is_withdraw'] ?? 0),
+            'is_admin' => ($posts['is_admin'] ?? 0),
+            'is_open_profile' => ($posts['is_open_profile'] ?? 0),
+            'receive_email' => ($posts['receive_email'] ?? 0),
+            'register_ip' => IP_ADDRESS,
+            'last_login_ip' => null,
+            'last_login_at' => null,
+            'password_updated_at' => now(),
+            'deleted_at' => null,
+            'created_at' => now(),
+            'updated_at' => null,
+        ];
+
+        // 파일 저장
+        if($request->hasFile('thumb')) {
+            $thumb = $request->file('thumb');
+            $thumb->store(UPLOAD_PATH_PUBLIC . DIRECTORY_SEPARATOR . UPLOAD_PATH_USER_THUMB);
+            $saveData['thumb'] = (UPLOAD_PATH_STORAGE . DIRECTORY_SEPARATOR . UPLOAD_PATH_USER_THUMB . DIRECTORY_SEPARATOR . $thumb->hashName());
+        }
+
+        // 파일 삭제
+        if($request->get('thumb_del')) {
+            $thumbPath = $request->get('thumb_url');
+            if(file_exists($thumbPath)) {
+                unlink($thumbPath);
+            }
+            $saveData['thumb'] = null;
+        }
+
+        $uid = $this->userModel->insertGetId($saveData);
+
+        $this->userRegisterModel->insert([
+            'user_id' => $uid,
+            'device' => DEVICE_TYPE,
+            'language' => null,
+            'browser' => BROWSER,
+            'platform' => PLATFORM,
+            'robot' => null,
+            'ip_address' => IP_ADDRESS,
+            'user_agent' => USER_AGENT,
+            'referer' => REFERER
+        ]);
+
+        $message = '회원이 등록되었습니다.';
+        return redirect()->route('admin.user.list.index')->with('message', $message);
+    }
+
+    /**
+     * 회원 수정 저장
+     * @method PUT
+     * @see /admin/user/list/{pk}
+     */
+    public function update(int $uid, Request $request, Config $config)
+    {
+        // 비밀번호 유효성 검사 규칙 지정
+        $passwordRule = ['nullable', 'confirmed'];
+        $passwordMinLength = $config->item('password_min_length');
+
+        // 비밀번호 최소 길이
+        if($passwordMinLength > 0) {
+            $passwordRule[] = 'min:' . $passwordMinLength;
+            $passwordRule[] = new UppercaseLength;
+            $passwordRule[] = new NumberLength;
+            $passwordRule[] = new SpecialCharLength;
+        }
+
+        $rules = [
+            'uid' => 'required|numeric|exists:users,id',
+            'email' => [
+                'required',
+                'email',
+                Rule::unique('users', 'email')->ignore($uid, 'id'),
+                new DeniedEmail
+            ],
+            'name' => 'required|string|min:2|max:20',
+            'nickname' => 'required|string|min:2|max:20|' . Rule::unique('users', 'nickname')->ignore($uid, 'id'),
+            'password' => $passwordRule,
+            'thumb_img' => 'nullable|mimes:jpg,jpeg,gif,png|max:3192',
+            'about_me' => 'string|nullable|max:500',
+            'receive_email' => 'nullable|numeric|in:0,1',
+            'is_denied' => 'nullable|numeric|in:0,1',
+            'is_withdraw' => 'nullable|numeric|in:0,1',
+            'is_admin' => 'nullable|numeric|in:0,1',
+            'is_open_profile' => 'nullable|numeric|in:0,1'
+        ];
+
+        $attributes = [
+            'uid' => '회원 ID',
+            'email' => '이메일',
+            'name' => '이름',
+            'nickname' => '닉네임',
+            'password' => '비밀번호',
+            'thumb_img' => '프로필 이미지',
+            'about_me' => '자기소개',
+            'receive_email' => '이메일 수신 여부',
+            'is_denied' => '차단 여부',
+            'is_withdraw' => '탈퇴 여부',
+            'is_admin' => '관리자 여부',
+            'is_open_profile' => '정보 공개 여부'
+        ];
+
+        $posts = $this->validate($request, $rules, [], $attributes);
+        $user = $this->userModel->find($uid);
+
+        $saveData = [
+            'name' => $posts['name'],
+            'nickname' => $posts['nickname'],
+            'email' => $posts['email'],
+            'password' => bcrypt($posts['password']),
+            'thumb' => ($posts['thumb_img'] ?? 0),
+            'about_me' => $posts['about_me'],
+            'receive_email' => ($posts['receive_email'] ?? 0),
+            'is_denied' => ($posts['is_denied'] ?? 0),
+            'is_withdraw' => ($posts['is_withdraw'] ?? 0),
+            'is_admin' => ($posts['is_admin'] ?? 0),
+            'is_open_profile' => ($posts['is_open_profile'] ?? 0),
+            'deleted_at' => null,
+            'updated_at' => now()
+        ];
+
+        if($user->password != $saveData['password']) {
+            $saveData['password_updated_at'] = now();
+        }
+
+        // 파일 저장
+        if($request->hasFile('thumb')) {
+            $thumb = $request->file('thumb');
+            $thumb->store(UPLOAD_PATH_PUBLIC . DIRECTORY_SEPARATOR . UPLOAD_PATH_USER_THUMB);
+            $saveData['thumb'] = (UPLOAD_PATH_STORAGE . DIRECTORY_SEPARATOR . UPLOAD_PATH_USER_THUMB . DIRECTORY_SEPARATOR . $thumb->hashName());
+        }
+
+        // 파일 삭제
+        if($request->get('thumb_del')) {
+            $thumbPath = $request->get('thumb_url');
+            if(file_exists($thumbPath)) {
+                unlink($thumbPath);
+            }
+            $saveData['thumb'] = null;
+        }
+
+        $this->userModel->updater($uid, $saveData);
+
+        $message = '회원 정보가 수정되었습니다.';
+        return redirect()->route('admin.user.list.edit', $uid)->with('message', $message);
+    }
+
+    /**
+     * 회원 삭제
+     * @method DELETE
+     * @see /admin/user/list/destroy
+     */
+    public function destroy(Request $request)
+    {
+        $chk = $request->post('chk');
+
+        if ($chk) {
+            foreach ($chk as $uid) {
+
+                $user = $this->userModel->findOrNew($uid);
+
+                if($user->exists) {
+                    // 프로필 이미지 삭제
+                    if(file_exists($user->thumb)) {
+                        unlink($user->thumb);
+                    }
+
+                    $user->delete();
+                }
+            }
+        }
+
+        $message = '회원 정보가 삭제되었습니다.';
+        return redirect()->route('admin.user.list.index')->with('message', $message);
+    }
+
+    /**
+     * 회원 탈퇴
+     * @method POST
+     * @see /admin/user/list/withdraw
+     */
+    public function withdraw(Request $request)
+    {
+        $chk = $request->post('chk');
+
+        if ($chk) {
+            foreach ($chk as $uid) {
+                $this->userModel->find($uid)->update([
+                    'is_withdraw' => 1,
+                    'deleted_at' => now()
+                ]);
+            }
+        }
+
+        $message = '선택 회원이 탈퇴 처리되었습니다.';
+        return redirect()->route('admin.user.list.index')->with('message', $message);
+    }
+}

+ 64 - 0
app/Http/Controllers/Admin/User/Log/EmailController.php

@@ -0,0 +1,64 @@
+<?php
+
+namespace App\Http\Controllers\Admin\User\Log;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Models\DTO\SearchData;
+use App\Models\User;
+use App\Models\UserEmailLog;
+
+class EmailController extends Controller
+{
+    private User $userModel;
+    private UserEmailLog $userEmailLogModel;
+
+    public function __construct(User $user, UserEmailLog $userEmailLogModel)
+    {
+        $this->userModel = $user;
+        $this->userEmailLogModel = $userEmailLogModel;
+    }
+
+    /**
+     * 이메일 변경 이력
+     * @method GET
+     * @see /admin/user/log/email
+     */
+    public function index(Request $request)
+    {
+        $params = SearchData::fromRequest($request);
+        $userEmailLog = $this->userEmailLogModel->data($params);
+
+        if ($userEmailLog->rows > 0) {
+            $num = listNum($userEmailLog->total, $params->page, $params->perPage);
+            foreach ($userEmailLog->list as $i => $row) {
+                $row->num = $num--;
+                $userEmailLog->list[$i] = $row;
+            }
+        }
+
+        return view('admin.user.log.email', [
+            'userEmailLog' => $userEmailLog,
+            'params' => $params
+        ]);
+    }
+
+    /**
+     * 이메일 변경 이력 삭제
+     * @method DELETE
+     * @see /admin/user/log/email/destroy
+     */
+    public function destroy(Request $request)
+    {
+        $chk = $request->post('chk');
+
+        if ($chk) {
+            foreach ($chk as $logID) {
+                $this->userEmailLogModel->find($logID)->delete();
+            }
+        }
+
+        $message = '이름 변경 이력 정보가 삭제되었습니다.';
+        return redirect()->route('admin.user.log.email.index')->with('message', $message);
+    }
+}

+ 65 - 0
app/Http/Controllers/Admin/User/Log/Login/LogController.php

@@ -0,0 +1,65 @@
+<?php
+
+namespace App\Http\Controllers\Admin\User\Log\Login;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Models\DTO\SearchData;
+use App\Models\User;
+use App\Models\LoginLog;
+
+class LogController extends Controller
+{
+    private User $userModel;
+    private LoginLog $loginLogModel;
+
+    public function __construct(User $user, LoginLog $loginLogModel)
+    {
+        $this->userModel = $user;
+        $this->loginLogModel = $loginLogModel;
+    }
+
+    /**
+     * 로그인 이력 관리
+     * @method GET
+     * @see /admin/user/log/login
+     */
+    public function index(Request $request)
+    {
+        $params = SearchData::fromRequest($request);
+        $userLoginLogData = $this->loginLogModel->data($params);
+
+        if ($userLoginLogData->rows > 0) {
+            $num = listNum($userLoginLogData->total, $params->page, $params->perPage);
+            foreach ($userLoginLogData->list as $i => $row) {
+                $row->num = $num--;
+                $row->createdAt = dateBr($row->created_at);
+                $userLoginLogData->list[$i] = $row;
+            }
+        }
+
+        return view('admin.user.log.login.index', [
+            'userLoginLogData' => $userLoginLogData,
+            'params' => $params
+        ]);
+    }
+
+    /**
+     * 로그인 이력 삭제
+     * @method DELETE
+     * @see /admin/user/log/login/destroy
+     */
+    public function destroy(Request $request)
+    {
+        $chk = $request->post('chk');
+
+        if ($chk) {
+            foreach ($chk as $logID) {
+                $this->loginLogModel->find($logID)->delete();
+            }
+        }
+
+        $message = '로그인 이력 정보가 삭제되었습니다.';
+        return redirect()->route('admin.user.log.login.index')->with('message', $message);
+    }
+}

+ 164 - 0
app/Http/Controllers/Admin/User/Log/Login/StatController.php

@@ -0,0 +1,164 @@
+<?php
+
+namespace App\Http\Controllers\Admin\User\Log\Login;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Models\LoginLog;
+
+class StatController extends Controller
+{
+    private LoginLog $userLoginLog;
+
+    public function __construct(LoginLog $userLoginLog)
+    {
+        $this->userLoginLog = $userLoginLog;
+    }
+
+    /**
+     * 로그인 그래프
+     * @method GET
+     * @see /admin/user/log/login/stat
+     * https://developers.google.com/chart/interactive/docs/basic_load_libs
+     */
+    public function index(Request $request)
+    {
+        /*
+         * d: 일별, m: 월별, y: 년도별
+         */
+        $dateType = $request->get('date_type', 'd');
+        if ($dateType !== 'm' && $dateType !== 'y') {
+            $dateType = 'd';
+        }
+
+        $startDate = $request->get('start_date', date('Y-m-01'));
+        $endDate = $request->get('end_date', date('Y-m-d'));
+        $success = $request->get('success', 1);
+
+        $startYear = '';
+        $endYear = '';
+        $startYearMonth = '';
+        $endYearMonth = '';
+        if ($dateType === 'y' || $dateType === 'm') {
+            $startYear = substr($startDate, 0, 4);
+            $endYear = substr($endDate, 0, 4);
+        }
+        if ($dateType === 'm') {
+            $startMonth = substr($startDate, 5, 2);
+            $endMonth = substr($endDate, 5, 2);
+            $startYearMonth = ($startYear * 12 + $startMonth);
+            $endYearMonth = ($endYear * 12 + $endMonth);
+        }
+
+        $userLoginLogData = $this->userLoginLog->getLoginSuccessCount($dateType, $startDate, $endDate, $success);
+        $sumCount = 0;
+        $arr = [];
+        $max = 0;
+
+        if ($userLoginLogData->total > 0) {
+            foreach ($userLoginLogData->list as $key => $value) {
+                $s = $value->day;
+                if (!isset($arr[$s])) {
+                    $arr[$s] = 0;
+                }
+                $arr[$s] += $value->cnt;
+
+                if ($arr[$s] > $max) {
+                    $max = $arr[$s];
+                }
+                $sumCount += $value->cnt;
+            }
+        }
+
+        $result = [];
+        $i = 0;
+        $no = 0;
+        $saveCount = -1;
+
+        /*
+         * 통계 데이터 생성
+         */
+        if (count($arr) > 0) {
+            foreach ($arr as $date => $value) {
+                $count = (int)$arr[$date];
+                $result[$date]['count'] = $count;
+                $i++;
+                if ($saveCount !== $count) {
+                    $no = $i;
+                    $saveCount = $count;
+                }
+                $result[$date]['no'] = $no;
+                $result[$date]['key'] = $date;
+                $rate = ($count / $sumCount * 100);
+                $result[$date]['rate'] = $rate;
+                $sRate = number_format($rate, 1);
+                $result[$date]['sRate'] = $sRate;
+
+                $bar = (int)($count / $max * 100);
+                $result[$date]['bar'] = $bar;
+            }
+            $data['maxValue'] = $max;
+            $data['sumCount'] = $sumCount;
+        }
+
+        /*
+         * 빈 배열 초기화
+         */
+        if ($dateType === 'y') {
+            for ($i = $startYear; $i <= $endYear; $i++) {
+                if (!isset($result[$i])) {
+                    $result[$i] = [
+                        'count' => 0,
+                        'no' => 0,
+                        'key' => 0,
+                        'rate' => 0,
+                        'sRate' => 0,
+                        'bar' => 0,
+                    ];
+                }
+            }
+        } elseif ($dateType === 'm') {
+            for ($i = $startYearMonth; $i <= $endYearMonth; $i++) {
+                $year = floor($i / 12);
+                if ($year * 12 == $i) $year--;
+                $month = sprintf("%02d", ($i - ($year * 12)));
+                $date = $year . '-' . $month;
+                if (!isset($result[$date])) {
+                    $result[$date] = [
+                        'count' => 0,
+                        'no' => 0,
+                        'key' => 0,
+                        'rate' => 0,
+                        'sRate' => 0,
+                        'bar' => 0,
+                    ];
+                }
+            }
+        } elseif ($dateType === 'd') {
+            $date = $startDate;
+            while ($date <= $endDate) {
+                if (!isset($result[$date])) {
+                    $result[$date] = [
+                        'count' => 0,
+                        'no' => 0,
+                        'key' => 0,
+                        'rate' => 0,
+                        'sRate' => 0,
+                        'bar' => 0,
+                    ];
+                }
+                $date = date('Y-m-d', strtotime($date) + 86400);
+            }
+        }
+
+        ksort($result);
+
+        $data['result'] = $result;
+        $data['dateType'] = $dateType;
+        $data['startDate'] = $startDate;
+        $data['endDate'] = $endDate;
+        $data['success'] = $success;
+
+        return view('admin.user.log.login.stat', $data);
+    }
+}

+ 64 - 0
app/Http/Controllers/Admin/User/Log/NameController.php

@@ -0,0 +1,64 @@
+<?php
+
+namespace App\Http\Controllers\Admin\User\Log;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Models\DTO\SearchData;
+use App\Models\User;
+use App\Models\UserNameLog;
+
+class NameController extends Controller
+{
+    private User $userModel;
+    private UserNameLog $userNameLogModel;
+
+    public function __construct(User $user, UserNameLog $userNameLogModel)
+    {
+        $this->userModel = $user;
+        $this->userNameLogModel = $userNameLogModel;
+    }
+
+    /**
+     * 이름 변경 이력
+     * @method GET
+     * @see /admin/user/log/name
+     */
+    public function index(Request $request)
+    {
+        $params = SearchData::fromRequest($request);
+        $userNameLog = $this->userNameLogModel->data($params);
+
+        if ($userNameLog->rows > 0) {
+            $num = listNum($userNameLog->total, $params->page, $params->perPage);
+            foreach ($userNameLog->list as $i => $row) {
+                $row->num = $num--;
+                $userNameLog->list[$i] = $row;
+            }
+        }
+
+        return view('admin.user.log.name', [
+            'userNameLog' => $userNameLog,
+            'params' => $params
+        ]);
+    }
+
+    /**
+     * 이름 변경 이력 삭제
+     * @method DELETE
+     * @see /admin/user/log/name/destroy
+     */
+    public function destroy(Request $request)
+    {
+        $chk = $request->post('chk');
+
+        if ($chk) {
+            foreach ($chk as $logID) {
+                $this->userNameLogModel->find($logID)->delete();
+            }
+        }
+
+        $message = '이름 변경 이력 정보가 삭제되었습니다.';
+        return redirect()->route('admin.user.log.name.index')->with('message', $message);
+    }
+}

+ 33 - 0
app/Http/Controllers/AdminController.php

@@ -0,0 +1,33 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+
+class AdminController extends Controller
+{
+    /**
+     * Create a new controller instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        $this->middleware('auth');
+    }
+
+    /**
+     * Show the application dashboard.
+     *
+     * @return \Illuminate\Contracts\Support\Renderable
+     */
+    public function index(Request $request)
+    {
+        // 관리자만 접속 가능하다.
+        if(!$request->user()->is_admin) {
+            abort(401);
+        }
+
+        return view('admin.index');
+    }
+}

+ 212 - 0
app/Http/Controllers/ApiController.php

@@ -0,0 +1,212 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+use Illuminate\Support\Str;
+use App\Models\Config;
+use App\Models\User;
+use App\Models\Movie\MovieReview;
+use App\Models\DTO\ResponseData;
+use App\Rules\DeniedEmail;
+use App\Rules\SpecialCharLength;
+use App\Rules\UppercaseLength;
+use App\Rules\NumberLength;
+use App\Rules\AllowNickname;
+use Exception;
+
+class ApiController extends Controller
+{
+    public function __construct()
+    {
+    }
+
+    /**
+     * 로그인 확인
+     */
+    public function loginCheck(): int
+    {
+        return intval(auth()->check());
+    }
+
+    /**
+     * 금지 단어 확인
+     */
+    public function filterSpamKeyword(Request $request, ResponseData $response): ResponseData
+    {
+        $subject = $request->input('subject');
+        $content = $request->input('content');
+        $response->subject = "";
+        $response->content = "";
+        $spamWord = explode(',', trim((new Config)->item("spam_word")));
+
+        if ($spamWord) {
+            for ($i = 0; $i < count($spamWord); $i++) {
+                $str = trim($spamWord[$i]);
+                if ($subject) {
+                    $pos = stripos($subject, $str);
+                    if ($pos !== false) {
+                        $response->subject = $str;
+                        break;
+                    }
+                }
+                if ($content) {
+                    $pos = stripos($content, $str);
+                    if ($pos !== false) {
+                        $response->content = $str;
+                        break;
+                    }
+                }
+            }
+        }
+
+        return $response;
+    }
+
+    /**
+     * 중복 이메일 여부
+     */
+    public function isEmailAble(Request $request): bool
+    {
+        try{
+
+            $email = $request->input('email');
+            if(!$email) {
+                throw new Exception;
+            }
+
+            // 중복 여부
+            if((new User)->where('email', $email)->exists()) {
+                throw new Exception;
+            }
+
+            // 유효성 확인
+            if(!(new DeniedEmail)->passes(null, $email)) {
+                throw new Exception;
+            }
+
+            return true;
+        }catch(Exception) {
+            return false;
+        }
+    }
+
+    /**
+     * 비밀번호 유효성 검사
+     */
+    public function isPasswordAble(Request $request): bool
+    {
+        try{
+
+            $password = $request->input('password');
+            if(!$password) {
+                throw new Exception;
+            }
+
+            // 유효성 확인
+            if(!(new SpecialCharLength)->passes(null, $password)) {
+                throw new Exception;
+            }
+
+            if(!(new UppercaseLength)->passes(null, $password)) {
+                throw new Exception;
+            }
+
+            if(!(new NumberLength)->passes(null, $password)) {
+                throw new Exception;
+            }
+
+            return true;
+        }catch(Exception) {
+            return false;
+        }
+    }
+
+    /**
+     * 중복 닉네임 여부
+     */
+    public function isNicknameAble(Request $request): bool
+    {
+        try {
+
+            $nickname = $request->input('nickname');
+            if(!$nickname) {
+                throw new Exception;
+            }
+
+            // 중복 여부
+            if((new User)->where([
+                ['nickname', $nickname],
+                ['user_id', UID]
+            ])->exists()) {
+                throw new Exception;
+            }
+
+            // 유효성 확인
+            if(!(new AllowNickname)->passes(null, $nickname)) {
+                throw new Exception;
+            }
+
+            return true;
+        }catch(Exception) {
+            return false;
+        }
+    }
+
+    /**
+     * 정기 비밀번호 변경 다음에 하기
+     */
+    public function passwordCampaignSkip()
+    {
+        return (new User)->where('id', UID)->update([
+            'password_updated_at' => now()
+        ]);
+    }
+
+    /**
+     * TinyMCE 에디터 이미지 첨부 화면
+     */
+    public function uploader()
+    {
+        return view('component.uploader');
+    }
+
+    /**
+     * 영화 평점 및 후기
+     * @method GET
+     * @see /api/moview/review/latest/{movieCd}
+     */
+    public function movieReviewLatest(string $base64, MovieReview $movieReviewModel)
+    {
+        try {
+
+            if(!$base64) {
+                throw new Exception('잘못된 접근입니다. [1]');
+            }
+
+            $movieCd = base64_decode($base64);
+
+            if(!$movieCd) {
+                throw new Exception('잘못된 접근입니다. [2]');
+            }
+
+            $latest = $movieReviewModel->latest($movieCd, UID);
+
+            if($latest->total > 0) {
+                foreach($latest->list as &$row) {
+                    $length = strlen($row->sid);
+                    $row->owner = ($row->name . '(' . Str::mask($row->sid, '*', intval($length / 2), $length) . ')');
+                    $row->rate = round($row->rate, 2);
+                    $row->createdAt = date('Y.m.d', strtotime($row->created_at));
+                }
+            }
+
+            return view(layout('movie.review.latest'), [
+                'latest' => $latest
+            ]);
+
+        }catch(Exception $e) {
+            abort($e->getCode(), $e->getMessage());
+        }
+    }
+}

+ 40 - 0
app/Http/Controllers/Auth/ConfirmPasswordController.php

@@ -0,0 +1,40 @@
+<?php
+
+namespace App\Http\Controllers\Auth;
+
+use App\Http\Controllers\Controller;
+use App\Providers\RouteServiceProvider;
+use Illuminate\Foundation\Auth\ConfirmsPasswords;
+
+class ConfirmPasswordController extends Controller
+{
+    /*
+    |--------------------------------------------------------------------------
+    | Confirm Password Controller
+    |--------------------------------------------------------------------------
+    |
+    | This controller is responsible for handling password confirmations and
+    | uses a simple trait to include the behavior. You're free to explore
+    | this trait and override any functions that require customization.
+    |
+    */
+
+    use ConfirmsPasswords;
+
+    /**
+     * Where to redirect users when the intended url fails.
+     *
+     * @var string
+     */
+    protected $redirectTo = RouteServiceProvider::HOME;
+
+    /**
+     * Create a new controller instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        $this->middleware('auth');
+    }
+}

+ 36 - 0
app/Http/Controllers/Auth/ForgotPasswordController.php

@@ -0,0 +1,36 @@
+<?php
+
+namespace App\Http\Controllers\Auth;
+
+use App\Http\Controllers\Controller;
+use Illuminate\Foundation\Auth\SendsPasswordResetEmails;
+use Illuminate\Http\Request;
+
+class ForgotPasswordController extends Controller
+{
+    /*
+    |--------------------------------------------------------------------------
+    | Password Reset Controller
+    |--------------------------------------------------------------------------
+    |
+    | This controller is responsible for handling password reset emails and
+    | includes a trait which assists in sending these notifications from
+    | your application to your users. Feel free to explore this trait.
+    |
+    */
+
+    use SendsPasswordResetEmails;
+
+    /**
+     * 이메인 인증 키 변경
+     * 탈퇴 회원은 제외
+     */
+    public function credentials(Request $request)
+    {
+        return [
+            'email' => $request->input('email'),
+            'is_withdraw' => 0,
+            'deleted_at' => null
+        ];
+    }
+}

+ 175 - 0
app/Http/Controllers/Auth/LoginController.php

@@ -0,0 +1,175 @@
+<?php
+
+namespace App\Http\Controllers\Auth;
+
+use Illuminate\Foundation\Auth\AuthenticatesUsers;
+use Illuminate\Validation\ValidationException;
+use Illuminate\Support\Facades\Auth;
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Providers\RouteServiceProvider;
+use App\Models\LoginLog;
+use DateTime;
+
+class LoginController extends Controller
+{
+    /*
+    |--------------------------------------------------------------------------
+    | Login Controller
+    |--------------------------------------------------------------------------
+    |
+    | This controller handles authenticating users for the application and
+    | redirecting them to your home screen. The controller uses a trait
+    | to conveniently provide its functionality to your applications.
+    |
+    */
+
+    use AuthenticatesUsers;
+
+    /**
+     * Where to redirect users after login.
+     *
+     * @var string
+     */
+    protected $redirectTo = RouteServiceProvider::HOME;
+
+    /**
+     * Create a new controller instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        $this->middleware(['guest', 'front'])->except('logout');
+    }
+
+    /**
+     * 로그인 시 uid 검증 사용
+     */
+    public function username()
+    {
+        return 'email';
+    }
+
+	/**
+     * Show the application's login form.
+     *
+     * @return \Illuminate\View\View
+     */
+    public function showLoginForm()
+    {
+        $env = strtoupper(env('DEVELOPER_ENV'));
+
+        $email = "";
+        $password = "";
+
+        if($env == 'LOCAL') {
+            $email = 'playedcompany@gmail.com';
+            $password = '12341234';
+        }
+
+        return view('auth.login', [
+            'email' => $email,
+            'password' => $password,
+        ]);
+    }
+
+    /**
+     * 로그인 창에서 처리 요청 시 처리 방식을 변경한다.
+     */
+    public function credentials(Request $request)
+    {
+        $inputParam = $request->only($this->username(), 'password');
+
+        return [
+            'email' => $inputParam['email'],
+            'password' => $inputParam['password'],
+            'is_withdraw' => 0,
+            'deleted_at' => null
+        ];
+    }
+
+    /**
+     * 로그인 시 마지막 로그인 시간 기록
+     */
+    public function authenticated(Request $request, $user)
+    {
+        // 이메일 인증을 받았는지 확인
+        if(!$user->hasVerifiedEmail()) {
+            Auth::logout(); // 강제 로그아웃
+            return view('auth.verify');
+        }
+
+        // 로그인 성공 기록
+        (new LoginLog)->setSucceedLog($user->email, "로그인 성공");
+
+        // 로그인 일시, IP 갱신
+        $user->update([
+            'last_login_at' => now(),
+            'last_login_ip' => $request->getClientIp()
+        ]);
+
+        // 비밀번호 갱신 주기 확인
+        $chgPwdDay = (int)config('change_password_day', 0);
+        if($chgPwdDay > 0) {
+            $diffDay = (int)((new DateTime($user->password_updated_at))->diff(new DateTime)->format("%r%a"));
+            if($diffDay >= $chgPwdDay) {
+                return redirect()->route('account.password.campaign');
+            }
+        }
+    }
+
+    /**
+     * 로그인 실패 시 처리
+     */
+    protected function sendFailedLoginResponse(Request $request)
+    {
+        // 로그인 시도 제한 여부
+        $msg = ($this->hasTooManyLoginAttempts($request) ? '로그인 시도 횟수 초과' : '로그인 실패, ID/PW 불일치');
+        $email = $request->post('email');
+
+        // 로그인 실패 기록
+        (new LoginLog)->setFailedLog($email, $msg);
+
+        // 로그인 실패 기록
+        throw ValidationException::withMessages([
+            $this->username() => [trans('auth.failed')],
+        ]);
+    }
+
+    /**
+     * 로그인 후 이동 주소
+     */
+    public function redirectTo()
+    {
+        return request()->post('callback') ?? DIRECTORY_SEPARATOR . config('url_after_login');
+    }
+
+    /**
+     * 로그인 시도 제한 횟수
+     */
+    public function maxAttempts()
+    {
+        return intval(config('max_login_try_count', 6));
+    }
+
+    /**
+     * 로그인 시도 제한 시간
+     */
+    public function decayMinutes()
+    {
+        return intval(config('max_login_try_limit_second', 60) / 60);
+    }
+
+    /**
+     * 로그아웃 후 처리
+     */
+    protected function loggedOut(Request $request)
+    {
+        if($to = config('url_after_logout')) {
+            return redirect($to)->call(function() {
+                Auth::logout();
+            });
+        }
+    }
+}

+ 150 - 0
app/Http/Controllers/Auth/RegisterController.php

@@ -0,0 +1,150 @@
+<?php
+
+namespace App\Http\Controllers\Auth;
+
+use Illuminate\Foundation\Auth\RegistersUsers;
+use Illuminate\Support\Facades\Validator;
+use Illuminate\Http\Request;
+use Illuminate\Auth\Events\Registered;
+use Illuminate\Http\JsonResponse;
+use App\Http\Controllers\Controller;
+use App\Providers\RouteServiceProvider;
+use App\Models\User;
+use App\Models\Document;
+use App\Rules\AllowNickname;
+use App\Rules\NumberLength;
+use App\Rules\SpecialCharLength;
+use App\Rules\UppercaseLength;
+use App\Rules\DeniedEmail;
+
+class RegisterController extends Controller
+{
+    /*
+    |--------------------------------------------------------------------------
+    | Register Controller
+    |--------------------------------------------------------------------------
+    |
+    | This controller handles the registration of new users as well as their
+    | validation and creation. By default this controller uses a trait to
+    | provide this functionality without requiring any additional code.
+    |
+    */
+
+    use RegistersUsers;
+
+    /**
+     * Where to redirect users after registration.
+     *
+     * @var string
+     */
+    protected $redirectTo = RouteServiceProvider::HOME;
+
+    /**
+     * Create a new controller instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        $this->middleware('guest');
+    }
+
+    /**
+     * Show the application registration form.
+     *
+     * @return \Illuminate\View\View
+     */
+    public function showRegistrationForm()
+    {
+        // 개인정보처리방침
+        $privacy = (new Document)->findByCode('privacy');
+
+        // 비밀번호 조건 확인
+        $passwordMinLength = config('password_min_length');
+        $passwordUppercaseLength = config('password_uppercase_length');
+        $passwordNumbersLength = config('password_numbers_length');
+        $passwordSpecialcharsLength = config('password_specialchars_length');
+
+        $passwordGuideTip = "";
+        if($passwordMinLength > 0) {
+            $passwordGuideTip .= sprintf('최소 %d자 이상, ', $passwordMinLength);
+        }
+        if($passwordUppercaseLength > 0) {
+            $passwordGuideTip .= sprintf('대문자 %d자 이상, ', $passwordUppercaseLength);
+        }
+        if($passwordNumbersLength > 0) {
+            $passwordGuideTip .= sprintf('숫자 %d자 이상, ', $passwordNumbersLength);
+        }
+        if($passwordSpecialcharsLength > 0) {
+            $passwordGuideTip .= sprintf('특수문자 %d자 이상, ', $passwordSpecialcharsLength);
+        }
+        $passwordGuideTip = rtrim($passwordGuideTip, ', ');
+
+        return view('auth.register', [
+            'privacy' => $privacy,
+            'passwordGuideTip' => $passwordGuideTip
+        ]);
+    }
+
+    /**
+     * Get a validator for an incoming registration request.
+     *
+     * @param  array  $data
+     * @return \Illuminate\Contracts\Validation\Validator
+     */
+    protected function validator(array $data)
+    {
+        return Validator::make($data, [
+            'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email', new DeniedEmail],
+            'nickname' => ['required', 'string', 'unique:users,nickname', 'min:2', 'max:20', new AllowNickname],
+            'password' => ['required', 'string', 'min:' . config('password_min_length', 4), 'confirmed', new NumberLength, new SpecialCharLength, new UppercaseLength],
+            'agree_1' => 'required|numeric|in:1',
+            'agree_2' => 'required|numeric|in:2'
+        ], [], [
+            'email' => '이메일',
+            'nickname' => '닉네임',
+            'password' => '비밀번호',
+            'agree_1' => '이용약관 동의',
+            'agree_2' => '개인정보처리방침 동의'
+        ]);
+    }
+
+    /**
+     * Create a new user instance after a valid registration.
+     *
+     * @param  array  $data
+     * @return \App\Models\User
+     */
+    protected function create(array $data): mixed
+    {
+        return (new User)->register($data);
+    }
+
+    /**
+     * Handle a registration request for the application.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\JsonResponse
+     */
+    public function register(Request $request)
+    {
+        // 회원가입 차단 확인
+        if(config('use_register_block')) {
+            return back()->withErrors('현재 회원 신청이 차단되어 회원가입을 할 수 없습니다. 관리자에게 문의하십시오.');
+        }
+
+        $this->validator($request->all())->validate();
+
+        event(new Registered($user = $this->create($request->all())));
+
+        $this->guard()->login($user);
+
+        if ($response = $this->registered($request, $user)) {
+            return $response;
+        }
+
+        return $request->wantsJson()
+            ? new JsonResponse([], 201)
+            : redirect($this->redirectPath());
+    }
+}

+ 54 - 0
app/Http/Controllers/Auth/ResetPasswordController.php

@@ -0,0 +1,54 @@
+<?php
+
+namespace App\Http\Controllers\Auth;
+
+use App\Http\Controllers\Controller;
+use App\Providers\RouteServiceProvider;
+use Illuminate\Foundation\Auth\ResetsPasswords;
+use Illuminate\Support\Facades\Hash;
+use Illuminate\Http\Request;
+
+class ResetPasswordController extends Controller
+{
+    /*
+    |--------------------------------------------------------------------------
+    | Password Reset Controller
+    |--------------------------------------------------------------------------
+    |
+    | This controller is responsible for handling password reset requests
+    | and uses a simple trait to include this behavior. You're free to
+    | explore this trait and override any methods you wish to tweak.
+    |
+    */
+
+    use ResetsPasswords;
+
+    /**
+     * Where to redirect users after resetting their password.
+     *
+     * @var string
+     */
+    protected $redirectTo = RouteServiceProvider::HOME;
+
+    /**
+     * 이메일 인증 키 변경
+     */
+    protected function credentials(Request $request)
+    {
+        return [
+            'email' => $request->input('email'),
+            'password' => $request->input('password'),
+            'password_confirmation' => $request->input('password_confirmation'),
+            'token' => $request->input('token'),
+        ];
+    }
+
+    /**
+     * 비밀번호 변경 처리
+     */
+    protected function setUserPassword($user, $password)
+    {
+        $user->password = Hash::make($password);
+        $user->password_updated_at = now();
+    }
+}

+ 42 - 0
app/Http/Controllers/Auth/VerificationController.php

@@ -0,0 +1,42 @@
+<?php
+
+namespace App\Http\Controllers\Auth;
+
+use App\Http\Controllers\Controller;
+use App\Providers\RouteServiceProvider;
+use Illuminate\Foundation\Auth\VerifiesEmails;
+
+class VerificationController extends Controller
+{
+    /*
+    |--------------------------------------------------------------------------
+    | Email Verification Controller
+    |--------------------------------------------------------------------------
+    |
+    | This controller is responsible for handling email verification for any
+    | user that recently registered with the application. Emails may also
+    | be re-sent if the user didn't receive the original email message.
+    |
+    */
+
+    use VerifiesEmails;
+
+    /**
+     * Where to redirect users after verification.
+     *
+     * @var string
+     */
+    protected $redirectTo = RouteServiceProvider::HOME;
+
+    /**
+     * Create a new controller instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        $this->middleware('auth');
+        $this->middleware('signed')->only('verify');
+        $this->middleware('throttle:6,1')->only('verify', 'resend');
+    }
+}

+ 66 - 0
app/Http/Controllers/Board/BoardController.php

@@ -0,0 +1,66 @@
+<?php
+
+namespace App\Http\Controllers\Board;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Http\Requests\BoardRequest;
+use App\Services\BoardService;
+use App\Models\Board;
+use App\Models\BoardMeta;
+
+class BoardController extends Controller
+{
+    protected BoardService $boardService;
+
+    protected string $code; // 게시판 코드
+    protected Board $board; // 게시판 정보
+    protected BoardMeta $boardMeta; // 게시판 설정 값
+
+    public function __construct(Request $request, BoardService $boardService)
+    {
+        $this->middleware(['front', 'authed']);
+
+        // 게시글 접근 권한 확인
+        $this->middleware('board.access')->only('index');
+
+        $this->boardService = $boardService;
+
+        $this->code = $request->route('code');
+    }
+
+    /**
+     * 공지사항 / 게시글 목록 또는 검색 결과
+     * @method GET
+     * @see /board/{code}
+     */
+    public function index(BoardRequest $request)
+    {
+        $this->board = $request->board;
+        $this->boardMeta = $request->boardMeta;
+        $boardID = $this->board->getKey();
+
+        // 분류
+        $categories = $this->boardService->categories($boardID);
+
+        // 공지사항
+        $notices = $this->boardService->notices($request);
+
+		// 일반 게시글
+		$posts = $this->boardService->posts($request);
+
+        $listURL = route('board.list', $this->code);
+        $writeURL = route('board.post.write', $this->code);
+
+        return view(layout('board.index'), [
+            'board' => $this->board,
+            'boardMeta' => $this->boardMeta,
+            'categories' => $categories,
+            'notices' => $notices,
+            'posts' => $posts,
+            'params' => $request,
+            'listURL' => $listURL,
+            'writeURL' => $writeURL
+        ]);
+    }
+}

+ 321 - 0
app/Http/Controllers/Board/CommentController.php

@@ -0,0 +1,321 @@
+<?php
+
+namespace App\Http\Controllers\Board;
+
+use Illuminate\Http\Request;
+use App\Http\Controllers\Controller;
+use App\Http\Requests\CommentRequest;
+use App\Http\Traits\PagingTrait;
+use App\Services\BoardService;
+use App\Services\PostService;
+use App\Services\CommentService;
+use App\Models\Board;
+use App\Models\BoardMeta;
+use App\Models\Comment;
+use App\Models\DTO\SearchData;
+use App\Models\DTO\ResponseData;
+
+class CommentController extends Controller
+{
+    use PagingTrait;
+
+    protected BoardService $boardService;
+    protected PostService $postService;
+    protected CommentService $commentService;
+
+    protected string $code; // 게시판 코드
+    protected int $postID; // 게시글 번호
+    protected Board $board; // 게시판 정보
+    protected BoardMeta $boardMeta; // 게시판 설정 값
+    protected ?Comment $comment; // 게시판 설정 값
+
+    public function __construct(
+        BoardService $boardService,
+        PostService $postService,
+        CommentService $commentService
+    ) {
+        $this->middleware(['front', 'authed']);
+
+        // 인증 필수
+        $this->middleware('auth')->only([
+            'blame', 'like', 'dislike'
+        ]);
+
+        // 댓글 읽기 권한 확인
+        $this->middleware('comment.access')->only('index');
+
+        // 댓글 쓰기 권한 확인
+        $this->middleware('comment.create')->only(['store', 'reply']);
+
+        // 댓글 수정 권한 확인
+        $this->middleware('comment.update')->only('update');
+
+        // 댓글 삭제 권한 확인
+        $this->middleware('comment.delete')->only('delete');
+
+        // 댓글/답글 속도 제한
+        $this->middleware('throttle:commentWrite')->only(['store', 'reply']);
+
+        $this->boardService = $boardService;
+        $this->postService = $postService;
+        $this->commentService = $commentService;
+    }
+
+    /**
+     * 댓글 목록
+     * @method GET
+     * @see /board/{code}/{postID}/comment
+     */
+    public function index(Request $request, string $code, int $postID)
+    {
+        $params = SearchData::fromRequest($request);
+        $this->board = $request->board;
+        $this->boardMeta = $request->boardMeta;
+
+        if($perPage = $this->boardMeta->item('comment_per_page', 0)) {
+            $params->perPage = intval($perPage);
+            $params->offset = $this->getPageOffset($params->page, $params->perPage);
+        }
+
+        if($pageCount = $this->boardMeta->item('comment_page_count', 0)) {
+            $params->pageCount = intval($pageCount);
+        }
+
+        $params->sort = intval($request->query('sort', 1)); // 정렬 (최신순, 공감순, 댓글순)
+
+        $comment = $this->commentService->list($code, $postID, $params);
+
+        return view(layout('board.comment.index'), [
+            'boardMeta' => $this->boardMeta,
+            'comment' => $comment,
+            'params' => $params
+        ]);
+    }
+
+    /**
+     * 댓글 등록
+     * @method POST
+     * @see /board/{code}/{postID}/comment/store
+     */
+    public function store(CommentRequest $request, ResponseData $response, string $code): ResponseData
+    {
+        $this->board = $request->board;
+        $this->boardMeta = $request->boardMeta;
+
+        // 댓글 등록
+        $result = $this->commentService->register($request, $response);
+
+        if(!$result->success) {
+            return $result;
+        }
+
+        $this->comment = $result->comment;
+
+        // 댓글 작성 경험치 처리
+        $this->commentService->setUserExp($this->comment, $this->comment->user, EXP_TYPE_8);
+
+        // 최근 게시글 캐시 삭제
+        $this->boardService->clearLatest($code);
+
+        $result->cid = $this->comment->id;
+
+        // 이메일 발송
+        $this->commentService->sendEmailNotify($this->comment, (
+            $this->boardMeta->item('use_personal', 0) ? SEND_MAIL_FORM_TYPE_12 : SEND_MAIL_FORM_TYPE_8
+        ));
+
+        unset($result->comment, $this->comment);
+
+        return $result;
+    }
+
+    /**
+     * 댓글 수정
+     * @method PUT
+     * @see /board/{code}/{postID}/comment/update
+     */
+    public function update(CommentRequest $request, ResponseData $response): ResponseData
+    {
+        // 댓글 수정
+        $result = $this->commentService->updater($request, $response);
+
+        if(!$result->success) {
+            return $result;
+        }
+
+        $result->cid = $request->comment->id;
+
+        unset($result->comment);
+
+        return $result;
+    }
+
+    /**
+     * 댓글 답글
+     * @method POST
+     * @see /board/{code}/{postID}/comment/reply
+     */
+    public function reply(CommentRequest $request, ResponseData $response, string $code): ResponseData
+    {
+        $this->board = $request->board;
+        $this->boardMeta = $request->boardMeta;
+
+        // 댓글 답글
+        $result = $this->commentService->reply($request, $response);
+
+        if(!$result->success) {
+            return $result;
+        }
+
+        $this->comment = $result->comment;
+
+        // 댓글 작성 경험치 처리
+        $this->commentService->setUserExp($this->comment, $this->comment->user, EXP_TYPE_8);
+
+        // 최근 게시글 캐시 삭제
+        $this->boardService->clearLatest($code);
+
+        $result->cid = $this->comment->id;
+
+        // 이메일 발송
+        $this->commentService->sendEmailNotify($this->comment, (
+            $this->boardMeta->item('use_personal', 0) ? SEND_MAIL_FORM_TYPE_12 : SEND_MAIL_FORM_TYPE_8
+        ));
+
+        unset($result->comment, $this->comment);
+
+        return $result;
+    }
+
+    /**
+     * 댓글 삭제
+     * @method DELETE
+     * @see /board/{code}/{postID}/comment/delete
+     */
+    public function delete(Request $request, ResponseData $response, string $code): ResponseData
+    {
+        $request->validate([
+            'bid' => 'required|numeric|exists:tb_board,id',
+            'pid' => 'required|numeric|exists:tb_post,id',
+            'cid' => 'required|numeric|exists:tb_comment,id'
+        ]);
+
+        $result = $this->commentService->delete($request, $response);
+
+        if(!$result->success) {
+            return $result;
+        }
+
+        $this->comment = $request->comment;
+
+        // 관리자인 경우
+        if(IS_ADMIN) {
+            $expType = EXP_TYPE_11;
+        }else{
+            $expType = EXP_TYPE_12;
+        }
+
+        // 댓글 삭제 경험치 처리
+        $this->commentService->setUserExp($this->comment, $this->comment->user, $expType);
+
+        // 최근 게시글 캐시 삭제
+        $this->boardService->clearLatest($code);
+
+        unset($result->comment, $this->comment);
+
+        return $result;
+    }
+
+    /**
+     * 댓글 신고
+     * @method POST
+     * @see /board/{code}/{postID}/comment/blame
+     */
+    public function blame(Request $request, ResponseData $response): ResponseData
+    {
+        $request->validate([
+            'bid' => 'required|numeric|exists:tb_board,id',
+            'pid' => 'required|numeric|exists:tb_post,id',
+            'cid' => 'required|numeric|exists:tb_comment,id',
+            'type' => 'required|numeric|in:1,2,3,4,5,6,7,8,9',
+            'reason' => 'nullable|string|max:1000'
+        ]);
+
+        $result = $this->commentService->blame($request, $response);
+
+        if(!$result->success) {
+            return $result;
+        }
+
+        $this->comment = $request->comment;
+
+        // 이메일 발송
+        $this->commentService->sendEmailNotify($this->comment, SEND_MAIL_FORM_TYPE_10);
+
+        return $result;
+    }
+
+    /**
+     * 댓글 좋아요
+     * @method POST
+     * @see /board/{code}/{postID}/comment/like
+     */
+    public function like(Request $request, ResponseData $response): ResponseData
+    {
+        $request->validate([
+            'bid' => 'required|numeric|exists:tb_board,id',
+            'pid' => 'required|numeric|exists:tb_post,id',
+            'cid' => 'required|numeric|exists:tb_comment,id'
+        ]);
+
+        $result = $this->commentService->like($request, $response);
+
+        if(!$result->success) {
+            return $result;
+        }
+
+        $this->comment = $result->comment;
+
+        // 댓글 좋아요 경험치 처리
+        $this->commentService->setUserExp($this->comment, $request->user(), EXP_TYPE_21);
+
+        // 댓글 좋아요 받음 (상대방)
+        $this->commentService->setUserExp($this->comment, $this->comment->user, EXP_TYPE_23);
+
+        unset($result->comment, $this->comment);
+
+        return $result;
+    }
+
+    /**
+     * 댓글 싫어요
+     * @method POST
+     * @see /board/{code}/{postID}/comment/dislike
+     */
+    public function dislike(Request $request, ResponseData $response): ResponseData
+    {
+        $request->validate([
+            'bid' => 'required|numeric|exists:tb_board,id',
+            'pid' => 'required|numeric|exists:tb_post,id',
+            'cid' => 'required|numeric|exists:tb_comment,id'
+        ]);
+
+        $result = $this->commentService->dislike($request, $response);
+
+        if(!$result->success) {
+            return $result;
+        }
+
+        $this->comment = $result->comment;
+
+        // 댓글 싫어요 경험치 처리
+        $this->commentService->setUserExp($this->comment, $request->user(), EXP_TYPE_22);
+
+        // 댓글 싫어요 받음 (상대방)
+        $this->commentService->setUserExp($this->comment, $this->comment->user, EXP_TYPE_24);
+
+        unset($result->comment, $this->comment);
+
+        return $result;
+    }
+}

+ 420 - 0
app/Http/Controllers/Board/PostController.php

@@ -0,0 +1,420 @@
+<?php
+
+namespace App\Http\Controllers\Board;
+
+use Illuminate\Http\Request;
+use Illuminate\Http\JsonResponse;
+use App\Http\Controllers\Controller;
+use App\Http\Requests\PostRequest;
+use App\Services\BoardService;
+use App\Services\PostService;
+use App\Models\Board;
+use App\Models\Post;
+use App\Models\BoardMeta;
+use App\Models\DTO\ResponseData;
+
+class PostController extends Controller
+{
+    protected BoardService $boardService;
+    protected PostService $postService;
+
+    protected string $code; // 게시판 코드
+    protected Board $board; // 게시판 정보
+    protected BoardMeta $boardMeta; // 게시판 설정 값
+    protected ?Post $post;
+
+    public function __construct(
+        BoardService $boardService,
+        PostService $postService
+    ) {
+        $this->middleware(['front', 'authed']);
+
+        // 인증 필수
+        $this->middleware('auth')->only([
+            'bookmark', 'blame', 'like', 'dislike'
+        ]);
+
+        // 게시글 보기 권한 확인
+        $this->middleware('post.access')->only('view');
+
+        // 게시글 쓰기 권한 확인
+        $this->middleware('post.create')->only(['write', 'store']);
+
+        // 게시글 수정 권한 확인
+        $this->middleware('post.update')->only(['edit', 'update']);
+
+        // 게시글 삭제 권한 확인
+        $this->middleware('post.delete')->only('delete');
+
+        // 게시글 이미치 첨부 권한 확인
+        $this->middleware('post.uploader')->only('uploader');
+
+        // 게시글 파일 다운로드 권한 확인
+        $this->middleware('post.download')->only('download');
+
+        $this->boardService = $boardService;
+        $this->postService = $postService;
+    }
+
+    /**
+     * 게시글 보기
+     * @method GET
+     * @see /board/{code}/{postID}
+     */
+    public function view(Request $request, string $code, int $postID)
+    {
+        $this->board = $request->board;
+        $this->boardMeta = $request->boardMeta;
+        $this->post = $request->post;
+
+        // 댓글 위치 ID
+        $id = $request->get('id', 0);
+
+        if(DEVICE_TYPE_1) {
+            $perPage = $request->get('per_page', $this->boardMeta->item('list_per_page', DEFAULT_LIST_PER_PAGE));
+            $pageCount = $this->boardMeta->item('list_page_count', DEFAULT_LIST_PAGE_COUNT);
+        }else{
+            $perPage = $request->get('per_page', $this->boardMeta->item('list_mobile_per_page',DEFAULT_LIST_PER_PAGE));
+            $pageCount = $this->boardMeta->item('list_page_mobile_count', DEFAULT_LIST_PAGE_COUNT);
+        }
+
+        $page = $request->get('page', $this->post->findPageNumber($postID));
+
+        $latest = $this->boardService->latest($code, $page, $perPage);
+
+        // 게시글 읽기 경험치 처리
+        $this->postService->setUserExp($this->post, $this->post->user, EXP_TYPE_16);
+
+        return view(layout('board.view'), [
+            'board' => $this->board,
+            'boardMeta' => $this->boardMeta,
+            'post' => $this->post,
+            'latest' => $latest, // 최근 게시글
+            'id' => $id, // 댓글 PK
+            'page' => $page, // 댓글 위치 페이지
+            'pageCount' => $pageCount,
+            'listURL' => route('board.list', $code),
+            'editURL' => route('board.post.edit', [$code, $postID]),
+            'deleteURL' => route('board.post.delete', [$code, $postID])
+        ]);
+    }
+
+    /**
+     * 게시글 작성
+     * @method GET
+     * @see /board/{code}/write
+     */
+    public function write(Request $request, string $code)
+    {
+        $this->board = $request->board;
+        $this->boardMeta = $request->boardMeta;
+
+        $category = $this->boardService->categories(
+            $this->board->getKey()
+        );
+
+        return view(layout('board.write'), [
+            'board' => $this->board,
+            'boardMeta' => $this->boardMeta,
+            'category' => $category,
+            'post' => null,
+            'listURL' => route('board.list', $code),
+            'storeURL' => route('board.post.store', $code),
+            'provisionURL' => route('document', 'provision'),
+            'boardRuleURL' => route('document', 'rule01')
+        ]);
+    }
+
+    /**
+     * 게시글 수정
+     * @method GET
+     * @see /board/{code}/{postID}/edit
+     */
+    public function edit(Request $request, string $code, int $postID)
+    {
+        $this->board = $request->board;
+        $this->boardMeta = $request->boardMeta;
+        $this->post = $request->post;
+
+        $category = $this->boardService->categories(
+            $this->board->getKey()
+        );
+
+        return view(layout('board.edit'), [
+            'board' => $this->board,
+            'boardMeta' => $this->boardMeta,
+            'category' => $category,
+            'post' => $this->post,
+            'listURL' => route('board.post.view', [$code, $postID]),
+            'updateURL' => route('board.post.update', [$code, $postID]),
+            'provisionURL' => route('document', 'provision'),
+            'boardRuleURL' => route('document', 'rule01')
+        ]);
+    }
+
+    /**
+     * 게시글 저장
+     * @method POST
+     * @see /board/{code}
+     */
+    public function store(PostRequest $request, ResponseData $response, string $code)
+    {
+        $this->board = $request->board;
+        $this->boardMeta = $request->boardMeta;
+
+        // 게시글 등록
+        $result = $this->postService->register($request, $response);
+
+        if(!$result->success) {
+            return back()->with('message', $result->message)->withInput();
+        }
+
+        $this->post = $result->post;
+
+        // 게시글 작성 경험치 처리
+        $this->postService->setUserExp($this->post, $this->post->user,EXP_TYPE_7);
+
+        // 파일 업로드 시 경험치 처리
+        if($this->post->file_rows > 0) {
+            $this->postService->setUserExp($this->post, $this->post->user,EXP_TYPE_13);
+        }
+
+        // 최근 게시글 캐시 삭제
+        $this->boardService->clearLatest($code);
+
+        // 이메일 발송
+        $this->postService->sendEmailNotify($this->post, (
+            $this->boardMeta->item('use_personal', 0) ? SEND_MAIL_FORM_TYPE_11 : SEND_MAIL_FORM_TYPE_7
+        ));
+
+        return redirect()->route('board.post.view', [
+            $code, $this->post
+        ]);
+    }
+
+    /**
+     * 게시글 수정 처리
+     * @method PUT
+     * @see /board/{code}/{postID}
+     */
+    public function update(PostRequest $request, ResponseData $response, string $code, int $postID)
+    {
+        // 게시글 수정
+        $result = $this->postService->updater($request, $response);
+
+        if(!$result->success) {
+            return back()->with('message', $result->message)->withInput();
+        }
+
+        // 최근 게시글 캐시 삭제
+        $this->boardService->clearLatest($code);
+
+        return redirect()->route('board.post.view', [
+            $code, $postID
+        ]);
+    }
+
+    /**
+     * 게시글 삭제
+     * @method DELETE
+     * @see /board/{code}/{postID}
+     */
+    public function delete(Request $request, ResponseData $response, string $code): ResponseData
+    {
+        $request->validate([
+            'bid' => 'required|numeric|exists:tb_board,id',
+            'pid' => 'required|numeric|exists:tb_post,id',
+        ]);
+
+        $result = $this->postService->delete($request, $response);
+
+        if(!$result->success) {
+            return $result;
+        }
+
+        $this->post = $request->post;
+
+        // 관리자인 경우
+        if(IS_ADMIN) {
+            $expType = EXP_TYPE_10;
+        }else{
+            $expType = EXP_TYPE_9;
+        }
+
+        // 게시글 삭제 경험치 처리
+        $this->postService->setUserExp($this->post, $this->post->user, $expType);
+
+        // 최근 게시글 캐시 삭제
+        $this->boardService->clearLatest($code);
+
+        return $result;
+    }
+
+    /**
+     * 게시글 즐겨찾기
+     * @method POST
+     * @see /board/{code}/{postID}/bookmark
+     */
+    public function bookmark(Request $request, ResponseData $response): ResponseData
+    {
+        $request->validate([
+            'bid' => 'required|numeric|exists:tb_board,id',
+            'pid' => 'required|numeric|exists:tb_post,id'
+        ]);
+
+        $result = $this->postService->bookmark($request, $response);
+
+        if(!$result->success) {
+            return $result;
+        }
+
+        return $result;
+    }
+
+    /**
+     * 게시글 신고
+     * @method POST
+     * @see /board/{code}/{postID}/blame
+     */
+    public function blame(Request $request, ResponseData $response): ResponseData
+    {
+        $request->validate([
+            'bid' => 'required|numeric|exists:tb_board,id',
+            'pid' => 'required|numeric|exists:tb_post,id',
+            'type' => 'required|numeric|in:1,2,3,4,5,6,7,8,9',
+            'reason' => 'nullable|string|max:1000'
+        ]);
+
+        $result = $this->postService->blame($request, $response);
+
+        if(!$result->success) {
+            return $result;
+        }
+
+        $this->post = $result->post;
+
+        // 이메일 발송
+        $this->postService->sendEmailNotify($this->post, SEND_MAIL_FORM_TYPE_9);
+
+        return $result;
+    }
+
+    /**
+     * 게시글 좋아요
+     * @method POST
+     * @see /board/{code}/{postID}/like
+     */
+    public function like(Request $request, ResponseData $response): ResponseData
+    {
+        $request->validate([
+            'bid' => 'required|numeric|exists:tb_board,id',
+            'pid' => 'required|numeric|exists:tb_post,id',
+            'type' => 'required|numeric|in:1,2'
+        ]);
+
+        $result = $this->postService->like($request, $response);
+
+        if(!$result->success) {
+            return $result;
+        }
+
+        $post = $result->post;
+
+        // 게시글 좋아요 경험치 처리
+        $this->postService->setUserExp($post, $request->user(), EXP_TYPE_17);
+
+        // 게시글 좋아요 받음 (상대방)
+        $this->postService->setUserExp($post, $post->user, EXP_TYPE_19);
+
+        return $result;
+    }
+
+    /**
+     * 게시글 싫어요
+     * @method POST
+     * @see /board/{code}/{postID}/dislike
+     */
+    public function dislike(Request $request, ResponseData $response): ResponseData
+    {
+        $request->validate([
+            'bid' => 'required|numeric|exists:tb_board,id',
+            'pid' => 'required|numeric|exists:tb_post,id',
+            'type' => 'required|numeric|in:1,2'
+        ]);
+
+        $result = $this->postService->dislike($request, $response);
+
+        if(!$result->success) {
+            return $result;
+        }
+
+        $post = $result->post;
+
+        // 게시글 싫어요 경험치 처리
+        $this->postService->setUserExp($post, $request->user(), EXP_TYPE_18);
+
+        // 게시글 싫어요 받음 (상대방)
+        $this->postService->setUserExp($post, $post->user, EXP_TYPE_20);
+
+        return $result;
+    }
+
+    /**
+     * 게시글 첨부파일 다운로드
+     * @method POST
+     * @see /board/{code}/{postID}/download
+     */
+    public function download(Request $request)
+    {
+        $posts = $request->validate([
+            'bid' => 'required|numeric|exists:tb_board,id',
+            'pid' => 'required|numeric|exists:tb_post,id',
+            'fid' => 'required|numeric|exists:tb_post_file,id'
+        ]);
+
+        $fileInfo = $this->postService->download($request);
+        if(!file_exists($fileInfo->path)) {
+            return back()->with('message', '해당 파일이 존재하지 않습니다.');
+        }
+
+        $post = $this->postService->postModel->findOrNew($posts['pid']);
+
+        // 게시글 다운로드 경험치 처리
+        $this->postService->setUserExp($post, $request->user(), EXP_TYPE_14);
+
+        // 게시글 파일 다운로드 시 (상대방)
+        $this->postService->setUserExp($post, $post->user, EXP_TYPE_15);
+
+        return response()->download($fileInfo->path, $fileInfo->name);
+    }
+
+    /**
+     * 게시글 Link 클릭
+     * @method POST
+     * @see /board/{code}/{postID}/link
+     */
+    public function link(Request $request): JsonResponse
+    {
+        $request->validate([
+            'bid' => 'required|numeric|exists:tb_board,id',
+            'pid' => 'required|numeric|exists:tb_post,id',
+            'lid' => 'required|numeric|exists:tb_post_link,id'
+        ]);
+
+        $href = $this->postService->linked($request);
+
+        return response()->json([
+            'href' => $href
+        ]);
+    }
+
+    /**
+     * 게시글 이미지 첨부
+     * @method GET
+     * @see /board/{code}/uploader
+     */
+    public function uploader()
+    {
+        return view('component.uploader');
+    }
+}

+ 15 - 0
app/Http/Controllers/Controller.php

@@ -0,0 +1,15 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
+use Illuminate\Foundation\Bus\DispatchesJobs;
+use Illuminate\Foundation\Validation\ValidatesRequests;
+use Illuminate\Routing\Controller as BaseController;
+
+require_once config_path("constants.php");
+
+class Controller extends BaseController
+{
+    use AuthorizesRequests, DispatchesJobs, ValidatesRequests;
+}

+ 33 - 0
app/Http/Controllers/DocumentController.php

@@ -0,0 +1,33 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\Document;
+
+class DocumentController extends Controller
+{
+    public function __construct()
+    {
+        $this->middleware('front');
+    }
+
+    /**
+     * 문서 보기
+     * @method GET
+     * @see /document/{code}
+     */
+    public function index(string $code, Document $documentModel)
+    {
+        $document = $documentModel->findByCode($code);
+
+        if (!$document->exists) {
+            abort(404);
+        }
+
+        return view(layout('document.index'), [
+            'title' => $document->subject,
+            'content' => $document->content,
+            'writeURL' => route('admin.page.document.edit', $document->id)
+        ]);
+    }
+}

+ 28 - 0
app/Http/Controllers/HomeController.php

@@ -0,0 +1,28 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+
+class HomeController extends Controller
+{
+    /**
+     * Create a new controller instance.
+     *
+     * @return void
+     */
+    public function __construct()
+    {
+        $this->middleware('auth');
+    }
+
+    /**
+     * Show the application dashboard.
+     *
+     * @return \Illuminate\Contracts\Support\Renderable
+     */
+    public function index()
+    {
+        return view('home');
+    }
+}

+ 145 - 0
app/Http/Controllers/MainController.php

@@ -0,0 +1,145 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Support\Facades\Http;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Http\Request;
+use App\Http\Traits\CommonTrait;
+use App\Models\Popup;
+use App\Models\Movie\MovieReview;
+use App\Models\DTO\SearchData;
+use App\Services\BoardService;
+
+class MainController extends Controller
+{
+    use CommonTrait;
+
+    private BoardService $boardService;
+    private Popup $popupModel;
+    private MovieReview $movieReviewModel;
+
+    /**
+     * Create a new controller instance.
+     *
+     * @return void
+     */
+    public function __construct(
+        BoardService $boardService, Popup $popupModel, MovieReview $movieReviewModel
+    ) {
+        $this->middleware('front');
+        
+        $this->boardService = $boardService;
+        $this->popupModel = $popupModel;
+        $this->movieReviewModel = $movieReviewModel;
+    }
+
+    /**
+     * 메인 페이지
+     * @method GET
+     * @see /
+     * @return \Illuminate\Contracts\Support\Renderable
+     */
+    public function index(Request $request)
+    {
+        // 팝업
+        $popups = $this->popupModel->list();
+
+        // 현재 상영작
+        $nowPlaying = $this->_nowPlaying();
+
+        // 공지사항
+        $notice = $this->boardService->latest('notice', 1, DEFAULT_LIST_PER_PAGE);
+
+        // 평점·후기
+        $review = $this->_review($request);
+
+        // 인기 영화
+        $trending = $this->_trending();
+
+        return view(layout('main'), [
+            'popups' => $popups,
+            'nowPlaying' => $nowPlaying,
+            'notice' => $notice,
+            'review' => $review,
+            'trending' => $trending
+        ]);
+    }
+
+    /**
+     * 현재 상영작
+     */
+    private function _nowPlaying(): object
+    {
+        return $this->_requestTMDB(TMDB_HOST . TMDB_GET_NOW_PLAYING);
+    }
+
+    /**
+     * 최근 평점·후기 목록
+     */
+    private function _review(Request $request): object
+    {
+        $cacheName = 'latest-review';
+        if (!$latest = Cache::get($cacheName)) {
+            $params = SearchData::fromRequest($request);
+            $params->sort = 2;
+            $latest = $this->movieReviewModel->list($params);
+
+            if($latest->total) {
+                $num = listNum($latest->total, $params->page, $params->perPage);
+                foreach ($latest->list as $i => $row) {
+                    $row->num = $num--;
+                    $row->viewURL = route('movie.review.show', base64_encode($row->movie_cd));
+                    $row->createdAt = $this->dateFormat($row->created_at, 'Y.m.d');
+                    $latest->list[$i] = $row;
+                }
+                $latest->list->filter();
+                $latest->pagination = null;
+                Cache::tags('latest-review')->put($cacheName, $latest, CACHE_EXPIRE_TIME);
+            }
+        }
+        return $latest;
+    }
+
+    /**
+     * 최신 트랜딩
+     */
+    private function _trending(): object
+    {
+        return $this->_requestTMDB(sprintf(TMDB_HOST . TMDB_TRENDING, 'movie', 'day'));
+    }
+
+    /**
+     * 최신 트랜딩
+     */
+    private function _requestTMDB(string $host): object
+    {
+        if(!$ret = Cache::get($host))
+        {
+            // 곧 개봉 예정
+            $response = Http::get($host, [
+                'api_key' => TMDB_API_KEY,
+                'language' => 'ko',
+                'region' => 'kr'
+            ]);
+
+            $list = [];
+
+            if($response->ok()) {
+                foreach(json_decode($response->body())->results as $row) {
+                    $row->posterURL = ('https://image.tmdb.org/t/p/original' . $row->poster_path);
+                    $row->backdropURL = ('https://image.tmdb.org/t/p/original' . $row->backdrop_path);
+                    $row->releaseDate = date('Y.m.d', strtotime($row->release_date));
+                    $row->voteAverage = round($row->vote_average, 2);
+                    $list[] = $row;
+                }
+            }
+
+            $ret = (object)['total' => count($list), 'list' => $list];
+
+            Cache::put($host, $ret, CACHE_EXPIRE_TIME);
+        }
+
+        return $ret;
+    }
+}

+ 187 - 0
app/Http/Controllers/Movie/RankController.php

@@ -0,0 +1,187 @@
+<?php
+
+namespace App\Http\Controllers\Movie;
+
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Http;
+use Illuminate\Support\Facades\Validator;
+use App\Http\Controllers\Controller;
+use App\Http\Traits\CommonTrait;
+use App\Models\DTO\Movie\BoxOfficeParams;
+use App\Models\DTO\Movie\InfoParams;
+use App\Models\Movie\MovieReview;
+use DateTime;
+use Exception;
+
+class RankController extends Controller
+{
+    use CommonTrait;
+
+    private string $host;
+    private MovieReview $movieReviewModel;
+
+    public function __construct(MovieReview $movieReviewModel) {
+        $this->middleware('front');
+
+        $this->host = env('MOVIE_API');
+        $this->movieReviewModel = $movieReviewModel;
+    }
+
+    /**
+     * 영화 인기순위
+     * @method GET
+     * @see /movie/rank
+     */
+    public function index(Request $request, BoxOfficeParams $params)
+    {
+        $rules = [
+            'date' => 'nullable|date_format:Y-m-d',
+            'nation' => 'nullable|in:k,f'
+        ];
+
+        $attributes = [
+            'date' => '기간',
+            'nation' => '구분'
+        ];
+
+        $message = [
+            'date.date_format' => '날짜 형식이 옳지 않습니다.',
+            'nation.in' => '구분 형식이 옳지 않습니다.',
+        ];
+
+        // 검색 유효성 검사
+        Validator::make($request->all(), $rules, $message, $attributes)->validated();
+        $date = $request->get('date', now()->format('Y-m-d'));
+        $nation = $request->get('nation', []);
+        $dateTime = new DateTime($date);
+
+        if ($dateTime->format('N') < 6) { // 평일
+            $url = ($this->host . MOVIE_DAILY_LIST);
+            if (now()->diffInDays($date) == 0) {
+                $dateTime->modify('-1 day');
+            }
+        } else { // 주말
+            $url = ($this->host . MOVIE_WEEK_LIST);
+            $dateTime->modify('-7 day');
+        }
+
+        // 검색 기간
+        $params->targetDt = $dateTime->format('Ymd');
+
+        // 국가 구분 값 계산
+        if ($nation) {
+            $nl = 0;
+            foreach ($nation as $n) {
+                $n = strtolower($n);
+                if ($n == 'k') {
+                    $params->repNationCd = 'K';
+                    $nl++;
+                }
+                if ($n == 'f') {
+                    $params->repNationCd = 'F';
+                    $nl++;
+                }
+            }
+            if ($nl == 2) {
+                $params->repNationCd = '';
+            }
+        }
+
+        // 검색 조건
+        $response = Http::get($url, $params->toArray());
+
+        $data = null;
+        if ($response->ok()) {
+            $data = json_decode($response->body());
+            if ($data->total > 0) {
+                $queryString = $request->getQueryString();
+                foreach ($data->list as $row) {
+                    $row->OpenDt = date('Y.m.d', strtotime($row->OpenDt));
+                    if (isset($row->DailyID)) {
+                        $id = base64_encode($row->DailyID . '|daily');
+                    } else {
+                        $id = base64_encode($row->WeeklyID . '|weekly');
+                    }
+                    $row->viewURL = route('movie.rank.show', $id);
+                    if ($queryString) {
+                        $row->viewURL .= ('?' . $queryString);
+                    }
+                }
+            }
+        }
+
+    
+
+        return view(layout('movie.rank.index'), [
+            'data' => $data,
+            'date' => $date,
+            'nation' => $nation
+        ]);
+    }
+
+    /**
+     * 영화 인기순위 보기
+     * @method GET
+     * @see /movie/rank/{daily_id|weekly_id}
+     */
+    public function show(string $base64, Request $request, InfoParams $params)
+    {
+        try {
+
+            if(!$base64) {
+                throw new Exception('잘못된 접근입니다. [1]');
+            }
+
+            [$id, $type] = explode('|', base64_decode($base64));
+
+            if(!$id || !$type) {
+                throw new Exception('잘못된 접근입니다. [2]');
+            }
+
+            $listURL = route('movie.rank.index');
+            if($queryString = $request->getQueryString()) {
+                $listURL .= ('?' . $queryString);
+            }
+
+            // 검색 조건
+            if($type == 'daily') {
+                $url = ($this->host . MOVIE_DAILY_INFO);
+            }else{
+                $url = ($this->host . MOVIE_WEEKLY_INFO);
+            }
+
+            $params->{$type. 'ID'} = $id;
+            $response = Http::get($url, $params->toArray());
+
+            if(!$response->ok()) {
+                throw new Exception('요청한 정보가 없습니다.');
+            }
+
+            $data = json_decode($response->body());
+            $stats = $data->stats;
+            $info = $data->info;
+            $detail = $data->detail;
+
+            $movieCd = base64_encode($info->MovieCd);
+            $movieNm = $info->MovieNm;
+            if($info->MovieNmEn) {
+                $movieNm .= (' (' . $info->MovieNmEn . ')');
+            }
+
+            $avgRate = $this->movieReviewModel->getAvgRate($info->MovieCd);
+
+            return view(layout('movie.rank.show'), [
+                'movieCd' => $movieCd,
+                'movieNm' => $movieNm,
+                'stats' => $stats,
+                'info' => $info,
+                'detail' => $detail,
+                'listURL' => $listURL,
+                'avgRate' => $avgRate
+            ]);
+
+        }catch(Exception $e) {
+            abort($e->getCode(), $e->getMessage());
+        }
+    }
+}

+ 457 - 0
app/Http/Controllers/Movie/ReviewController.php

@@ -0,0 +1,457 @@
+<?php
+
+namespace App\Http\Controllers\Movie;
+
+use Illuminate\Http\Request;
+use Illuminate\Support\Str;
+use App\Http\Controllers\Controller;
+use App\Http\Traits\CommonTrait;
+use App\Models\Movie\Movie;
+use App\Models\Movie\MovieInfo;
+use App\Models\Movie\MovieDetail;
+use App\Models\Movie\MovieReview;
+use App\Models\Movie\MovieLike;
+use App\Models\Movie\MovieBlame;
+use App\Models\DTO\ResponseData;
+use App\Models\DTO\SearchData;
+use App\Rules\FilterSpamKeyword;
+use Exception;
+
+class ReviewController extends Controller
+{
+    use CommonTrait;
+
+    private Movie $movieModel;
+    private MovieInfo $movieInfoModel;
+    private MovieDetail $movieDetailModel;
+    private MovieReview $movieReviewModel;
+    private MovieLike $movieLikeModel;
+
+    public function __construct(
+        Movie $movieModel,
+        MovieInfo $movieInfoModel,
+        MovieDetail $movieDetailModel,
+        MovieReview $movieReviewModel,
+        MovieLike $movieLikeModel
+    ) {
+        $this->middleware('front');
+
+        $this->movieModel = $movieModel;
+        $this->movieInfoModel = $movieInfoModel;
+        $this->movieDetailModel = $movieDetailModel;
+        $this->movieReviewModel = $movieReviewModel;
+        $this->movieLikeModel = $movieLikeModel;
+    }
+
+    /**
+     * 영화 평점·후기 목록
+     * @method GET
+     * @see /movie/review
+     */
+    public function index(Request $request)
+    {
+        $params = SearchData::fromRequest($request);
+        $params->userID = UID;
+        $params->sort = $request->get('sort', 1);
+
+        $reviews = $this->movieReviewModel->list($params);
+        if($reviews->rows > 0) {
+            foreach($reviews->list as &$row) {
+                $length = strlen($row->sid);
+                $row->owner = ($row->name . '(' . Str::mask($row->sid, '*', intval($length / 2), $length) . ')');
+                $row->createdAt = date('Y.m.d', strtotime($row->created_at));
+                $row->isLike = $this->movieLikeModel->isLike($row->id, UID);
+                $row->isDisLike = $this->movieLikeModel->isDisLike($row->id, UID);
+                $row->viewURL = route('movie.search.show', base64_encode($row->movie_cd));
+            }
+        }
+
+        return view(layout('movie.review.index'), [
+            'params' => $params,
+            'reviews' => $reviews
+        ]);
+    }
+
+    /**
+     * 영화 평점·후기 보기
+     * @method GET
+     * @see /movie/review/{movieCd}
+     */
+    public function show(Request $request)
+    {
+        $movieCd = base64_decode($request->route('review'));
+
+        $params = SearchData::fromRequest($request);
+        $params->movieCd = $movieCd;
+        $params->userID = UID;
+        $params->sort = $request->get('sort', 1);
+
+        // 이미 등록 했는지 확인
+        if(!$this->movieModel->isExists($movieCd)) {
+             return alert('잘못된 접근입니다.', DIRECTORY_SEPARATOR);
+        }
+
+        $info = $this->movieInfoModel->info($movieCd);
+        $detail = $this->movieDetailModel->info($movieCd);
+        $avgRate = $this->movieReviewModel->getAvgRate($movieCd);
+        $reviews = $this->movieReviewModel->list($params);
+        if($reviews->rows > 0) {
+            foreach($reviews->list as &$row) {
+                $length = strlen($row->sid);
+                $row->owner = ($row->name . '(' . Str::mask($row->sid, '*', intval($length / 2), $length) . ')');
+                $row->createdAt = date('Y.m.d', strtotime($row->created_at));
+                $row->isLike = $this->movieLikeModel->isLike($row->id, UID);
+                $row->isDisLike = $this->movieLikeModel->isDisLike($row->id, UID);
+            }
+        }
+
+        $movieCd = base64_encode($movieCd);
+        $movieNm = $info->movie_nm;
+        if($info->movie_nm_en) {
+            $movieNm .= (' (' . $info->movie_nm_en . ')');
+        }
+
+        return view(layout('movie.review.show'), [
+            'movieCd' => $movieCd,
+            'movieNm' => $movieNm,
+            'params' => $params,
+            'info' => $info,
+            'detail' => $detail,
+            'reviews' => $reviews,
+            'avgRate' => $avgRate
+        ]);
+    }
+
+    /**
+     * 영화 평점·후기 등록
+     * @method POST
+     * @see /movie/review
+     */
+    public function store(Request $request, ResponseData $response): ResponseData
+    {
+        try {
+
+            $request->merge([
+                'mid' => base64_decode($request->post('mid'))
+            ]);
+
+            $rules = [
+                'mid' => 'required|exists:tb_movie,movie_cd',
+                'mode' => 'required|in:write',
+                'rate' => 'required|numeric|min:1|max:10',
+                'content' => ['required', 'string', 'min:1', 'max:1000', new FilterSpamKeyword]
+            ];
+
+            $attributes = [
+                'mid' => '영화 PK',
+                'mode' => '처리 구분',
+                'rate' => '평점',
+                'content' => '감상평'
+            ];
+
+            $messages = [
+                'mid.required' => '영화 PK는 필수 값 입니다.',
+                'mid.exists' => '영화 PK가 존재하지 않습니다.',
+                'mode.required' => '처리 구분을 입력해주세요.',
+                'mode.in' => '처리 구분 형식이 옳지 않습니다.',
+                'rate.required' => '평점을 선택해주세요.',
+                'rate.numeric' => '평점은 형식이 옳지 않습니다.',
+                'rate.min' => '평점은 최소 1 이상 입니다.',
+                'rate.max' => '평점은 최대 10 이하 입니다.',
+                'content.required' => '감상평을 입력해주세요.',
+                'content.string' => '감상평 형식이 옳지 않습니다.',
+                'content.min' => '감상평을 입력해주세요.',
+                'content.max' => '감상평은 최대 1000자 입력 가능합니다.'
+            ];
+
+            $posts = $this->validate($request, $rules, $messages, $attributes);
+
+            // 이미 등록 했는지 확인
+            if($this->movieReviewModel->isAlready($posts['mid'], UID)) {
+                throw new Exception('이미 감상 후기를 등록하셨습니다.');
+            }
+
+            $this->movieReviewModel->insert([
+                'movie_cd' => $posts['mid'],
+                'user_id' => UID,
+                'content' => trim($posts['content']),
+                'rate' => $posts['rate'],
+                'like' => 0,
+                'dislike' => 0,
+                'blame' => 0,
+                'ip_address' => IP_ADDRESS,
+                'user_agent' => USER_AGENT,
+                'updated_at' => null,
+                'created_at' => now()
+            ]);
+
+        }catch(Exception $e) {
+            $response = $response::fromException($e);
+        }
+
+        return $response;
+    }
+
+    /**
+     * 영화 평점·후기 수정
+     * @method PUT
+     * @see /movie/review/{review_id}
+     */
+    public function update(Request $request, ResponseData $response): ResponseData
+    {
+        try {
+
+            $request->merge([
+                'mid' => base64_decode($request->post('mid'))
+            ]);
+
+            $rules = [
+                'mid' => 'required|exists:tb_movie,movie_cd',
+                'rid' => 'required|exists:tb_movie_review,id',
+                'mode' => 'required|in:modify',
+                'rate' => 'required|numeric|min:1|max:10',
+                'content' => ['required', 'string', 'min:1', 'max:1000', new FilterSpamKeyword]
+            ];
+
+            $attributes = [
+                'rid' => '후기 PK',
+                'mid' => '영화 PK',
+                'mode' => '처리 구분',
+                'rate' => '평점',
+                'content' => '감상평'
+            ];
+
+            $messages = [
+                'mid.required' => '영화 PK는 필수 값 입니다.',
+                'mid.exists' => '영화 PK가 존재하지 않습니다.',
+                'rid.required' => '후기 PK는 필수 값 입니다.',
+                'rid.exists' => '후기 PK가 존재하지 않습니다.',
+                'mode.required' => '처리 구분을 입력해주세요.',
+                'mode.in' => '처리 구분 형식이 옳지 않습니다.',
+                'rate.required' => '평점을 선택해주세요.',
+                'rate.numeric' => '평점은 형식이 옳지 않습니다.',
+                'rate.min' => '평점은 최소 1 이상 입니다.',
+                'rate.max' => '평점은 최대 10 이하 입니다.',
+                'content.required' => '감상평을 입력해주세요.',
+                'content.string' => '감상평 형식이 옳지 않습니다.',
+                'content.min' => '감상평을 입력해주세요.',
+                'content.max' => '감상평은 최대 1000자 입력 가능합니다.'
+            ];
+
+            $posts = $this->validate($request, $rules, $messages, $attributes);
+
+            // 내가 등록한 후기 인지 확인
+            if(!$this->movieReviewModel->isExists($posts['rid'], UID)) {
+                throw new Exception('내가 등록한 감상 후기가 아닙니다.');
+            }
+
+            $this->movieReviewModel->find($posts['rid'])->update([
+                'content' => trim($posts['content']),
+                'rate' => $posts['rate'],
+                'updated_at' => now()
+            ]);
+
+        }catch(Exception $e) {
+            $response = $response::fromException($e);
+        }
+
+        return $response;
+    }
+
+    /**
+     * 영화 평점·후기 삭제
+     * @method DELETE
+     * @see /movie/review/{review_id}
+     */
+    public function destroy(Request $request, ResponseData $response): ResponseData
+    {
+        try {
+
+            $request->merge([
+                'mid' => base64_decode($request->post('mid'))
+            ]);
+
+            $rules = [
+                'mid' => 'required|exists:tb_movie,movie_cd',
+                'rid' => 'required|exists:tb_movie_review,id',
+                'mode' => 'required|in:delete'
+            ];
+
+            $attributes = [
+                'rid' => '후기 PK',
+                'mid' => '영화 PK',
+                'mode' => '처리 구분'
+            ];
+
+            $messages = [
+                'mid.required' => '영화 PK는 필수 값 입니다.',
+                'mid.exists' => '영화 PK가 존재하지 않습니다.',
+                'rid.required' => '후기 PK는 필수 값 입니다.',
+                'rid.exists' => '후기 PK가 존재하지 않습니다.',
+                'mode.required' => '처리 구분을 입력해주세요.',
+                'mode.in' => '처리 구분 형식이 옳지 않습니다.'
+            ];
+
+            $posts = $this->validate($request, $rules, $messages, $attributes);
+
+            // 내가 등록한 후기 인지 확인
+            if(!$this->movieReviewModel->isExists($posts['rid'], UID)) {
+                throw new Exception('내가 등록한 감상 후기가 아닙니다.');
+            }
+
+            $this->movieReviewModel->find($posts['rid'])->delete();
+
+        }catch(Exception $e) {
+            $response = $response::fromException($e);
+        }
+
+        return $response;
+    }
+
+    /**
+     * 평점·후기 공감하기
+     * @method POST
+     * @see /movie/review/like
+     */
+    public function like(Request $request, ResponseData $response): ResponseData
+    {
+        try {
+
+            $request->merge([
+                'mid' => base64_decode($request->post('mid'))
+            ]);
+
+            $rules = [
+                'mid' => 'required|exists:tb_movie,movie_cd',
+                'rid' => 'required|exists:tb_movie_review,id'
+            ];
+
+            $attributes = [
+                'mid' => '영화 PK',
+                'rid' => '후기 PK'
+            ];
+
+            $messages = [
+                'mid.required' => '영화 PK는 필수 값 입니다.',
+                'mid.exists' => '영화 PK가 존재하지 않습니다.',
+                'rid.required' => '후기 PK는 필수 값 입니다.',
+                'rid.exists' => '후기 PK가 존재하지 않습니다.',
+            ];
+
+            $posts = $this->validate($request, $rules, $messages, $attributes);
+
+            $like = $this->movieLikeModel->getType($posts['rid'], UID);
+            if($like->exists) {
+                throw new Exception(sprintf("이미 %s 하셨습니다.", ($like->type == 1 ? '공감' : '비공감')));
+            }
+
+            $response->like = $this->movieReviewModel->setLike($posts['mid'], $posts['rid'], UID, LIKE);
+
+        }catch(Exception $e) {
+            $response = $response::fromException($e);
+        }
+
+        return $response;
+    }
+
+    /**
+     * 평점·후기 비공감하기
+     * @method POST
+     * @see /movie/review/dislike
+     */
+    public function dislike(Request $request, ResponseData $response): ResponseData
+    {
+        try {
+
+            $request->merge([
+                'mid' => base64_decode($request->post('mid'))
+            ]);
+
+            $rules = [
+                'mid' => 'required|exists:tb_movie,movie_cd',
+                'rid' => 'required|exists:tb_movie_review,id'
+            ];
+
+            $attributes = [
+                'mid' => '영화 PK',
+                'rid' => '후기 PK',
+            ];
+
+            $messages = [
+                'mid.required' => '영화 PK는 필수 값 입니다.',
+                'mid.exists' => '영화 PK가 존재하지 않습니다.',
+                'rid.required' => '후기 PK는 필수 값 입니다.',
+                'rid.exists' => '후기 PK가 존재하지 않습니다.'
+            ];
+
+            $posts = $this->validate($request, $rules, $messages, $attributes);
+
+            $like = $this->movieLikeModel->getType($posts['rid'], UID);
+            if($like->exists) {
+                throw new Exception(sprintf("이미 %s 하셨습니다.", ($like->type == 1 ? '공감' : '비공감')));
+            }
+
+            $response->dislike = $this->movieReviewModel->setLike($posts['mid'], $posts['rid'], UID, DISLIKE);
+
+        }catch(Exception $e) {
+            $response = $response::fromException($e);
+        }
+
+        return $response;
+    }
+
+    /**
+     * 평점·후기 신고
+     * @method POST
+     * @see /movie/review/blame
+     */
+    public function blame(Request $request, ResponseData $response, MovieBlame $movieBlameModel): ResponseData
+    {
+        try {
+
+            $request->merge([
+                'mid' => base64_decode($request->post('mid'))
+            ]);
+
+            $rules = [
+                'mid' => 'required|exists:tb_movie,movie_cd',
+                'rid' => 'required|exists:tb_movie_review,id',
+                'blame_type' => 'required|numeric|in:1,2,3,4,5,6,7,8,9',
+                'blame_reason' => 'required|string|max:1000'
+            ];
+
+            $attributes = [
+                'rid' => '후기 PK',
+                'mid' => '영화 PK',
+                'blame_type' => '신고 사유',
+                'blame_reason' => '신고 내용'
+            ];
+
+            $messages = [
+                'mid.required' => '영화 PK는 필수 값 입니다.',
+                'mid.exists' => '영화 PK가 존재하지 않습니다.',
+                'rid.required' => '후기 PK는 필수 값 입니다.',
+                'rid.exists' => '후기 PK가 존재하지 않습니다.',
+                'blame_type.required' => '신고 사유를 선택해주세요.',
+                'blame_type.numeric' => '신고 사유 형식이 옳지 않습니다.',
+                'blame_type.in' => '신고 사유가 잘못되었습니다.',
+                'blame_reason.required' => '신고 내용을 입력해주세요.',
+                'blame_reason.string' => '신고 내용 형식이 옳지 않습니다.',
+                'blame_reason.max' => '신고 내용은 최대 1000자 입력 가능합니다.'
+            ];
+
+            $posts = $this->validate($request, $rules, $messages, $attributes);
+
+            if($movieBlameModel->isAlready($posts['mid'], $posts['rid'], UID)) {
+                throw new Exception('이미 신고가 접수되었습니다.');
+            }
+
+            $this->movieReviewModel->setBlame($posts['mid'], $posts['rid'], UID, $posts['blame_type'], $posts['blame_reason']);
+
+        }catch(Exception $e) {
+            $response = $response::fromException($e);
+        }
+
+        return $response;
+    }
+}

+ 170 - 0
app/Http/Controllers/Movie/SearchController.php

@@ -0,0 +1,170 @@
+<?php
+
+namespace App\Http\Controllers\Movie;
+
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Http;
+use Illuminate\Support\Facades\Validator;
+use App\Http\Controllers\Controller;
+use App\Http\Traits\CommonTrait;
+use App\Http\Traits\PagingTrait;
+use App\Models\DTO\Movie\ListParams;
+use App\Models\DTO\Movie\InfoParams;
+use App\Models\DTO\SearchData;
+use App\Models\Movie\Movie;
+use App\Models\Movie\MovieReview;
+use Exception;
+
+class SearchController extends Controller
+{
+    use CommonTrait;
+    use PagingTrait;
+
+    private string $host;
+    private Movie $movieModel;
+    private MovieReview $movieReviewModel;
+
+    public function __construct(
+        Movie $movieModel,
+        MovieReview $movieReviewModel
+    ) {
+        $this->middleware('front');
+
+        $this->host = env('MOVIE_API');
+        $this->movieModel = $movieModel;
+        $this->movieReviewModel = $movieReviewModel;
+    }
+
+    /**
+     * 영화 DB
+     * @method GET
+     * @see /movie/search
+     */
+    public function index(Request $request, ListParams $params)
+    {
+        $rules = [
+            'page' => 'nullable|numeric',
+            'keyword' => 'nullable|string|max:100',
+            'director' => 'nullable|string|max:30',
+            's_year' => 'nullable|date_format:Y|before:e_year',
+            'e_year' => 'nullable|date_format:Y|after:s_year',
+            's_open_dt' => 'nullable|date_format:Y-m-d|before:e_open_dt',
+            'e_open_dt' => 'nullable|date_format:Y-m-d|after:s_open_dt',
+            'genre' => 'nullable|numeric',
+            'type' => 'nullable|numeric'
+        ];
+
+        $attributes = [
+            'page' => '페이지 번호',
+            'keyword' => '검색어',
+            'director' => '감독',
+            's_year' => '제작 시작연도',
+            'e_year' => '제작 종료연도',
+            's_open_dt' => '개봉 시작일',
+            'e_open_dt' => '개봉 종료일',
+            'genre' => '장르',
+            'type' => '유형'
+        ];
+
+        $message = [
+            'page.numeric' => '페이지 번호는 숫자여야 합니다.',
+            'keyword.max' => '검색어 최대 글자는 100자 입니다.',
+            's_year.date_format' => '제작 시작연도 형식이 옳지 않습니다.',
+            's_year.before' => '제작 시작연도는 종료 연도보다 작아야 합니다.',
+            'e_year.date_format' => '제작 시작연도 형식이 옳지 않습니다.',
+            'e_year.after' => '제작 종료연도는 시작 연도보다 커야 합니다.',
+            's_open_dt.date_format' => '개봉 시작일 형식이 옳지 않습니다.',
+            's_open_dt.before' => '개봉 시작일은 종료일 보다 작아야 합니다.',
+            'e_open_dt.date_format' => '개봉 종료일 형식이 옳지 않습니다.',
+            'e_open_dt.after' => '개봉 종료일은 시작일 보다 커야 합니다.',
+        ];
+
+        // 검색 유효성 검사
+        Validator::make($request->all(), $rules, $message, $attributes)->validated();
+        $params = SearchData::fromRequest($request);
+        $params->director = $request->get('director');
+        $params->sYear = $request->get('s_year');
+        $params->eYear = $request->get('e_year');
+        $params->sOpenDt = $request->get('s_open_dt');
+        $params->eOpenDt = $request->get('e_open_dt');
+        $params->genre = $request->get('genre', []);
+        $params->type = $request->get('type', []);
+        $params->perPage = 20;
+
+        $movies = $this->movieModel->list($params);
+        if($movies->total > 0) {
+            $queryString = $request->getQueryString();
+            foreach($movies->list as &$row) {
+                $row->open_dt = ($row->open_dt ? date('Y.m.d', strtotime($row->open_dt)) : null);
+                $row->viewURL = route('movie.search.show', base64_encode($row->movie_cd));
+                if($queryString) {
+                    $row->viewURL .= ('?' . $queryString);
+                }
+            }
+        }
+
+        return view(layout('movie.search.index'), [
+            'params' => $params,
+            'movies' => $movies
+        ]);
+    }
+
+    /**
+     * 영화 DB 상세 보기
+     * @method GET
+     * @see /movie/search/{movie_id}
+     */
+    public function show(string $base64, Request $request, InfoParams $params)
+    {
+        try {
+
+            if(!$base64) {
+                throw new Exception('잘못된 접근입니다. [1]');
+            }
+
+            $movieCd = base64_decode($base64);
+
+            if(!$movieCd) {
+                throw new Exception('잘못된 접근입니다. [2]');
+            }
+
+            $listURL = route('movie.search.index');
+            if($queryString = $request->getQueryString()) {
+                $listURL .= ('?' . $queryString);
+            }
+
+            // 검색 조건
+            $url = ($this->host . MOVIE_INFO);
+
+            $params->movieCd = $movieCd;
+            $response = Http::get($url, $params->toArray());
+
+            if(!$response->ok()) {
+                throw new Exception('요청한 정보가 없습니다.');
+            }
+
+            $data = json_decode($response->body());
+            $info = $data->info;
+            $detail = $data->detail;
+
+            $movieNm = $info->MovieNm;
+            if($info->MovieNmEn) {
+                $movieNm .= (' (' . $info->MovieNmEn . ')');
+            }
+
+            $avgRate = $this->movieReviewModel->getAvgRate($movieCd);
+
+            return view(layout('movie.search.show'), [
+                'movieCd' => $base64,
+                'movieNm' => $movieNm,
+                'info' => $info,
+                'detail' => $detail,
+                'listURL' => $listURL,
+                'avgRate' => $avgRate
+            ]);
+
+        }catch(Exception $e) {
+            abort($e->getCode(), $e->getMessage());
+        }
+    }
+}

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác