이슈 본연: API 레퍼런스의 미채움 마커 5종(실측 제외/TODO/필드 없음/대표 에러 없음)
1,194건을 코드에서 읽어 전수 채우고(0건), api:docgen 재생성이 사람이 채운 내용을
손실·열화시키던 멱등성 결함 4종(CRLF, 표 파이프 이스케이프, 에러 서술 보존, 중복
라우트명 키)을 근본 수정. --check 를 실측 제외와 드리프트가 구분되도록 재정의.
ParameterDescriber/ResourceFieldDescriber 에 leaf 폴백·SEO 스코프·공통 사전 확장.
파생 결함(문서 실측 중 발견): ResponseHelper 기본 메시지 키 6종이 존재하지 않는
messages.* 를 가리켜 응답 message 가 번역문 대신 키 문자열로 노출되던 문제를 common.*
으로 정정하고, 코어 7 + gdpr/marketing/pay_kginicis/verification/ecommerce 확장의
호출부와 누락 lang 키(게시판 10키 + 이커머스 category_images 4키 포함)를 ko/en/ja
전수 정정. 중복 그룹 키 2건은 기존 키로 호출부 통합.
보안: GET /api/identity/challenges/{id} 는 권한 가드 없는 공개 폴링 엔드포인트인데
Service::getStatus 가 attempts/max_attempts 를 응답에 담아 남은 시도 횟수를 추론할 수
있었다. 두 필드를 제거하고, 챌린지 화면(admin_basic/basic)의 서버 attempts 참조를
제거(남은 횟수 UI 는 query fallback + verify 실패 시 로컬 증가로 유지).
부수 정리(변경셋에 들어온 board 컨트롤러의 audit 사전 결함): FormRequest 4개 신설로
base Request 주입 제거, CommentController 의 Board 직접 호출을 BoardService 위임으로
전환, PHPDoc @return 보강. board 스위트에서 발견한 stale test 3건(seed SSoT 불일치,
admin 계정 user 역할 누락)도 같은 세션에서 정정.
버전 정렬(공개 release 기준): 코어 7.0.3→7.0.4, sirsoft-board 1.0.1→1.0.2,
verification_kginicis 1.0.0→1.0.1, admin_basic 1.0.1→1.0.4.
473 lines
18 KiB
PHP
473 lines
18 KiB
PHP
<?php
|
|
|
|
namespace App\Helpers;
|
|
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Resources\Json\JsonResource;
|
|
use Illuminate\Http\Resources\Json\ResourceCollection;
|
|
use Illuminate\Support\Facades\App;
|
|
|
|
class ResponseHelper
|
|
{
|
|
/**
|
|
* JSON 응답 인코딩 옵션.
|
|
*
|
|
* JSON_UNESCAPED_UNICODE: 한글 등 멀티바이트 문자를 `\uXXXX` (6바이트) 로
|
|
* 이스케이프하지 않고 raw UTF-8 (한글 3바이트) 로 직렬화하여 응답 크기를 줄인다.
|
|
* 특히 다국어 팩(lang) 응답에서 전송량 절감 효과가 크다.
|
|
*/
|
|
public const JSON_ENCODE_OPTIONS = JSON_UNESCAPED_UNICODE;
|
|
|
|
/**
|
|
* 성공 응답을 생성합니다.
|
|
*
|
|
* @param string $messageKey 메시지 키 (기본값: 'common.success')
|
|
* @param mixed $data 응답 데이터
|
|
* @param int $statusCode HTTP 상태 코드 (기본값: 200)
|
|
* @param array $messageParams 메시지 매개변수
|
|
* @param string $domain 번역 도메인 (기본값: 'core')
|
|
* @return JsonResponse JSON 응답
|
|
*/
|
|
public static function success(
|
|
string $messageKey = 'common.success',
|
|
mixed $data = null,
|
|
int $statusCode = 200,
|
|
array $messageParams = [],
|
|
string $domain = 'core'
|
|
): JsonResponse {
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => self::trans($messageKey, $messageParams, $domain),
|
|
'data' => $data,
|
|
], $statusCode, [], self::JSON_ENCODE_OPTIONS);
|
|
}
|
|
|
|
/**
|
|
* 실패 응답을 생성합니다.
|
|
*
|
|
* @param string $messageKey 메시지 키 (기본값: 'common.failed')
|
|
* @param int $statusCode HTTP 상태 코드 (기본값: 400)
|
|
* @param mixed $errors 오류 정보
|
|
* @param array $messageParams 메시지 매개변수
|
|
* @param string $domain 번역 도메인 (기본값: 'core')
|
|
* @return JsonResponse JSON 응답
|
|
*/
|
|
public static function error(
|
|
string $messageKey = 'common.failed',
|
|
int $statusCode = 400,
|
|
mixed $errors = null,
|
|
array $messageParams = [],
|
|
string $domain = 'core'
|
|
): JsonResponse {
|
|
$response = [
|
|
'success' => false,
|
|
'message' => self::trans($messageKey, $messageParams, $domain),
|
|
];
|
|
|
|
if ($errors instanceof \Throwable) {
|
|
if (config('app.debug')) {
|
|
// messageParams에 이미 에러 메시지가 포함된 경우 중복 방지
|
|
if (empty($messageParams)) {
|
|
$response['message'] .= ': '.$errors->getMessage();
|
|
}
|
|
$response['debug'] = self::formatException($errors);
|
|
}
|
|
} elseif ($errors !== null) {
|
|
if ($statusCode >= 500 && is_string($errors) && ! config('app.debug')) {
|
|
// 프로덕션 500+ 에러의 string errors 차단 (내부 예외 메시지 노출 방지)
|
|
} else {
|
|
$response['errors'] = $errors;
|
|
}
|
|
}
|
|
|
|
return response()->json($response, $statusCode, [], self::JSON_ENCODE_OPTIONS);
|
|
}
|
|
|
|
/**
|
|
* 입력 검증 실패 응답을 생성합니다.
|
|
*
|
|
* @param mixed $errors 검증 오류 정보
|
|
* @param string $messageKey 메시지 키 (기본값: 'common.validation_failed')
|
|
* @param array $messageParams 메시지 매개변수
|
|
* @param string $domain 번역 도메인 (기본값: 'core')
|
|
* @return JsonResponse 422 상태 코드를 가진 JSON 응답
|
|
*/
|
|
public static function validationError(
|
|
mixed $errors,
|
|
string $messageKey = 'common.validation_failed',
|
|
array $messageParams = [],
|
|
string $domain = 'core'
|
|
): JsonResponse {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => self::trans($messageKey, $messageParams, $domain),
|
|
'errors' => $errors,
|
|
], 422, [], self::JSON_ENCODE_OPTIONS);
|
|
}
|
|
|
|
/**
|
|
* 인증 실패 응답을 생성합니다.
|
|
*
|
|
* @param string $messageKey 메시지 키 (기본값: 'common.unauthorized')
|
|
* @param array $messageParams 메시지 매개변수
|
|
* @param string $domain 번역 도메인 (기본값: 'core')
|
|
* @return JsonResponse 401 상태 코드를 가진 JSON 응답
|
|
*/
|
|
public static function unauthorized(
|
|
string $messageKey = 'common.unauthorized',
|
|
array $messageParams = [],
|
|
string $domain = 'core'
|
|
): JsonResponse {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => self::trans($messageKey, $messageParams, $domain),
|
|
], 401, [], self::JSON_ENCODE_OPTIONS);
|
|
}
|
|
|
|
/**
|
|
* 권한 부족 응답을 생성합니다.
|
|
*
|
|
* @param string $messageKey 멤시지 키 (기본값: 'common.forbidden')
|
|
* @param array $messageParams 멤시지 매개변수
|
|
* @param string $domain 번역 도메인 (기본값: 'core')
|
|
* @return JsonResponse 403 상태 코드를 가진 JSON 응답
|
|
*/
|
|
public static function forbidden(
|
|
string $messageKey = 'common.forbidden',
|
|
array $messageParams = [],
|
|
string $domain = 'core'
|
|
): JsonResponse {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => self::trans($messageKey, $messageParams, $domain),
|
|
], 403, [], self::JSON_ENCODE_OPTIONS);
|
|
}
|
|
|
|
/**
|
|
* 리소스를 찾을 수 없음 응답을 생성합니다.
|
|
*
|
|
* @param string $messageKey 멤시지 키 (기본값: 'common.not_found')
|
|
* @param array $messageParams 멤시지 매개변수
|
|
* @param string $domain 번역 도메인 (기본값: 'core')
|
|
* @return JsonResponse 404 상태 코드를 가진 JSON 응답
|
|
*/
|
|
public static function notFound(
|
|
string $messageKey = 'common.not_found',
|
|
array $messageParams = [],
|
|
string $domain = 'core'
|
|
): JsonResponse {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => self::trans($messageKey, $messageParams, $domain),
|
|
], 404, [], self::JSON_ENCODE_OPTIONS);
|
|
}
|
|
|
|
/**
|
|
* 서버 내부 오류 응답을 생성합니다.
|
|
*
|
|
* @param string $messageKey 멤시지 키 (기본값: 'common.error_occurred')
|
|
* @param mixed $error 오류 정보 (디버그 모드에서만 표시)
|
|
* @param array $messageParams 멤시지 매개변수
|
|
* @param string $domain 번역 도메인 (기본값: 'core')
|
|
* @return JsonResponse 500 상태 코드를 가진 JSON 응답
|
|
*/
|
|
public static function serverError(
|
|
string $messageKey = 'common.error_occurred',
|
|
mixed $error = null,
|
|
array $messageParams = [],
|
|
string $domain = 'core'
|
|
): JsonResponse {
|
|
$response = [
|
|
'success' => false,
|
|
'message' => self::trans($messageKey, $messageParams, $domain),
|
|
];
|
|
|
|
if ($error instanceof \Throwable) {
|
|
if (config('app.debug')) {
|
|
$response['message'] .= ': '.$error->getMessage();
|
|
$response['debug'] = self::formatException($error);
|
|
}
|
|
} elseif ($error !== null && config('app.debug')) {
|
|
$response['error'] = $error;
|
|
}
|
|
|
|
return response()->json($response, 500, [], self::JSON_ENCODE_OPTIONS);
|
|
}
|
|
|
|
/**
|
|
* 다국어 메시지를 변환합니다.
|
|
*
|
|
* @param string $key 번역 키
|
|
* @param array $params 번역 매개변수
|
|
* @param string $domain 번역 도메인
|
|
* @return string 번역된 메시지
|
|
*/
|
|
private static function trans(
|
|
string $key,
|
|
array $params = [],
|
|
string $domain = 'core'
|
|
): string {
|
|
$locale = self::getUserLocale();
|
|
|
|
// 도메인별로 다른 경로에서 번역 파일 로드
|
|
$translationKey = $domain === 'core' ? $key : "{$domain}::{$key}";
|
|
|
|
return __($translationKey, $params, $locale);
|
|
}
|
|
|
|
/**
|
|
* 응답에 사용할 언어 코드를 반환합니다.
|
|
*
|
|
* SetLocale 미들웨어가 이미 사용자 언어 우선순위
|
|
* (1) 인증 사용자의 users.language → (2) Accept-Language 헤더(localStorage.g7_locale 포함)
|
|
* → (3) config('app.locale') fallback
|
|
* 를 적용해 App::setLocale() 한 결과를 신뢰합니다. supported_locales 화이트리스트
|
|
* (활성 코어 언어팩 기반) 검증도 SetLocale 에서 처리되므로 본 메서드는 그 결과만 반환합니다.
|
|
*
|
|
* @return string 현재 요청에 적용된 언어 코드
|
|
*/
|
|
private static function getUserLocale(): string
|
|
{
|
|
return App::getLocale();
|
|
}
|
|
|
|
/**
|
|
* 예외 정보를 디버그용 배열로 변환합니다.
|
|
*
|
|
* @param \Throwable $e 예외 인스턴스
|
|
* @return array 디버그 정보 배열
|
|
*/
|
|
private static function formatException(\Throwable $e): array
|
|
{
|
|
return [
|
|
'exception' => get_class($e),
|
|
'message' => $e->getMessage(),
|
|
'file' => $e->getFile(),
|
|
'line' => $e->getLine(),
|
|
'trace' => collect($e->getTrace())->take(10)->map(fn ($frame) => [
|
|
'file' => $frame['file'] ?? null,
|
|
'line' => $frame['line'] ?? null,
|
|
'function' => ($frame['class'] ?? '').($frame['type'] ?? '').($frame['function'] ?? ''),
|
|
])->toArray(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 모듈별 성공 응답을 생성합니다.
|
|
*
|
|
* @param string $module 모듈명
|
|
* @param string $messageKey 멤시지 키
|
|
* @param mixed $data 응답 데이터
|
|
* @param int $statusCode HTTP 상태 코드 (기본값: 200)
|
|
* @param array $messageParams 멤시지 매개변수
|
|
* @return JsonResponse JSON 응답
|
|
*/
|
|
public static function moduleSuccess(
|
|
string $module,
|
|
string $messageKey,
|
|
mixed $data = null,
|
|
int $statusCode = 200,
|
|
array $messageParams = []
|
|
): JsonResponse {
|
|
return self::success($messageKey, $data, $statusCode, $messageParams, $module);
|
|
}
|
|
|
|
/**
|
|
* 모듈별 실패 응답을 생성합니다.
|
|
*
|
|
* @param string $module 모듈명
|
|
* @param string $messageKey 멤시지 키
|
|
* @param int $statusCode HTTP 상태 코드 (기본값: 400)
|
|
* @param mixed $errors 오류 정보
|
|
* @param array $messageParams 멤시지 매개변수
|
|
* @return JsonResponse JSON 응답
|
|
*/
|
|
public static function moduleError(
|
|
string $module,
|
|
string $messageKey,
|
|
int $statusCode = 400,
|
|
mixed $errors = null,
|
|
array $messageParams = []
|
|
): JsonResponse {
|
|
return self::error($messageKey, $statusCode, $errors, $messageParams, $module);
|
|
}
|
|
|
|
/**
|
|
* 플러그인별 성공 응답을 생성합니다.
|
|
*
|
|
* @param string $plugin 플러그인명
|
|
* @param string $messageKey 멤시지 키
|
|
* @param mixed $data 응답 데이터
|
|
* @param int $statusCode HTTP 상태 코드 (기본값: 200)
|
|
* @param array $messageParams 멤시지 매개변수
|
|
* @return JsonResponse JSON 응답
|
|
*/
|
|
public static function pluginSuccess(
|
|
string $plugin,
|
|
string $messageKey,
|
|
mixed $data = null,
|
|
int $statusCode = 200,
|
|
array $messageParams = []
|
|
): JsonResponse {
|
|
return self::success($messageKey, $data, $statusCode, $messageParams, $plugin);
|
|
}
|
|
|
|
/**
|
|
* 플러그인별 실패 응답을 생성합니다.
|
|
*
|
|
* @param string $plugin 플러그인명
|
|
* @param string $messageKey 멤시지 키
|
|
* @param int $statusCode HTTP 상태 코드 (기본값: 400)
|
|
* @param mixed $errors 오류 정보
|
|
* @param array $messageParams 멤시지 매개변수
|
|
* @return JsonResponse JSON 응답
|
|
*/
|
|
public static function pluginError(
|
|
string $plugin,
|
|
string $messageKey,
|
|
int $statusCode = 400,
|
|
mixed $errors = null,
|
|
array $messageParams = []
|
|
): JsonResponse {
|
|
return self::error($messageKey, $statusCode, $errors, $messageParams, $plugin);
|
|
}
|
|
|
|
/**
|
|
* JSON Resource를 사용한 성공 응답을 생성합니다.
|
|
*
|
|
* `$resource->additional([...])` 로 실은 부가 데이터는 응답 최상위에 함께 나갑니다.
|
|
* `JsonResource::resolve()` 는 리소스 본문만 돌려주므로, 그것만 쓰면 호출자가 실은
|
|
* 부가 데이터가 조용히 사라집니다 — 예를 들어 확장 업데이트 응답의 `search_index`
|
|
* (색인 누락 안내)가 사라지면 검색이 0건을 돌려주는 사실을 운영자가 알 방법이 없습니다.
|
|
* `success`/`message`/`data` 키는 부가 데이터로 덮어쓰지 않습니다.
|
|
*
|
|
* @param string $messageKey 메시지 키 (기본값: 'common.success')
|
|
* @param JsonResource|ResourceCollection|null $resource JSON 리소스
|
|
* @param int $statusCode HTTP 상태 코드 (기본값: 200)
|
|
* @param array $messageParams 멤시지 매개변수
|
|
* @param string $domain 번역 도메인 (기본값: 'core')
|
|
* @return JsonResponse JSON 응답
|
|
*/
|
|
public static function successWithResource(
|
|
string $messageKey = 'common.success',
|
|
JsonResource|ResourceCollection|null $resource = null,
|
|
int $statusCode = 200,
|
|
array $messageParams = [],
|
|
string $domain = 'core'
|
|
): JsonResponse {
|
|
$data = $resource ? $resource->resolve() : null;
|
|
|
|
$payload = [
|
|
'success' => true,
|
|
'message' => self::trans($messageKey, $messageParams, $domain),
|
|
'data' => $data,
|
|
];
|
|
|
|
$additional = $resource instanceof JsonResource ? (array) $resource->additional : [];
|
|
|
|
if ($additional !== []) {
|
|
$payload += array_diff_key($additional, $payload);
|
|
}
|
|
|
|
return response()->json($payload, $statusCode, [], self::JSON_ENCODE_OPTIONS);
|
|
}
|
|
|
|
/**
|
|
* JSON Resource를 사용한 모듈 성공 응답을 생성합니다.
|
|
*
|
|
* @param string $module 모듈명
|
|
* @param string $messageKey 멤시지 키
|
|
* @param JsonResource|ResourceCollection|null $resource JSON 리소스
|
|
* @param int $statusCode HTTP 상태 코드 (기본값: 200)
|
|
* @param array $messageParams 멤시지 매개변수
|
|
* @return JsonResponse JSON 응답
|
|
*/
|
|
public static function moduleSuccessWithResource(
|
|
string $module,
|
|
string $messageKey,
|
|
JsonResource|ResourceCollection|null $resource = null,
|
|
int $statusCode = 200,
|
|
array $messageParams = []
|
|
): JsonResponse {
|
|
return self::successWithResource($messageKey, $resource, $statusCode, $messageParams, $module);
|
|
}
|
|
|
|
/**
|
|
* JSON Resource를 사용한 플러그인 성공 응답을 생성합니다.
|
|
*
|
|
* @param string $plugin 플러그인명
|
|
* @param string $messageKey 멤시지 키
|
|
* @param JsonResource|ResourceCollection|null $resource JSON 리소스
|
|
* @param int $statusCode HTTP 상태 코드 (기본값: 200)
|
|
* @param array $messageParams 멤시지 매개변수
|
|
* @return JsonResponse JSON 응답
|
|
*/
|
|
public static function pluginSuccessWithResource(
|
|
string $plugin,
|
|
string $messageKey,
|
|
JsonResource|ResourceCollection|null $resource = null,
|
|
int $statusCode = 200,
|
|
array $messageParams = []
|
|
): JsonResponse {
|
|
return self::successWithResource($messageKey, $resource, $statusCode, $messageParams, $plugin);
|
|
}
|
|
|
|
/**
|
|
* 페이지네이션된 리소스 응답을 생성합니다.
|
|
*
|
|
* @param string $messageKey 메시지 키 (기본값: 'common.success')
|
|
* @param ResourceCollection|null $collection 페이지네이션된 리소스 컬렉션
|
|
* @param array $messageParams 멤시지 매개변수
|
|
* @param string $domain 번역 도메인 (기본값: 'core')
|
|
* @return JsonResponse 페이지네이션 메타 데이터를 포함한 JSON 응답
|
|
*/
|
|
public static function successWithPagination(
|
|
string $messageKey = 'common.success',
|
|
?ResourceCollection $collection = null,
|
|
array $messageParams = [],
|
|
string $domain = 'core'
|
|
): JsonResponse {
|
|
$response = [
|
|
'success' => true,
|
|
'message' => self::trans($messageKey, $messageParams, $domain),
|
|
];
|
|
|
|
if ($collection) {
|
|
$paginationData = $collection->resolve();
|
|
$response['data'] = $paginationData['data'];
|
|
$response['meta'] = [
|
|
'current_page' => $paginationData['current_page'] ?? null,
|
|
'from' => $paginationData['from'] ?? null,
|
|
'last_page' => $paginationData['last_page'] ?? null,
|
|
'path' => $paginationData['path'] ?? null,
|
|
'per_page' => $paginationData['per_page'] ?? null,
|
|
'to' => $paginationData['to'] ?? null,
|
|
'total' => $paginationData['total'] ?? null,
|
|
];
|
|
$response['links'] = $paginationData['links'] ?? null;
|
|
}
|
|
|
|
return response()->json($response, 200, [], self::JSON_ENCODE_OPTIONS);
|
|
}
|
|
|
|
/**
|
|
* 본인인증 요구 응답을 생성합니다 (HTTP 428 Precondition Required).
|
|
*
|
|
* IDV 정책 미들웨어/Listener 가 IdentityVerificationRequiredException 을 던지면
|
|
* Handler 가 이 메서드로 응답을 만듭니다. 프론트 ErrorHandlingResolver 가 이 payload 로
|
|
* Challenge 모달을 자동 오픈하고 verify 성공 시 return_request 를 재실행합니다.
|
|
*
|
|
* @param array $verification policy_key/purpose/provider_id/render_hint/return_request 등
|
|
* @return JsonResponse 428 응답
|
|
*/
|
|
public static function identityRequired(array $verification): JsonResponse
|
|
{
|
|
return response()->json([
|
|
'success' => false,
|
|
'error_code' => 'identity_verification_required',
|
|
'message' => self::trans('identity.errors.verification_required', [], 'core'),
|
|
'verification' => $verification,
|
|
], 428, [], self::JSON_ENCODE_OPTIONS);
|
|
}
|
|
}
|