무통장입금 주문의 현금영수증 발급을 관리자·회원·비회원 세 경로에서 제공하고,
입금이 확인되면 자동 발급하며, 부분환불로 금액이 바뀌면 전액취소 후 재발급한다.
발급 프로바이더에 종속되지 않도록 발급/취소는 필터 훅으로 위임한다.
자동발급 리스너는 결제완료 전이(after_payment_complete)와 입금만 기록
(after_deposit_recorded) 두 경로를 모두 구독한다 — 후자가 없으면 관리자가
"입금만 기록"했을 때 자동발급이 통째로 누락된다.
금액 동기화(syncFromOrder)는 트랜잭션이 닫힌 뒤에 부른다. 재발급 실패 이력은
국세청 신고 누락을 막는 원장이라 롤백에 휩쓸리면 안 되고, 프로바이더 왕복 2회
동안 DB 잠금을 붙들 수 없기 때문이다. 환불은 이미 확정됐으므로 어떤 실패도
호출부로 전파하지 않고 FAILED 이력 + 로그로 남겨 관리자 수동 복구에 맡긴다.
식별번호는 재발급용으로만 암호화 보관하고 구매확정 시점에 폐기한다.
그 시점을 잡기 위해 order.after_purchase_confirmed 훅을 신설했다.
함께 고친 사전 결함:
- 취소 시 total_cash_equivalent_amount 가 재계산되지 않아 syncFromOrder 가
"금액 변동 없음"으로 판정, 부분환불 후 재발급이 아예 동작하지 않았다.
산정 규칙을 PaymentMethodEnum::resolveCashEquivalentAmount 로 SSoT 화해
주문 생성 시점과 취소 재계산 시점이 같은 규칙을 쓰게 했다.
- 현금영수증 발급이 payment.receipt_url 을 덮어써 카드 매출전표 URL 이
유실됐다. 한 컬럼이 두 의미를 갖던 문제로, 현금영수증 URL 은 이력 테이블이
보관하도록 분리했다.
- OrderService::confirmOption 이 confirmed_at 을 무조건 now 로 덮어썼다
(v0.16.1 부터 존재). 재확정 시 최초 확정 시점이 소실되고 구매확정 훅이
중복 발화한다. OrderOptionService 와 대칭으로 멱등 가드를 넣었다.
- 코어 api:docgen 이 사람이 작성한 API 문서를 재생성 때마다 지웠다.
@generated 블록 안의 표 셀·응답 본문이 대상이었고, extractGeneratedBlock
이 앞의 `## ` 헤딩까지 거슬러 올라가 여러 엔드포인트를 한 덩어리로 반환하던
문제도 함께 고쳤다. 실측 산출 응답 예시에 @probed 출처 마커를 붙여, 마커가
없는 본문은 사람 작성분으로 보고 보존한다 — 실측 1회의 표본은 사람이 코드에서
읽어낸 사실보다 좁으므로 실측은 기본값이지 덮어쓰기 권한이 아니다.
ParameterDescriber 가 {order} 같은 path 파라미터를 "정렬 방향 asc/desc" 로
설명하던 오염도 정정했다.
398 lines
17 KiB
PHP
398 lines
17 KiB
PHP
<?php
|
|
|
|
namespace App\Support\ApiDoc;
|
|
|
|
use Illuminate\Support\Str;
|
|
|
|
/**
|
|
* 요청 파라미터 설명기
|
|
*
|
|
* G7 전역에서 의미가 표준화된 공통 요청 파라미터(페이지네이션·정렬·검색·필터·
|
|
* 소프트삭제 토글 등)와, 일관된 명명 규칙(*_id / *Id / is_* / *_date / sort_* 등)의
|
|
* 설명을 코드에서 확인된 계약 그대로 서술합니다.
|
|
*
|
|
* ResourceFieldDescriber(응답 필드)의 요청 파라미터 대응물입니다. 도메인 특이
|
|
* 파라미터(예: refund_priority, temp_key)는 여기서 커버하지 않고 사람 서술(TODO)로
|
|
* 남깁니다 — 자동 채움은 "도메인 무관하게 의미가 고정된 파라미터"에만 한정합니다.
|
|
*/
|
|
class ParameterDescriber
|
|
{
|
|
/**
|
|
* @var array<string, string> 정확 이름 => 설명 (위치 무관 공통 파라미터)
|
|
*/
|
|
private const EXACT = [
|
|
// 페이지네이션
|
|
'page' => '조회할 페이지 번호 (1부터 시작)',
|
|
'per_page' => '페이지당 항목 수',
|
|
'limit' => '반환할 최대 항목 수',
|
|
'offset' => '건너뛸 항목 수 (오프셋 페이지네이션)',
|
|
'cursor' => '커서 기반 페이지네이션의 다음 페이지 커서',
|
|
|
|
// 정렬
|
|
'sort' => '정렬 기준 (필드명, `-` 접두 시 내림차순)',
|
|
'sort_by' => '정렬 기준 필드명',
|
|
'sort_field' => '정렬 기준 필드명',
|
|
'sort_direction' => '정렬 방향 (asc / desc)',
|
|
'order_by' => '정렬 기준 필드명',
|
|
'direction' => '정렬 방향 (asc / desc)',
|
|
// sort_order / order 는 문맥에 따라 정렬 방향(문자열 asc/desc)과
|
|
// 표시 순서 값(정수)으로 갈리므로 EXACT 에 두지 않고 describe() 에서
|
|
// 타입으로 분기한다.
|
|
|
|
// 검색/필터
|
|
'search' => '검색어 (지정한 검색 대상 필드에서 부분 일치)',
|
|
'q' => '검색어 (부분 일치)',
|
|
'keyword' => '검색 키워드 (부분 일치)',
|
|
'search_keyword' => '검색 키워드 (부분 일치)',
|
|
'search_field' => '검색 대상 필드명 (검색어를 적용할 컬럼)',
|
|
'search_type' => '검색 유형 (검색 대상/방식 구분)',
|
|
'filters' => '추가 필터 조건 맵 (필드별 조건)',
|
|
'filter' => '필터 조건',
|
|
'start_date' => '조회 기간 시작일 (이 날짜 이후 데이터)',
|
|
'end_date' => '조회 기간 종료일 (이 날짜 이전 데이터)',
|
|
'date_from' => '조회 기간 시작일',
|
|
'date_to' => '조회 기간 종료일',
|
|
'from' => '조회 시작 값 (기간/범위 하한)',
|
|
'to' => '조회 종료 값 (기간/범위 상한)',
|
|
'scope' => '조회 범위 한정 키',
|
|
|
|
// 상태 토글/플래그
|
|
'is_active' => '활성 여부 (true 활성 / false 비활성)',
|
|
'is_default' => '기본값 지정 여부',
|
|
'active' => '활성 여부',
|
|
'published' => '발행 여부 (발행된 항목만 필터)',
|
|
'enabled' => '사용 여부',
|
|
'force' => '강제 실행 여부 (안전 확인/선행 검사 우회)',
|
|
'with_trashed' => '소프트 삭제된 항목 포함 여부',
|
|
'only_trashed' => '소프트 삭제된 항목만 조회 여부',
|
|
|
|
// 대량 처리
|
|
'ids' => '대상 리소스 식별자 배열 (대량 작업 대상)',
|
|
'items' => '처리 대상 항목 배열',
|
|
|
|
// 국제화
|
|
'locale' => '로케일 코드 (표시 언어/지역)',
|
|
'language' => '언어 코드',
|
|
'country_code' => '국가 코드 (ISO 3166-1 alpha-2)',
|
|
'timezone' => '타임존 식별자',
|
|
|
|
// 인증/보안 공통
|
|
'password' => '비밀번호',
|
|
'current_password' => '현재 비밀번호 (변경 전 확인용)',
|
|
'password_confirmation' => '비밀번호 확인 (password 와 일치해야 함)',
|
|
'token' => '인증/검증 토큰',
|
|
'email' => '이메일 주소',
|
|
|
|
// 주소 공통
|
|
'zipcode' => '우편번호',
|
|
'address' => '기본 주소',
|
|
'address_detail' => '상세 주소',
|
|
'recipient_name' => '수령인 이름',
|
|
'recipient_phone' => '수령인 연락처',
|
|
'address_line_1' => '주소 1행 (기본 주소)',
|
|
'address_line_2' => '주소 2행 (상세 주소)',
|
|
'intl_city' => '도시 (국제 주소)',
|
|
'intl_state' => '주/도 (국제 주소)',
|
|
'intl_postal_code' => '우편번호 (국제 주소)',
|
|
'region' => '지역/권역',
|
|
|
|
// SEO 메타 공통 (근거: Seo\* / Page\* / Product\* FormRequest 의
|
|
// meta_title/meta_description — 검색엔진 노출용 메타 태그 값. 도메인 무관.)
|
|
'meta_title' => 'SEO 메타 제목 (검색엔진/소셜 공유 표시 제목)',
|
|
'meta_description' => 'SEO 메타 설명 (검색엔진/소셜 공유 표시 요약)',
|
|
'alt_text' => '이미지 대체 텍스트 (접근성/이미지 미표시 시 대체 문구)',
|
|
|
|
// 프로필/콘텐츠 공통 필드 (User/프로필/일반 리소스에서 의미 고정)
|
|
// 근거: User\{Create,Update}UserRequest, UpdateProfileRequest, Auth\RegisterRequest,
|
|
// Layout\* / Menu\* / Notification* / Schedule\* FormRequest
|
|
'name' => '대상의 이름/명칭',
|
|
'nickname' => '닉네임',
|
|
'description' => '설명',
|
|
'content' => '본문 내용',
|
|
'body' => '본문',
|
|
'subject' => '제목',
|
|
'title' => '제목',
|
|
'slug' => 'URL 친화 식별자 (slug)',
|
|
'label' => '표시용 라벨',
|
|
'phone' => '전화번호',
|
|
'mobile' => '휴대전화 번호',
|
|
'homepage' => '홈페이지 URL',
|
|
'bio' => '자기소개',
|
|
'signature' => '서명',
|
|
'country' => '국가 코드 (ISO 3166-1 alpha-2)',
|
|
'url' => 'URL',
|
|
'file' => '업로드 파일',
|
|
'files' => '업로드 파일 배열',
|
|
'collection' => '첨부 컬렉션 그룹명 (첨부를 용도별로 묶는 키, 미지정 시 default)',
|
|
'avatar' => '아바타 이미지',
|
|
'icon' => '아이콘',
|
|
'value' => '값',
|
|
'values' => '값 배열',
|
|
'username' => '사용자명 (로그인/인증 아이디)',
|
|
'path' => '경로',
|
|
'data' => '데이터 페이로드',
|
|
|
|
// 확장/버전 공통 (module/plugin/template/language-pack 설치·업데이트 계약)
|
|
// 근거: Module/Plugin/Template/LanguagePack Install·Update·Activate FormRequest,
|
|
// Extension\ChangelogRequest, Menu/Schedule/Notification* Request
|
|
'extension_type' => '확장 유형 (core/module/plugin/template)',
|
|
'extension_identifier' => '확장 식별자',
|
|
'from_version' => '시작 버전 (범위 하한)',
|
|
'to_version' => '대상 버전 (범위 상한)',
|
|
'github_url' => 'GitHub 저장소 URL',
|
|
'vendor' => '벤더명 (확장 제작자 식별자)',
|
|
'vendor_mode' => '벤더 설치 모드 (auto/composer/bundled)',
|
|
'checksum' => '무결성 검증 체크섬 (SHA-256)',
|
|
'target_identifier' => '대상 확장 식별자',
|
|
'source_identifier' => '출처 식별자',
|
|
'auto_activate' => '설치 후 자동 활성화 여부',
|
|
'cascade' => '연쇄 처리 여부 (의존 항목 함께 처리)',
|
|
'exclude_protected' => '보호 항목 제외 여부',
|
|
|
|
// 스케줄/작업 공통 (근거: Schedule\{Create,Update}ScheduleRequest, ScheduleListRequest)
|
|
'command' => '실행할 아티즌 커맨드',
|
|
'frequency' => '실행 주기',
|
|
'priority' => '우선순위 (작을수록 우선)',
|
|
'timeout' => '타임아웃 (초)',
|
|
'run_in_maintenance' => '점검 모드 중 실행 여부',
|
|
'without_overlapping' => '중복 실행 방지 여부',
|
|
'expected_lock_version' => '낙관적 잠금 버전 (동시 편집 충돌 감지)',
|
|
|
|
// 메일/드라이버 설정 (근거: Settings\SaveSettingsRequest,
|
|
// Settings\TestMailRequest, Settings\TestDriverConnectionRequest)
|
|
'mailer' => '메일 발송 드라이버 (smtp/mailgun/ses)',
|
|
'from_address' => '발신자 주소',
|
|
'from_name' => '발신자 이름',
|
|
'to_email' => '테스트 수신 주소',
|
|
'host' => '호스트 주소',
|
|
'port' => '포트 번호',
|
|
'encryption' => '전송 암호화 방식 (tls/ssl)',
|
|
'storage_driver' => '스토리지 드라이버 (local/s3)',
|
|
'cache_driver' => '캐시 드라이버 (file/redis/memcached)',
|
|
'session_driver' => '세션 드라이버 (file/database/redis)',
|
|
'queue_driver' => '큐 드라이버 (sync/database/redis)',
|
|
'redis_host' => 'Redis 호스트 주소',
|
|
'redis_port' => 'Redis 포트 번호',
|
|
'redis_password' => 'Redis 비밀번호',
|
|
'redis_database' => 'Redis 데이터베이스 번호',
|
|
'memcached_host' => 'Memcached 호스트 주소',
|
|
'memcached_port' => 'Memcached 포트 번호',
|
|
's3_bucket' => 'S3 버킷명',
|
|
's3_region' => 'S3 리전',
|
|
's3_access_key' => 'S3 액세스 키',
|
|
's3_secret_key' => 'S3 시크릿 키',
|
|
's3_url' => 'S3 엔드포인트 URL',
|
|
'ses_key' => 'SES 액세스 키',
|
|
'ses_secret' => 'SES 시크릿 키',
|
|
'ses_region' => 'SES 리전',
|
|
'mailgun_domain' => 'Mailgun 도메인',
|
|
'mailgun_secret' => 'Mailgun 시크릿 키',
|
|
'mailgun_endpoint' => 'Mailgun 엔드포인트',
|
|
'websocket_enabled' => 'WebSocket 사용 여부',
|
|
'websocket_host' => 'WebSocket 호스트 주소',
|
|
'websocket_port' => 'WebSocket 포트 번호',
|
|
'websocket_scheme' => 'WebSocket 스킴 (http/https)',
|
|
'websocket_app_key' => 'WebSocket 앱 키',
|
|
];
|
|
|
|
/**
|
|
* 파라미터 설명을 반환합니다. 없으면 null (호출자가 TODO 로 폴백).
|
|
*
|
|
* @param string $name 파라미터명
|
|
* @param string $location 위치 (path/query/body)
|
|
* @param string $type 타입 (integer/string/boolean/array...)
|
|
* @return string|null 설명 (없으면 null)
|
|
*/
|
|
public function describe(string $name, string $location = '', string $type = ''): ?string
|
|
{
|
|
// path 파라미터는 언제나 리소스 식별자다(라우트 모델 바인딩). 이름이 무엇이든
|
|
// 정렬·필터 같은 조회 파라미터 의미를 가질 수 없으므로 이름 기반 규칙보다 앞서 처리한다.
|
|
// (회귀: `{order}` path 가 "정렬 방향 asc/desc" 로 설명되던 문제)
|
|
if ($location === 'path') {
|
|
return self::EXACT[$name] ?? $this->describePathParam($name);
|
|
}
|
|
|
|
// sort_order / order 는 타입에 따라 의미가 갈린다:
|
|
// - 문자열: 정렬 방향(asc/desc)
|
|
// - 정수: 표시 정렬 순서 값(작을수록 우선 — 컬럼 값)
|
|
if (in_array($name, ['sort_order', 'order'], true)) {
|
|
return match ($type) {
|
|
'integer', 'number' => '표시 정렬 순서 값 (작을수록 우선)',
|
|
'string' => '정렬 방향 (asc 오름차순 / desc 내림차순)',
|
|
default => null,
|
|
};
|
|
}
|
|
|
|
// status / type / category 는 query(목록 조회)에서만 필터 의미가 고정된다.
|
|
// body(생성/수정)에서는 설정할 도메인 값이므로 의미가 도메인마다 달라
|
|
// 사람 서술(TODO)로 남긴다.
|
|
if (in_array($name, ['status', 'type', 'category'], true)) {
|
|
if ($location !== 'query') {
|
|
return null;
|
|
}
|
|
$label = ['status' => '상태', 'type' => '유형', 'category' => '분류'][$name];
|
|
|
|
return "{$label} 필터 (해당 {$label}의 항목만 조회)";
|
|
}
|
|
|
|
if (isset(self::EXACT[$name])) {
|
|
return self::EXACT[$name];
|
|
}
|
|
|
|
// path 파라미터는 위 진입부에서 이미 처리했다.
|
|
return $this->byPattern($name, $type);
|
|
}
|
|
|
|
/**
|
|
* path 파라미터(리소스 식별자)의 설명을 유추합니다.
|
|
*
|
|
* @param string $name path 파라미터명
|
|
* @return string|null 설명 (미매칭 시 null)
|
|
*/
|
|
private function describePathParam(string $name): ?string
|
|
{
|
|
// 순수 id / *_id / *Id: 대상 리소스의 식별자
|
|
if ($name === 'id') {
|
|
return '대상 리소스의 식별자';
|
|
}
|
|
if (Str::endsWith($name, '_id')) {
|
|
$base = $this->humanize(Str::beforeLast($name, '_id'));
|
|
|
|
return "대상 {$base}의 식별자";
|
|
}
|
|
if (Str::endsWith($name, 'Id') && $name !== 'Id') {
|
|
$base = $this->humanizeCamel(Str::beforeLast($name, 'Id'));
|
|
|
|
return "대상 {$base}의 식별자";
|
|
}
|
|
|
|
// slug / identifier / hash / uuid: 리소스 지시 키
|
|
if (in_array($name, ['slug', 'identifier', 'hash', 'uuid', 'code'], true)) {
|
|
$labels = [
|
|
'slug' => '대상 리소스의 slug (URL 친화 식별자)',
|
|
'identifier' => '대상 리소스의 식별자',
|
|
'hash' => '대상 리소스의 해시 식별자',
|
|
'uuid' => '대상 리소스의 UUID',
|
|
'code' => '대상 리소스의 코드',
|
|
];
|
|
|
|
return $labels[$name];
|
|
}
|
|
|
|
// *Name (templateName, pluginName, moduleName): 확장/리소스 이름 식별자
|
|
if (Str::endsWith($name, 'Name') && $name !== 'Name') {
|
|
$base = $this->humanizeCamel(Str::beforeLast($name, 'Name'));
|
|
|
|
return "대상 {$base}의 이름 (식별자)";
|
|
}
|
|
|
|
// *Identifier (templateIdentifier 등): 확장/리소스 식별자
|
|
if (Str::endsWith($name, 'Identifier') && $name !== 'Identifier') {
|
|
$base = $this->humanizeCamel(Str::beforeLast($name, 'Identifier'));
|
|
|
|
return "대상 {$base}의 식별자";
|
|
}
|
|
|
|
// bare 리소스명 path 파라미터: Laravel route-model binding 은
|
|
// `/{user}`, `/{role}`, `/{definition}` 처럼 대상 모델의 단수형(또는
|
|
// camelCase)을 그대로 세그먼트로 쓴다. 접미 패턴(_id/slug/*Id 등)에
|
|
// 걸리지 않은 path 파라미터는 이 바인딩 대상 리소스의 식별자로 본다.
|
|
// 예외: key/version 은 리소스가 아니라 설정 키/버전 값이므로 EXACT 폴백.
|
|
$bareExact = [
|
|
'key' => '대상 설정/항목의 키',
|
|
'version' => '대상 버전 (버전 문자열)',
|
|
];
|
|
if (isset($bareExact[$name])) {
|
|
return $bareExact[$name];
|
|
}
|
|
$base = str_contains($name, '_')
|
|
? $this->humanize($name)
|
|
: $this->humanizeCamel($name);
|
|
|
|
return "대상 {$base}의 식별자";
|
|
}
|
|
|
|
/**
|
|
* query/body 파라미터의 일관된 명명 규칙으로 설명을 유추합니다.
|
|
*
|
|
* @param string $name 파라미터명
|
|
* @param string $type 타입
|
|
* @return string|null 설명 (미매칭 시 null)
|
|
*/
|
|
private function byPattern(string $name, string $type): ?string
|
|
{
|
|
// identifier: 확장/리소스 지시 식별자 (query/body).
|
|
// path 위치는 describePathParam 이 먼저 처리하므로 여기 도달하지 않는다.
|
|
if ($name === 'identifier') {
|
|
return '대상 확장/리소스의 식별자';
|
|
}
|
|
|
|
// *_name: 확장/리소스 이름 식별자 (template_name/plugin_name/module_name/layout_name 등).
|
|
// EXACT 의 recipient_name/from_name 은 여기 도달 전에 이미 처리된다.
|
|
if (Str::endsWith($name, '_name')) {
|
|
$base = $this->humanize(Str::beforeLast($name, '_name'));
|
|
|
|
return "{$base} 이름 (식별자)";
|
|
}
|
|
|
|
// *_id: 연관 리소스 식별자 참조
|
|
if (Str::endsWith($name, '_id')) {
|
|
$base = $this->humanize(Str::beforeLast($name, '_id'));
|
|
|
|
return "{$base} 식별자";
|
|
}
|
|
|
|
// *_ids: 연관 리소스 식별자 배열
|
|
if (Str::endsWith($name, '_ids')) {
|
|
$base = $this->humanize(Str::beforeLast($name, '_ids'));
|
|
|
|
return "{$base} 식별자 배열";
|
|
}
|
|
|
|
// is_*/has_*: 불리언 토글
|
|
if ((Str::startsWith($name, 'is_') || Str::startsWith($name, 'has_')) && $type === 'boolean') {
|
|
$base = $this->humanize(Str::after($name, '_'));
|
|
|
|
return "{$base} 여부";
|
|
}
|
|
|
|
// *_date: 날짜 값
|
|
if (Str::endsWith($name, '_date')) {
|
|
$base = $this->humanize(Str::beforeLast($name, '_date'));
|
|
|
|
return "{$base} 날짜";
|
|
}
|
|
|
|
// *_at: 일시 값
|
|
if (Str::endsWith($name, '_at')) {
|
|
$base = $this->humanize(Str::beforeLast($name, '_at'));
|
|
|
|
return "{$base} 일시";
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* snake_case 를 사람이 읽는 문구로 변환합니다.
|
|
*
|
|
* @param string $token snake_case 토큰
|
|
* @return string 공백 구분 문구
|
|
*/
|
|
private function humanize(string $token): string
|
|
{
|
|
return str_replace('_', ' ', $token);
|
|
}
|
|
|
|
/**
|
|
* camelCase 를 사람이 읽는 문구로 변환합니다.
|
|
*
|
|
* @param string $token camelCase 토큰
|
|
* @return string 공백 구분 소문자 문구
|
|
*/
|
|
private function humanizeCamel(string $token): string
|
|
{
|
|
return Str::lower(trim(preg_replace('/([A-Z])/', ' $1', $token)));
|
|
}
|
|
}
|