generic catch 가 4xx 를 돌려주면 인프라 장애가 입력 오류로 위장돼 장애 인지가 늦어지고, 이미 번역된 예외 메시지를 응답의 메시지 키 자리에 넘기면 키 해석에 실패해 원문이 그대로 화면에 나간다. 두 형태를 코어와 전 번들 확장에서 함께 정리하고, 판정기를 한 확장이 아닌 코어 모집단에 두어 확장 밖 동형 결함도 red 가 되게 했다 — 그 사각에 실제로 15건이 있었다. 주소록에서 해외 배송지를 고르면 국내 6필드만 옮겨 담아 해외 주소가 통째로 사라지던 결함도 함께 고쳤다. 저장은 200 으로 성공 처리돼 주문 상세를 다시 열기 전에는 드러나지 않았다.
422 lines
16 KiB
PHP
422 lines
16 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Api\Admin;
|
|
|
|
use App\Exceptions\LanguagePackSlotConflictException;
|
|
use App\Extension\Helpers\ChangelogParser;
|
|
use App\Http\Controllers\Api\Base\AdminBaseController;
|
|
use App\Http\Requests\LanguagePack\BulkActivateRequest;
|
|
use App\Http\Requests\LanguagePack\IndexLanguagePackRequest;
|
|
use App\Http\Requests\LanguagePack\InstallFromBundledRequest;
|
|
use App\Http\Requests\LanguagePack\InstallFromFileRequest;
|
|
use App\Http\Requests\LanguagePack\InstallFromGithubRequest;
|
|
use App\Http\Requests\LanguagePack\InstallFromUrlRequest;
|
|
use App\Http\Requests\LanguagePack\ManifestPreviewRequest;
|
|
use App\Http\Requests\LanguagePack\UninstallLanguagePackRequest;
|
|
use App\Http\Resources\LanguagePackCollection;
|
|
use App\Http\Resources\LanguagePackResource;
|
|
use App\Services\LanguagePackService;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
/**
|
|
* 관리자용 언어팩 관리 컨트롤러.
|
|
*
|
|
* 컨트롤러는 얇게 유지하고 모든 비즈니스 로직은 LanguagePackService 에 위임합니다.
|
|
*/
|
|
class LanguagePackController extends AdminBaseController
|
|
{
|
|
/**
|
|
* @param LanguagePackService $service 언어팩 Service
|
|
*/
|
|
public function __construct(
|
|
private readonly LanguagePackService $service,
|
|
) {
|
|
parent::__construct();
|
|
}
|
|
|
|
/**
|
|
* 언어팩 목록을 페이지네이션으로 조회합니다.
|
|
*
|
|
* @param IndexLanguagePackRequest $request 목록 조회 요청
|
|
* @return JsonResponse 페이지네이션된 언어팩 목록
|
|
*/
|
|
public function index(IndexLanguagePackRequest $request): JsonResponse
|
|
{
|
|
try {
|
|
$validated = $request->validated();
|
|
$perPage = (int) ($validated['per_page'] ?? 10);
|
|
|
|
$paginator = $this->service->list($validated, $perPage);
|
|
|
|
$collection = new LanguagePackCollection(collect($paginator->items()));
|
|
$payload = [
|
|
'data' => $collection->toArray($request)['data'],
|
|
'meta' => array_merge(
|
|
$collection->with($request)['meta'] ?? [],
|
|
[
|
|
'current_page' => $paginator->currentPage(),
|
|
'last_page' => $paginator->lastPage(),
|
|
'per_page' => $paginator->perPage(),
|
|
'total' => $paginator->total(),
|
|
]
|
|
),
|
|
'abilities' => $collection->resolveCollectionAbilities($request),
|
|
];
|
|
|
|
return $this->success('language_packs.fetch_success', $payload);
|
|
} catch (\Throwable $e) {
|
|
return $this->error('language_packs.fetch_failed', 500, $e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 언어팩 상세 정보를 조회합니다.
|
|
*
|
|
* `{id}` 가 정수면 DB 레코드를, 문자열(번들 식별자)이면 `lang-packs/_bundled/{id}` 의
|
|
* manifest 로부터 합성된 가상 행을 반환합니다 (미설치 번들의 모달 열람용).
|
|
*
|
|
* @param Request $request HTTP 요청
|
|
* @param string $id 언어팩 ID 또는 번들 식별자
|
|
* @return JsonResponse 언어팩 상세 정보
|
|
*/
|
|
// audit:allow controller-base-request-injection reason: GET 상세 조회. 입력 검증 없음 — Resource::toDetailArray($request) 의 Request 인자 전달 목적
|
|
public function show(Request $request, string $id): JsonResponse
|
|
{
|
|
$pack = $this->service->findOrBundled($id);
|
|
if (! $pack) {
|
|
return $this->notFound('language_packs.not_found');
|
|
}
|
|
|
|
$resource = new LanguagePackResource($pack);
|
|
|
|
return $this->success('language_packs.fetch_success', $resource->toDetailArray($request));
|
|
}
|
|
|
|
/**
|
|
* 업로드된 ZIP 파일에서 언어팩을 설치합니다.
|
|
*
|
|
* @param InstallFromFileRequest $request 설치 요청
|
|
* @return JsonResponse 설치 결과
|
|
*/
|
|
public function installFromFile(InstallFromFileRequest $request): JsonResponse
|
|
{
|
|
try {
|
|
$validated = $request->validated();
|
|
$pack = $this->service->installFromFile(
|
|
$request->file('file'),
|
|
(bool) ($validated['auto_activate'] ?? false),
|
|
$this->getCurrentUser()?->id
|
|
);
|
|
|
|
return $this->success(
|
|
'language_packs.install_success',
|
|
(new LanguagePackResource($pack))->toArray($request),
|
|
201
|
|
);
|
|
} catch (ValidationException $e) {
|
|
return $this->error('language_packs.manifest_invalid', 422, $e->errors());
|
|
} catch (\Throwable $e) {
|
|
return $this->error('language_packs.install_failed', 500, $e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* GitHub URL 에서 언어팩을 설치합니다.
|
|
*
|
|
* @param InstallFromGithubRequest $request 설치 요청
|
|
* @return JsonResponse 설치 결과
|
|
*/
|
|
public function installFromGithub(InstallFromGithubRequest $request): JsonResponse
|
|
{
|
|
try {
|
|
$validated = $request->validated();
|
|
$pack = $this->service->installFromGithub(
|
|
$validated['github_url'],
|
|
(bool) ($validated['auto_activate'] ?? false),
|
|
$this->getCurrentUser()?->id
|
|
);
|
|
|
|
return $this->success(
|
|
'language_packs.install_success',
|
|
(new LanguagePackResource($pack))->toArray($request),
|
|
201
|
|
);
|
|
} catch (ValidationException $e) {
|
|
return $this->error('language_packs.manifest_invalid', 422, $e->errors());
|
|
} catch (\Throwable $e) {
|
|
return $this->error('language_packs.install_failed', 500, $e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* `lang-packs/_bundled/{identifier}` 디렉토리의 번들 소스에서 언어팩을 설치(또는 재설치)합니다.
|
|
*
|
|
* @param InstallFromBundledRequest $request 설치 요청
|
|
* @return JsonResponse 설치 결과
|
|
*/
|
|
public function installFromBundled(InstallFromBundledRequest $request): JsonResponse
|
|
{
|
|
try {
|
|
$validated = $request->validated();
|
|
$pack = $this->service->installFromBundled(
|
|
$validated['identifier'],
|
|
(bool) ($validated['auto_activate'] ?? false),
|
|
$this->getCurrentUser()?->id
|
|
);
|
|
|
|
return $this->success(
|
|
'language_packs.install_success',
|
|
(new LanguagePackResource($pack))->toArray($request),
|
|
201
|
|
);
|
|
} catch (ValidationException $e) {
|
|
return $this->error('language_packs.manifest_invalid', 422, $e->errors());
|
|
} catch (\Throwable $e) {
|
|
return $this->error('language_packs.install_failed', 500, $e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 임의 URL 에서 언어팩을 설치합니다.
|
|
*
|
|
* @param InstallFromUrlRequest $request 설치 요청
|
|
* @return JsonResponse 설치 결과
|
|
*/
|
|
public function installFromUrl(InstallFromUrlRequest $request): JsonResponse
|
|
{
|
|
try {
|
|
$validated = $request->validated();
|
|
$pack = $this->service->installFromUrl(
|
|
$validated['url'],
|
|
$validated['checksum'] ?? null,
|
|
(bool) ($validated['auto_activate'] ?? false),
|
|
$this->getCurrentUser()?->id
|
|
);
|
|
|
|
return $this->success(
|
|
'language_packs.install_success',
|
|
(new LanguagePackResource($pack))->toArray($request),
|
|
201
|
|
);
|
|
} catch (ValidationException $e) {
|
|
return $this->error('language_packs.manifest_invalid', 422, $e->errors());
|
|
} catch (\Throwable $e) {
|
|
return $this->error('language_packs.install_failed', 500, $e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 언어팩을 활성화합니다 (슬롯 스위칭).
|
|
*
|
|
* @param Request $request HTTP 요청
|
|
* @param int $id 언어팩 ID
|
|
* @return JsonResponse 활성화 결과
|
|
*/
|
|
// audit:allow controller-base-request-injection reason: force 플래그(boolean) 단순 토글. FormRequest 분리 시 빈 rules() 만 제공 — 검증 가치 없음
|
|
public function activate(Request $request, int $id): JsonResponse
|
|
{
|
|
$pack = $this->service->find($id);
|
|
if (! $pack) {
|
|
return $this->notFound('language_packs.not_found');
|
|
}
|
|
|
|
$force = (bool) $request->boolean('force', false);
|
|
|
|
try {
|
|
$pack = $this->service->activate($pack, $force);
|
|
|
|
return $this->success(
|
|
'language_packs.activate_success',
|
|
(new LanguagePackResource($pack))->toArray($request)
|
|
);
|
|
} catch (LanguagePackSlotConflictException $e) {
|
|
// 동일 슬롯에 다른 활성 팩 존재 — 프론트가 모달로 사용자 확인 후 force=true 로 재호출
|
|
return $this->error('language_packs.errors.slot_conflict', 409, [
|
|
'current' => (new LanguagePackResource($e->current))->toArray($request),
|
|
'target' => (new LanguagePackResource($e->target))->toArray($request),
|
|
]);
|
|
} catch (\Throwable $e) {
|
|
return $this->error('language_packs.activate_failed', 500, $e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 여러 언어팩을 일괄 활성화합니다 (요구사항 #7 reactivate 모달 → "활성화" 버튼).
|
|
*
|
|
* @param BulkActivateRequest $request 요청
|
|
* @return JsonResponse 결과 (succeeded/failed 분리)
|
|
*/
|
|
public function bulkActivate(BulkActivateRequest $request): JsonResponse
|
|
{
|
|
$ids = $request->validated('ids');
|
|
$result = $this->service->bulkActivate($ids);
|
|
|
|
return $this->success('language_packs.bulk_activate_success', $result);
|
|
}
|
|
|
|
/**
|
|
* 언어팩을 비활성화합니다.
|
|
*
|
|
* @param Request $request HTTP 요청
|
|
* @param int $id 언어팩 ID
|
|
* @return JsonResponse 비활성화 결과
|
|
*/
|
|
// audit:allow controller-base-request-injection reason: 라우트 파라미터만 사용. Request 는 Resource::toArray($request) 전달용
|
|
public function deactivate(Request $request, int $id): JsonResponse
|
|
{
|
|
$pack = $this->service->find($id);
|
|
if (! $pack) {
|
|
return $this->notFound('language_packs.not_found');
|
|
}
|
|
|
|
try {
|
|
$pack = $this->service->deactivate($pack);
|
|
|
|
return $this->success(
|
|
'language_packs.deactivate_success',
|
|
(new LanguagePackResource($pack))->toArray($request)
|
|
);
|
|
} catch (\Throwable $e) {
|
|
return $this->error('language_packs.deactivate_failed', 500, $e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 언어팩을 제거합니다.
|
|
*
|
|
* @param UninstallLanguagePackRequest $request 제거 요청
|
|
* @param int $id 언어팩 ID
|
|
* @return JsonResponse 제거 결과
|
|
*/
|
|
public function uninstall(UninstallLanguagePackRequest $request, int $id): JsonResponse
|
|
{
|
|
$pack = $this->service->find($id);
|
|
if (! $pack) {
|
|
return $this->notFound('language_packs.not_found');
|
|
}
|
|
|
|
try {
|
|
$cascade = (bool) ($request->validated()['cascade'] ?? false);
|
|
$this->service->uninstall($pack, $cascade);
|
|
|
|
return $this->success('language_packs.uninstall_success');
|
|
} catch (\Throwable $e) {
|
|
return $this->error('language_packs.uninstall_failed', 500, $e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* GitHub 소스 언어팩의 업데이트 가능 여부를 확인합니다.
|
|
*
|
|
* @return JsonResponse 검사 결과 (checked, updates, details)
|
|
*/
|
|
public function checkUpdates(): JsonResponse
|
|
{
|
|
try {
|
|
$result = $this->service->checkUpdates();
|
|
|
|
return $this->success('language_packs.check_updates_success', $result);
|
|
} catch (\Throwable $e) {
|
|
return $this->error('language_packs.check_updates_failed', 500, $e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 언어팩을 신버전으로 업데이트합니다 (GitHub 재다운로드 + 적용).
|
|
*
|
|
* @param Request $request HTTP 요청
|
|
* @param int $id 언어팩 ID
|
|
* @return JsonResponse 업데이트된 언어팩
|
|
*/
|
|
// audit:allow controller-base-request-injection reason: 입력 검증 없음. Request 는 Resource::toArray($request) 전달용
|
|
public function performUpdate(Request $request, int $id): JsonResponse
|
|
{
|
|
$pack = $this->service->find($id);
|
|
if (! $pack) {
|
|
return $this->notFound('language_packs.not_found');
|
|
}
|
|
|
|
try {
|
|
$updated = $this->service->performUpdate($pack);
|
|
|
|
return $this->success(
|
|
'language_packs.update_success',
|
|
(new LanguagePackResource($updated))->toArray($request)
|
|
);
|
|
} catch (\Throwable $e) {
|
|
return $this->error('language_packs.update_failed', 500, $e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 번역/레지스트리/템플릿 언어 캐시를 무효화합니다.
|
|
*
|
|
* @return JsonResponse 캐시 갱신 결과
|
|
*/
|
|
public function refreshCache(): JsonResponse
|
|
{
|
|
try {
|
|
$result = $this->service->refreshCache();
|
|
|
|
return $this->success('language_packs.refresh_cache_success', $result);
|
|
} catch (\Throwable $e) {
|
|
// 예외 원문을 응답에 싣지 않는다 — 디버그 노출은 ResponseHelper 가
|
|
// app.debug 에서만 Throwable 을 펼치는 기존 메커니즘에 위임한다.
|
|
return $this->error('language_packs.refresh_cache_failed', 500, $e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 설치 전 ZIP 의 manifest 와 검증 결과만 미리 조회합니다 (실제 설치 X).
|
|
*
|
|
* @param ManifestPreviewRequest $request 미리보기 요청
|
|
* @return JsonResponse manifest + validation 결과
|
|
*/
|
|
public function manifestPreview(ManifestPreviewRequest $request): JsonResponse
|
|
{
|
|
try {
|
|
$result = $this->service->previewManifest($request->file('file'));
|
|
|
|
return $this->success('language_packs.preview_success', $result);
|
|
} catch (\Throwable $e) {
|
|
// 업로드 ZIP 검증 실패는 의도적 422 로 존치하되, 예외 원문은 싣지 않는다
|
|
// (다른 manifestPreview 컨트롤러 3종과 동형).
|
|
return $this->error('language_packs.preview_failed', 422, $e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 언어팩의 CHANGELOG.md 파일 내용을 반환합니다.
|
|
*
|
|
* `{id}` 가 정수면 DB 레코드를, 문자열(번들 식별자)이면 가상 행을 사용합니다.
|
|
*
|
|
* @param string $id 언어팩 ID 또는 번들 식별자
|
|
* @return JsonResponse CHANGELOG 문자열
|
|
*/
|
|
public function changelog(string $id): JsonResponse
|
|
{
|
|
$pack = $this->service->findOrBundled($id);
|
|
if (! $pack) {
|
|
return $this->notFound('language_packs.not_found');
|
|
}
|
|
|
|
try {
|
|
$directory = $pack->resolveDirectory();
|
|
$changelogPath = $directory.DIRECTORY_SEPARATOR.'CHANGELOG.md';
|
|
$entries = is_file($changelogPath) ? ChangelogParser::parse($changelogPath) : [];
|
|
$rawContent = is_file($changelogPath) ? (string) file_get_contents($changelogPath) : '';
|
|
|
|
return $this->success('language_packs.fetch_success', [
|
|
'identifier' => $pack->identifier,
|
|
'entries' => $entries,
|
|
'changelog' => $rawContent,
|
|
'has_changelog' => $entries !== [] || $rawContent !== '',
|
|
]);
|
|
} catch (\Throwable $e) {
|
|
return $this->error('language_packs.fetch_failed', 500, $e);
|
|
}
|
|
}
|
|
}
|