feat: 앱 버전 관리 페이지 구현
- AppVersion 모델, Service, Controller - 버전 등록 폼 (APK 업로드, 강제 업데이트 설정) - 버전 목록 테이블 (활성 토글, 다운로드 수, 삭제) - /app-versions 라우트 추가 - app_releases 스토리지 디스크 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
71
app/Http/Controllers/AppVersionController.php
Normal file
71
app/Http/Controllers/AppVersionController.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\AppVersionService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AppVersionController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AppVersionService $appVersionService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 버전 목록 + 등록 폼
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$versions = $this->appVersionService->list();
|
||||
|
||||
return view('app-versions.index', compact('versions'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 새 버전 등록
|
||||
*/
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'version_code' => 'required|integer|min:1|unique:app_versions,version_code',
|
||||
'version_name' => 'required|string|max:20',
|
||||
'platform' => 'required|in:android,ios',
|
||||
'release_notes' => 'nullable|string',
|
||||
'force_update' => 'nullable|boolean',
|
||||
'apk_file' => 'nullable|file|max:204800', // 200MB
|
||||
]);
|
||||
|
||||
$this->appVersionService->store(
|
||||
$request->only(['version_code', 'version_name', 'platform', 'release_notes', 'force_update']),
|
||||
$request->file('apk_file')
|
||||
);
|
||||
|
||||
return redirect()->route('app-versions.index')
|
||||
->with('success', '새 버전이 등록되었습니다.');
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 토글
|
||||
*/
|
||||
public function toggleActive(int $id): RedirectResponse
|
||||
{
|
||||
$version = $this->appVersionService->toggleActive($id);
|
||||
$status = $version->is_active ? '활성화' : '비활성화';
|
||||
|
||||
return redirect()->route('app-versions.index')
|
||||
->with('success', "v{$version->version_name}이 {$status}되었습니다.");
|
||||
}
|
||||
|
||||
/**
|
||||
* 삭제
|
||||
*/
|
||||
public function destroy(int $id): RedirectResponse
|
||||
{
|
||||
$this->appVersionService->destroy($id);
|
||||
|
||||
return redirect()->route('app-versions.index')
|
||||
->with('success', '버전이 삭제되었습니다.');
|
||||
}
|
||||
}
|
||||
36
app/Models/AppVersion.php
Normal file
36
app/Models/AppVersion.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class AppVersion extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'version_code',
|
||||
'version_name',
|
||||
'platform',
|
||||
'release_notes',
|
||||
'apk_path',
|
||||
'apk_size',
|
||||
'apk_original_name',
|
||||
'force_update',
|
||||
'is_active',
|
||||
'download_count',
|
||||
'published_at',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'version_code' => 'integer',
|
||||
'apk_size' => 'integer',
|
||||
'force_update' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
'download_count' => 'integer',
|
||||
'published_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
65
app/Services/AppVersionService.php
Normal file
65
app/Services/AppVersionService.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\AppVersion;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class AppVersionService
|
||||
{
|
||||
/**
|
||||
* 버전 목록 (페이지네이션)
|
||||
*/
|
||||
public function list(int $perPage = 20): LengthAwarePaginator
|
||||
{
|
||||
return AppVersion::orderByDesc('version_code')
|
||||
->paginate($perPage);
|
||||
}
|
||||
|
||||
/**
|
||||
* 새 버전 등록
|
||||
*/
|
||||
public function store(array $data, ?UploadedFile $apkFile = null): AppVersion
|
||||
{
|
||||
if ($apkFile) {
|
||||
$data['apk_original_name'] = $apkFile->getClientOriginalName();
|
||||
$data['apk_size'] = $apkFile->getSize();
|
||||
$data['apk_path'] = $apkFile->store('apk', 'app_releases');
|
||||
}
|
||||
|
||||
$data['created_by'] = auth()->id();
|
||||
$data['published_at'] = $data['published_at'] ?? now();
|
||||
|
||||
return AppVersion::create($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 토글
|
||||
*/
|
||||
public function toggleActive(int $id): AppVersion
|
||||
{
|
||||
$version = AppVersion::findOrFail($id);
|
||||
$version->is_active = ! $version->is_active;
|
||||
$version->updated_by = auth()->id();
|
||||
$version->save();
|
||||
|
||||
return $version;
|
||||
}
|
||||
|
||||
/**
|
||||
* 삭제
|
||||
*/
|
||||
public function destroy(int $id): void
|
||||
{
|
||||
$version = AppVersion::findOrFail($id);
|
||||
|
||||
// APK 파일 삭제
|
||||
if ($version->apk_path && Storage::disk('app_releases')->exists($version->apk_path)) {
|
||||
Storage::disk('app_releases')->delete($version->apk_path);
|
||||
}
|
||||
|
||||
$version->delete();
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,14 @@
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
'app_releases' => [
|
||||
'driver' => 'local',
|
||||
'root' => env('APP_RELEASES_PATH', '/var/www/shared-storage/releases'),
|
||||
'visibility' => 'private',
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
's3' => [
|
||||
'driver' => 's3',
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
|
||||
169
resources/views/app-versions/index.blade.php
Normal file
169
resources/views/app-versions/index.blade.php
Normal file
@@ -0,0 +1,169 @@
|
||||
@extends('layouts.app')
|
||||
|
||||
@section('title', '앱 버전 관리')
|
||||
|
||||
@section('content')
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<h1 class="text-2xl font-bold mb-6">앱 버전 관리</h1>
|
||||
|
||||
{{-- 성공 메시지 --}}
|
||||
@if(session('success'))
|
||||
<div class="mb-4 rounded-lg bg-green-50 p-4 text-green-800 border border-green-200">
|
||||
{{ session('success') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- 에러 메시지 --}}
|
||||
@if($errors->any())
|
||||
<div class="mb-4 rounded-lg bg-red-50 p-4 text-red-800 border border-red-200">
|
||||
<ul class="list-disc list-inside">
|
||||
@foreach($errors->all() as $error)
|
||||
<li>{{ $error }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- 등록 폼 --}}
|
||||
<div class="bg-white rounded-lg shadow p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold mb-4">새 버전 등록</h2>
|
||||
<form action="{{ route('app-versions.store') }}" method="POST" enctype="multipart/form-data">
|
||||
@csrf
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">버전 코드 (정수) *</label>
|
||||
<input type="number" name="version_code" value="{{ old('version_code') }}" required min="1"
|
||||
class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
|
||||
placeholder="예: 2">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">버전명 *</label>
|
||||
<input type="text" name="version_name" value="{{ old('version_name') }}" required maxlength="20"
|
||||
class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
|
||||
placeholder="예: 0.2">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">플랫폼</label>
|
||||
<select name="platform" class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm">
|
||||
<option value="android" {{ old('platform') === 'ios' ? '' : 'selected' }}>Android</option>
|
||||
<option value="ios" {{ old('platform') === 'ios' ? 'selected' : '' }}>iOS</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">변경사항</label>
|
||||
<textarea name="release_notes" rows="3"
|
||||
class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
|
||||
placeholder="- 기능 추가 - 버그 수정">{{ old('release_notes') }}</textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">APK 파일</label>
|
||||
<input type="file" name="apk_file" accept=".apk"
|
||||
class="w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100">
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<label class="inline-flex items-center">
|
||||
<input type="hidden" name="force_update" value="0">
|
||||
<input type="checkbox" name="force_update" value="1" {{ old('force_update') ? 'checked' : '' }}
|
||||
class="rounded border-gray-300 text-blue-600 shadow-sm focus:ring-blue-500">
|
||||
<span class="ml-2 text-sm text-gray-700">강제 업데이트</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<button type="submit" class="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||
등록
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{-- 버전 목록 --}}
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">버전</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">플랫폼</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">변경사항</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">강제</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">활성</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">다운로드</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">APK</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">배포일</th>
|
||||
<th class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
@forelse($versions as $version)
|
||||
<tr class="{{ $version->is_active ? '' : 'bg-gray-50 opacity-60' }}">
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
<div class="text-sm font-medium text-gray-900">v{{ $version->version_name }}</div>
|
||||
<div class="text-xs text-gray-500">code: {{ $version->version_code }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ strtoupper($version->platform) }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500 max-w-xs truncate">
|
||||
{{ $version->release_notes ? \Illuminate\Support\Str::limit($version->release_notes, 60) : '-' }}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-center">
|
||||
@if($version->force_update)
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">필수</span>
|
||||
@else
|
||||
<span class="text-gray-400 text-xs">-</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-center">
|
||||
<form action="{{ route('app-versions.toggle', $version->id) }}" method="POST" class="inline">
|
||||
@csrf
|
||||
<button type="submit" class="text-sm {{ $version->is_active ? 'text-green-600 hover:text-green-800' : 'text-gray-400 hover:text-gray-600' }}">
|
||||
{{ $version->is_active ? 'ON' : 'OFF' }}
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-center text-sm text-gray-500">
|
||||
{{ number_format($version->download_count) }}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500">
|
||||
@if($version->apk_path)
|
||||
<span class="text-green-600" title="{{ $version->apk_original_name }}">
|
||||
{{ $version->apk_original_name ? \Illuminate\Support\Str::limit($version->apk_original_name, 20) : 'APK' }}
|
||||
</span>
|
||||
@if($version->apk_size)
|
||||
<span class="text-xs text-gray-400">({{ number_format($version->apk_size / 1024 / 1024, 1) }}MB)</span>
|
||||
@endif
|
||||
@else
|
||||
<span class="text-gray-400">-</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ $version->published_at?->format('Y-m-d') ?? '-' }}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-center">
|
||||
<form action="{{ route('app-versions.destroy', $version->id) }}" method="POST" class="inline"
|
||||
onsubmit="return confirm('v{{ $version->version_name }}을 삭제하시겠습니까?')">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="text-red-600 hover:text-red-800 text-sm">삭제</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="9" class="px-4 py-8 text-center text-gray-500">등록된 버전이 없습니다.</td>
|
||||
</tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{{-- 페이지네이션 --}}
|
||||
@if($versions->hasPages())
|
||||
<div class="px-4 py-3 border-t border-gray-200">
|
||||
{{ $versions->links() }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -4,6 +4,7 @@
|
||||
use App\Http\Controllers\ApiLogController;
|
||||
use App\Http\Controllers\ArchivedRecordController;
|
||||
use App\Http\Controllers\AuditLogController;
|
||||
use App\Http\Controllers\AppVersionController;
|
||||
use App\Http\Controllers\Auth\LoginController;
|
||||
use App\Http\Controllers\BoardController;
|
||||
use App\Http\Controllers\CategoryController;
|
||||
@@ -319,6 +320,7 @@
|
||||
Route::prefix('common-codes')->name('common-codes.')->group(function () {
|
||||
Route::get('/', [CommonCodeController::class, 'index'])->name('index');
|
||||
Route::post('/', [CommonCodeController::class, 'store'])->name('store');
|
||||
Route::post('/store-group', [CommonCodeController::class, 'storeGroup'])->name('store-group');
|
||||
Route::post('/bulk-copy', [CommonCodeController::class, 'bulkCopy'])->name('bulk-copy');
|
||||
Route::post('/bulk-promote', [CommonCodeController::class, 'bulkPromoteToGlobal'])->name('bulk-promote');
|
||||
Route::put('/{id}', [CommonCodeController::class, 'update'])->name('update');
|
||||
@@ -350,6 +352,14 @@
|
||||
Route::get('/{id}/edit', [DocumentController::class, 'edit'])->whereNumber('id')->name('edit');
|
||||
});
|
||||
|
||||
// 앱 버전 관리
|
||||
Route::prefix('app-versions')->name('app-versions.')->group(function () {
|
||||
Route::get('/', [AppVersionController::class, 'index'])->name('index');
|
||||
Route::post('/', [AppVersionController::class, 'store'])->name('store');
|
||||
Route::post('/{id}/toggle', [AppVersionController::class, 'toggleActive'])->name('toggle');
|
||||
Route::delete('/{id}', [AppVersionController::class, 'destroy'])->name('destroy');
|
||||
});
|
||||
|
||||
// AI 설정 관리
|
||||
Route::prefix('system/ai-config')->name('system.ai-config.')->group(function () {
|
||||
Route::get('/', [AiConfigController::class, 'index'])->name('index');
|
||||
@@ -367,6 +377,7 @@
|
||||
// 카테고리 관리
|
||||
Route::prefix('categories')->name('categories.')->group(function () {
|
||||
Route::get('/', [CategoryController::class, 'index'])->name('index');
|
||||
Route::post('/store-group', [CategoryController::class, 'storeGroup'])->name('store-group');
|
||||
|
||||
// 카테고리 동기화
|
||||
Route::prefix('sync')->name('sync.')->group(function () {
|
||||
|
||||
Reference in New Issue
Block a user