feat(identity): NHN KCP 휴대폰 본인확인 플러그인 추가

메일 인증 대신 휴대폰 본인확인을 쓸 수 있게 하는 플러그인을 추가한다.
가입·비밀번호 재설정·성인인증 등 코어 IDV 가 강제하는 모든 지점에서 동작하며,
테스트 모드로 계약 없이 전 흐름을 확인할 수 있다.

구현·검수 과정에서 드러난 저장소 전역 결함을 함께 처리했다.

- 외래키 컬럼의 한국어 comment 가 방출되지 않던 문제 (소스 38건 교정 +
 기설치본 백필 업그레이드 스텝 7종). ->comment 를 ->constrained 뒤에
 체인하면 예외 없이 통과하면서 comment 가 조용히 사라진다
- 로케일 전환 후 확장 액션 핸들러가 소실되던 문제 (플러그인 3종에
 재등록 진입점 노출)
- 본인인증 상태 폴링 응답이 누적 시도 횟수를 함께 내보내던 문제
- 인증 안내 문구와 실제 진행 방식의 폭 경계 불일치 (768~1023px).
 엔진과 같은 값을 같은 방법으로 읽도록 교정
- transition_overlay_target 이 replace 없이 무효이던 관리자 목록 40곳.
 로딩 표시가 나오지 않아도 경고가 남지 않아 화면을 봐야만 알 수 있었다

두 인증 플러그인의 코어 최소 요구 버전을 7.0.6 으로 올린다. 운영 모드
자격증명 필수 검사가 7.0.6 의 설정 저장 검증 표면에 의존하며, 그보다 낮은
코어에서는 그 검사가 조용히 통과해 빈 자격증명으로 저장할 수 있었다.

재발 차단으로 정적 검사 룰 4종을 신설하고, 검사 도구가 미커밋 신규 확장을
"검사 파일 0" 으로 통과시키던 사각을 함께 막았다.
This commit is contained in:
HeuJung
2026-07-31 00:32:46 +09:00
parent eca1232246
commit bd215bc586
226 changed files with 21048 additions and 2192 deletions
+5 -1
View File
@@ -153,7 +153,7 @@
| 코어 | [docs/backend/api/README.md](docs/backend/api/README.md) | 35 / 291 |
### 확장 API 레퍼런스 (11개 확장, 자동 스캔)
### 확장 API 레퍼런스 (12개 확장, 자동 스캔)
> 각 확장이 소유하는 API 문서 목차. `php artisan api:docgen` 이 생성하며, 이 표는 `{modules,plugins}/_bundled/*/docs/api/README.md` 를 패턴 스캔해 자동 편입된다(확장명 하드코딩 없음).
@@ -170,6 +170,7 @@
| `sirsoft-pay_nhnkcp` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/README.md) | 0 / 0 |
| `sirsoft-pay_nicepayments` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-pay_nicepayments/docs/api/README.md) | 0 / 0 |
| `sirsoft-verification_kginicis` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-verification_kginicis/docs/api/README.md) | 1 / 1 |
| `sirsoft-verification_nhnkcp` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-verification_nhnkcp/docs/api/README.md) | 1 / 1 |
<!-- AUTO-GENERATED-END: docs-quick-reference -->
@@ -760,6 +761,8 @@ Controller → Request → Service → RepositoryInterface → Repository → Mo
절대 금지: DB CASCADE에 의존한 삭제 → Service에서 명시적 삭제 (훅/파일/로깅 보장)
절대 금지: 로케일 하드코딩 → config('app.supported_locales') 사용
필수: 마이그레이션 한국어 comment 필수, down() 구현 필수
필수: FK 컬럼의 ->comment() 는 ->constrained()/->references()/->on() 앞에 둔다 (뒤에 두면 comment 가 컬럼이 아닌 FK 정의에 부착되어 조용히 사라진다)
필수: 소스 교정만으로는 기설치본이 낫지 않는다 — 마이그레이션은 재실행되지 않으므로 업그레이드 스텝 백필을 함께 작성
주의: ResponseHelper::success($messageKey, $data) — 메시지가 첫 번째 인수
```
@@ -806,6 +809,7 @@ BaseApiController (최상위)
필수: 확장 코드 변경 시 manifest 버전 업 (미변경 시 업데이트 감지 불가)
필수: 버전 업 시 CHANGELOG.md 기록 — Keep a Changelog 표준 (미기록 시 버전 업 불가)
필수: StorageInterface 사용 (Storage::disk() 직접 호출 금지)
필수: ActionDispatcher 에 핸들러를 등록하는 확장은 재등록 진입점을 window 전역에 고정 이름으로 노출 — 모듈 window.__[Name].initModule, 플러그인 window.__[Name].initPlugin (미노출 시 로케일 전환 후 해당 확장 액션이 전부 무반응, 에러·토스트 없음). 진입점은 핸들러 재등록만 수행
필수: 확장 미들웨어는 getMiddleware() 로 부착 대상(targets) 명시 선언 (self-gate) — SP Kernel 미들웨어 그룹 직접 조작·라우트 파일 자기 미들웨어 FQCN 부착 금지, 무규율 전역 개입 금지
필수: 코어 레이아웃에 모듈 UI 주입은 layout_extensions만 사용
필수: 모든 확장 작업은 Artisan 커맨드로 수행
+2
View File
@@ -73,6 +73,8 @@
- 데이터베이스 연결이 일시적으로 끊기거나 확장 설치가 중간에 실패한 직후 확장 목록을 다시 만들면, 설치된 모듈·플러그인 정보가 통째로 비워지면서 게시판·쇼핑몰 등 모든 확장 화면이 500 오류를 내던 문제를 수정했습니다. 이제 확장 폴더가 그대로 남아 있는데 목록만 비어 있으면 기존 정보를 지우지 않고 보존하며, 왜 갱신하지 않았는지 로그에 남깁니다.
- 확장 목록이 손상된 상태에서 복구 명령을 실행하면 목록은 되살아나지만 확장 기능 연결이 비어 있는 채로 남아, 명령을 두 번 실행해야 완전히 복구되던 문제를 수정했습니다. 이제 한 번 실행으로 모두 복구됩니다.
- 데이터베이스에서 일부 컬럼의 설명이 비어 있던 문제를 바로잡았습니다. 회원·작성자 등 다른 표를 가리키는 컬럼이 대상이며, 업그레이드하면 이미 운영 중인 데이터베이스에도 설명이 채워집니다(직접 적어 넣은 설명은 그대로 둡니다).
- 본인인증 진행 상태를 조회하는 화면용 응답에 지금까지의 인증 시도 횟수가 함께 실려 나가던 것을 빼도록 바로잡았습니다. 원래 내보내지 않기로 한 값이며, 잠금 직전까지 시도 횟수를 맞춰 보는 데 쓰일 수 있었습니다. 화면 동작에는 영향이 없습니다.
- 데이터베이스 계정 설정이 잘못되었을 때 아무 설명 없이 빈 화면만 뜨던 문제를 수정했습니다. 이전에는 최고 권한 계정을 쓰거나 사용자명이 비어 있으면 사이트 기능이 통째로 동작을 멈추면서도 화면에는 아무 안내가 없어 원인을 알 수 없었습니다. 이제 무엇이 잘못되었고 어떻게 고쳐야 하는지 설명하는 전용 안내 화면이 나타납니다. 서버 관리 명령은 평소대로 실행되므로 이 상태에서도 설정을 고칠 수 있습니다.
- 모듈·플러그인의 연동 기능이 실행된 뒤로 같은 요청의 나머지 처리가 요청 정보를 잃어버리던 문제를 수정했습니다. 백그라운드 작업을 별도 프로세스로 돌리지 않는 설정에서 이 현상이 나타나, 뒤이어 실행되는 연동 기능이 필요한 정보를 찾지 못하고 조용히 건너뛰었습니다. 대표적으로 비회원 장바구니에 담아 두고 로그인하면 담은 상품이 사라졌습니다.
- 모듈·플러그인 업데이트가 실패하거나 도중에 중단된 뒤, 확장은 정상인데 그 관리자 화면만 "페이지를 찾을 수 없습니다"로 나오고 하루가 지나야 돌아오던 문제를 수정했습니다. 업데이트가 진행되는 동안 사이트를 열어 본 사람이 있으면 그 순간의 "이 확장은 사용 중이 아님" 상태가 기억되는데, 실패로 끝나면 그 기억이 정리되지 않았습니다. 이제 실패로 끝나도 즉시 정리되며, 잘못된 상태가 애초에 기억되지 않도록 함께 보완했습니다.
@@ -106,7 +106,24 @@ class UpdatePluginSettingsRequest extends FormRequest
$rules[$field] = $fieldRules;
}
return HookManager::applyFilters('core.plugin_settings.update_rules', $rules, $identifier);
// audit:allow core-formrequest-hook-filter reason: 훅 이름이 표준(`{대상}.{동작}_validation_rules`)과
// 어긋난 상태로 이미 공개되어 있다(`core.plugin_settings.update_rules`). 같은 이탈이 코어에
// 6개 더 있으며(core.auth.validate_reset_token_rules / core.auth.verify_password_rules /
// core.extension.changelog_rules / core.layout.get_validation_messages /
// core.search.validation_rules / core.user.upload_avatar_rules), 이름을 바꾸면 이 훅을
// 구독 중인 제3자 확장이 조용히 동작을 멈춘다. 일괄 개명은 PO 판단 사항이라 여기서
// 단독으로 바꾸지 않는다.
//
// 3번째 인자로 이번 요청의 입력값을 함께 넘긴다. 확장이 "현재 입력한 모드에 따라
// 다른 필드를 필수로 만드는" 조건부 규칙을 만들려면 입력값이 필요한데, 그것이 없어
// 확장이 request() 를 직접 들여다보던 것을 없애기 위함이다.
// 기존 2인자 구독자는 그대로 동작한다(초과 인자는 무시된다).
return HookManager::applyFilters(
'core.plugin_settings.update_rules',
$rules,
$identifier,
$this->all(),
);
}
/**
+7 -6
View File
@@ -174,11 +174,11 @@ class IdentityVerificationService
* 비동기 검증 흐름(Stripe Identity / 토스인증 push / 외부 redirect 콜백 대기) 에서 클라이언트가
* `GET /api/identity/challenges/{id}` 로 상태를 폴링할 때 사용합니다.
*
* 반환 필드는 코드 본체·내부 metadata 를 제외한 공개 안전 필드만:
* - id / status / provider_id / purpose / render_hint / expires_at / attempts / max_attempts / public_payload
* 반환 필드는 코드 본체·내부 metadata·시도 횟수를 제외한 공개 안전 필드만:
* - id / status / provider_id / purpose / render_hint / expires_at / max_attempts / public_payload
*
* attempts/max_attempts 는 프론트 풀페이지 (`auth/identity_challenge.json`) 가 "남은 시도 횟수" UI
* 카운트다운에 사용한다. URL 직접 진입(외부 redirect 콜백 후) 흐름에서도 정확한 한도 표시 보장.
* max_attempts 는 정책 상수라 노출해도 무방하며, 프론트 풀페이지 (`auth/identity_challenge.json`) 가
* 시도 한도 표시에 사용한다. 누적 시도 횟수(attempts)는 싣지 않는다 — 사유는 반환 지점 주석 참조.
*
* @param string $challengeId Challenge UUID
* @return array<string, mixed>|null 공개 상태 또는 null (없는 경우)
@@ -207,8 +207,9 @@ class IdentityVerificationService
'render_hint' => $log->render_hint,
'expires_at' => optional($log->expires_at)->toIso8601String(),
// 시도 횟수(attempts)는 공개 폴링 응답에 싣지 않는다 — challenge id 만 알면 누구나
// 조회할 수 있는 경로라, 남의 인증 시도가 몇 번 실패했는지가 그대로 드러난다.
// 화면의 '남은 시도 횟수' 는 모달이 자기 시도를 세어 표시하므로 영향이 없다.
// 조회할 수 있는 경로라, 남의 인증 시도가 몇 번 실패했는지가 드러나고 잠금 직전까지
// 시도 횟수를 맞춰 보는 데도 쓰일 수 있다. 상한(max_attempts)은 정책 상수라 노출해도
// 무방하며, 화면의 '남은 시도 횟수' 는 모달이 자기 시도를 세어 표시하므로 영향이 없다.
'max_attempts' => (int) $log->max_attempts,
'public_payload' => $publicPayload,
];
@@ -24,7 +24,7 @@ return new class extends Migration
$table->string('extension_type', 20)->nullable()->comment('확장 소유 타입: core(코어), module(모듈), plugin(플러그인), NULL(사용자 정의)');
$table->string('extension_identifier', 255)->nullable()->comment('확장 식별자 (예: core, sirsoft-board, sirsoft-payment)');
$table->text('user_overrides')->nullable()->comment('유저가 수정한 필드명 목록 (예: ["name", "icon", "order"])');
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete()->comment('등록자 ID');
$table->foreignId('created_by')->nullable()->comment('등록자 ID')->constrained('users')->nullOnDelete();
$table->timestamps();
$table->index(['parent_id', 'order']);
@@ -14,7 +14,7 @@ return new class extends Migration
{
Schema::create('user_consents', function (Blueprint $table) {
$table->id()->comment('동의 이력 ID');
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete()->comment('사용자 ID');
$table->foreignId('user_id')->comment('사용자 ID')->constrained('users')->cascadeOnDelete();
$table->string('consent_type', 30)->comment('동의 유형: terms, privacy');
$table->timestamp('agreed_at')->comment('동의 일시');
$table->timestamp('revoked_at')->nullable()->comment('철회 일시 (향후 플러그인 확장용)');
@@ -28,7 +28,7 @@ return new class extends Migration
$table->boolean('update_available')->default(false)->comment('업데이트 가능 여부');
$table->string('update_source', 20)->nullable()->comment('업데이트 출처 (github, pending, bundled)');
$table->mediumText('config')->nullable()->comment('모듈 설정 정보');
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete()->comment('모듈 생성자 ID');
$table->foreignId('created_by')->nullable()->comment('모듈 생성자 ID')->constrained('users')->nullOnDelete();
$table->timestamps();
});
@@ -28,7 +28,7 @@ return new class extends Migration
$table->string('github_changelog_url', 512)->nullable()->comment('GitHub 변경 내역 URL');
$table->enum('status', ['active', 'inactive', 'installing', 'uninstalling', 'updating'])->default('inactive')->index()->comment('상태 (active: 활성화, inactive: 비활성화, installing: 설치 중, uninstalling: 제거 중, updating: 업데이트 중)');
$table->mediumText('hooks')->nullable()->comment('훅 설정 정보');
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete()->comment('플러그인 생성자 ID');
$table->foreignId('created_by')->nullable()->comment('플러그인 생성자 ID')->constrained('users')->nullOnDelete();
$table->timestamps();
});
@@ -14,7 +14,7 @@ return new class extends Migration
{
Schema::create('template_layouts', function (Blueprint $table) {
$table->id()->comment('레이아웃 ID');
$table->foreignId('template_id')->constrained('templates')->cascadeOnDelete()->comment('템플릿 ID');
$table->foreignId('template_id')->comment('템플릿 ID')->constrained('templates')->cascadeOnDelete();
$table->string('name')->comment('레이아웃 이름 (예: dashboard, users, user_edit)');
$table->longText('content')->comment('레이아웃 JSON 내용');
$table->string('extends')->nullable()->index()->comment('부모 레이아웃 이름 (예: layouts/_admin_base)');
@@ -14,7 +14,7 @@ return new class extends Migration
{
Schema::create('template_layout_versions', function (Blueprint $table) {
$table->id()->comment('버전 ID');
$table->foreignId('layout_id')->constrained('template_layouts')->cascadeOnDelete()->comment('레이아웃 ID');
$table->foreignId('layout_id')->comment('레이아웃 ID')->constrained('template_layouts')->cascadeOnDelete();
$table->unsignedInteger('version')->comment('버전 번호 (자동 증가)');
$table->longText('content')->comment('레이아웃 JSON 스냅샷');
$table->text('changes_summary')->nullable()->comment('변경 요약 JSON: {"added": 3, "removed": 2, "is_restored": false, "restored_from": null}');
@@ -14,7 +14,7 @@ return new class extends Migration
{
Schema::create('template_layout_extensions', function (Blueprint $table) {
$table->id()->comment('확장 ID');
$table->foreignId('template_id')->constrained('templates')->cascadeOnDelete()->comment('템플릿 ID (g7_templates 참조)');
$table->foreignId('template_id')->comment('템플릿 ID (g7_templates 참조)')->constrained('templates')->cascadeOnDelete();
$table->string('extension_type', 50)->comment('확장 타입: extension_point=확장점 방식, overlay=ID 기반 오버레이 방식');
$table->string('target_name')->comment('타겟 이름 (extension_point: 확장점명, overlay: 레이아웃명)');
$table->string('source_type', 50)->comment('출처 타입: template=템플릿(오버라이드용), module=모듈, plugin=플러그인');
@@ -15,12 +15,12 @@ return new class extends Migration
$table->id()->comment('활동 로그 ID');
$table->string('log_type', 20)->index()->comment('로그 유형 (admin: 관리자, user: 사용자, system: 시스템)');
$table->nullableMorphs('loggable');
$table->foreignId('user_id')->nullable()->index()->constrained('users')->nullOnDelete()->comment('사용자 ID');
$table->foreignId('user_id')->nullable()->index()->comment('사용자 ID')->constrained('users')->nullOnDelete();
$table->string('action', 50)->index()->comment('액션 유형 (created, updated, deleted, login, export 등)');
$table->text('description')->comment('액션 상세 설명');
$table->mediumText('properties')->nullable()->comment('변경 상세 데이터 (old/new 값)');
$table->string('ip_address', 45)->nullable()->comment('IP 주소 (IPv6 대응)');
$table->string('user_agent', 500)->nullable()->comment('User Agent');
$table->string('user_agent', 500)->nullable()->comment('접속 브라우저 정보 (User Agent)');
$table->timestamp('created_at')->useCurrent()->index()->comment('생성일시');
});
}
@@ -14,7 +14,7 @@ return new class extends Migration
Schema::create('template_layout_previews', function (Blueprint $table) {
$table->id()->comment('ID');
$table->char('token', 36)->unique()->comment('미리보기 URL 토큰 (UUID)');
$table->foreignId('template_id')->constrained('templates')->cascadeOnDelete()->comment('템플릿 ID');
$table->foreignId('template_id')->comment('템플릿 ID')->constrained('templates')->cascadeOnDelete();
$table->string('layout_name', 255)->comment('미리보기 대상 레이아웃 이름');
$table->longText('content')->comment('편집 중인 레이아웃 JSON');
$table->unsignedBigInteger('admin_id')->comment('미리보기 생성 관리자 ID');
@@ -15,14 +15,14 @@ return new class extends Migration
{
Schema::create('notification_templates', function (Blueprint $table) {
$table->id()->comment('ID');
$table->foreignId('definition_id')->constrained('notification_definitions')->cascadeOnDelete()->comment('알림 정의 ID');
$table->foreignId('definition_id')->comment('알림 정의 ID')->constrained('notification_definitions')->cascadeOnDelete();
$table->string('channel', 50)->comment('채널: mail, database, fcm');
$table->text('subject')->nullable()->comment('다국어 제목 ({"ko": "...", "en": "..."})');
$table->mediumText('body')->comment('다국어 본문 ({"ko": "...", "en": "..."})');
$table->boolean('is_active')->default(true)->comment('해당 채널 활성 여부');
$table->boolean('is_default')->default(true)->comment('시더 생성 여부');
$table->text('user_overrides')->nullable()->comment('사용자가 수정한 필드명 목록');
$table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete()->comment('수정자');
$table->foreignId('updated_by')->nullable()->comment('수정자')->constrained('users')->nullOnDelete();
$table->timestamps();
$table->unique(['definition_id', 'channel']);
@@ -19,12 +19,10 @@ return new class extends Migration
$table->string('notification_type', 100)->comment('알림 타입: welcome, order_confirmed 등');
$table->string('extension_type', 20)->default('core')->comment('확장 타입: core, module, plugin');
$table->string('extension_identifier', 100)->default('core')->comment('확장 식별자');
$table->foreignId('recipient_user_id')->nullable()->constrained('users')->nullOnDelete()
->comment('수신자 회원 ID (회원인 경우)');
$table->foreignId('recipient_user_id')->nullable()->comment('수신자 회원 ID (회원인 경우)')->constrained('users')->nullOnDelete();
$table->string('recipient_identifier', 255)->comment('수신자 식별자 (채널별: 이메일, 디바이스토큰, user_id 등)');
$table->string('recipient_name', 255)->nullable()->comment('수신자 표시명 (발송 시점 스냅샷)');
$table->foreignId('sender_user_id')->nullable()->constrained('users')->nullOnDelete()
->comment('발송자 회원 ID (null=시스템 자동)');
$table->foreignId('sender_user_id')->nullable()->comment('발송자 회원 ID (null=시스템 자동)')->constrained('users')->nullOnDelete();
$table->string('subject', 500)->nullable()->comment('렌더링된 제목');
$table->longText('body')->nullable()->comment('렌더링된 본문');
$table->string('status', 20)->default('sent')->comment('상태: sent, failed, skipped');
@@ -26,9 +26,9 @@ return new class extends Migration
$table->foreignId('user_id')
->nullable()
->index()
->comment('사용자 탈퇴 시 NULL 로 유지 — 감사 이력 보존 (CASCADE 금지 규정 준수)')
->constrained('users')
->nullOnDelete()
->comment('사용자 탈퇴 시 NULL 로 유지 — 감사 이력 보존 (CASCADE 금지 규정 준수)');
->nullOnDelete();
$table->string('target_hash', 64)->index()->comment('SHA256(email|phone) — PII 원본 저장 회피');
$table->string('status', 32)->index()->comment('인증 challenge 생명주기 상태 (requested|sent|processing|verified|failed|expired|cancelled|policy_violation_logged) — App\\Enums\\IdentityVerificationStatus enum');
@@ -39,7 +39,7 @@ return new class extends Migration
$table->json('manifest')->comment('language-pack.json 전체 스냅샷');
$table->string('source_type', 30)->nullable()->comment('설치 소스 유형 (zip/github/url/bundled/bundled_with_extension)');
$table->string('source_url', 500)->nullable()->comment('설치 소스 URL 또는 경로');
$table->foreignId('installed_by')->nullable()->constrained('users')->nullOnDelete()->comment('설치자 사용자 ID');
$table->foreignId('installed_by')->nullable()->comment('설치자 사용자 ID')->constrained('users')->nullOnDelete();
$table->timestamp('installed_at')->nullable()->comment('설치 시각');
$table->timestamp('activated_at')->nullable()->comment('활성화 시각');
$table->timestamps();
@@ -19,9 +19,9 @@ return new class extends Migration
Schema::create('identity_message_templates', function (Blueprint $table) {
$table->id()->comment('ID');
$table->foreignId('definition_id')
->comment('메시지 정의 ID (FK)')
->constrained('identity_message_definitions')
->cascadeOnDelete()
->comment('메시지 정의 ID (FK)');
->cascadeOnDelete();
$table->string('channel', 20)->comment('메시지 템플릿 채널 (mail 현재 / sms 등 향후) — IdentityVerificationChannel 과는 별개의 도메인 분류');
$table->text('subject')->nullable()
->comment('다국어 제목 ({"ko":"...", "en":"..."}) — mail 채널에서만 의미');
@@ -32,9 +32,9 @@ return new class extends Migration
->comment('운영자가 수정한 필드명 목록 (예: ["subject","body","is_active"])');
$table->foreignId('updated_by')
->nullable()
->comment('수정자 (사용자 삭제 시 NULL)')
->constrained('users')
->nullOnDelete()
->comment('수정자 (사용자 삭제 시 NULL)');
->nullOnDelete();
$table->timestamps();
$table->unique(['definition_id', 'channel'], 'idx_identity_message_tpl_def_channel_unique');
@@ -14,7 +14,7 @@ return new class extends Migration
{
Schema::create('template_layout_extension_versions', function (Blueprint $table) {
$table->id()->comment('버전 ID');
$table->foreignId('extension_id')->constrained('template_layout_extensions')->cascadeOnDelete()->comment('레이아웃 확장 ID');
$table->foreignId('extension_id')->comment('레이아웃 확장 ID')->constrained('template_layout_extensions')->cascadeOnDelete();
$table->unsignedInteger('version')->comment('버전 번호 (자동 증가)');
$table->longText('content')->comment('확장 정의 JSON 스냅샷');
$table->text('changes_summary')->nullable()->comment('변경 요약 JSON: {"added": 3, "removed": 2, "is_restored": false, "restored_from": null}');
@@ -18,9 +18,9 @@ return new class extends Migration
Schema::create('template_layout_attachments', function (Blueprint $table) {
$table->id()->comment('첨부 파일 ID');
$table->foreignId('template_id')
->comment('소속 템플릿 ID')
->constrained('templates')
->cascadeOnDelete()
->comment('소속 템플릿 ID');
->cascadeOnDelete();
$table->string('layout_name', 150)->nullable()->comment('사용 출처 레이아웃 이름');
$table->string('disk', 50)->comment('스토리지 디스크 이름');
$table->string('path', 500)->comment('스토리지 내 파일 경로');
@@ -29,9 +29,9 @@ return new class extends Migration
$table->unsignedBigInteger('size')->comment('파일 크기(바이트)');
$table->foreignId('created_by')
->nullable()
->comment('업로더 사용자 ID')
->constrained('users')
->nullOnDelete()
->comment('업로더 사용자 ID');
->nullOnDelete();
$table->timestamps();
$table->index(['template_id', 'layout_name'], 'index_template_layout');
@@ -14,16 +14,16 @@ return new class extends Migration
Schema::create('template_custom_translations', function (Blueprint $table) {
$table->id()->comment('커스텀 다국어 키 ID');
$table->foreignId('template_id')
->comment('소속 템플릿 ID')
->constrained('templates')
->cascadeOnDelete()
->comment('소속 템플릿 ID');
->cascadeOnDelete();
$table->string('layout_name', 150)->nullable()->comment('생성 출처 레이아웃 이름');
$table->string('translation_key', 255)->comment('다국어 키 ($t: 참조 경로)');
$table->json('values')->comment('로케일별 번역 값');
$table->json('user_overrides')->nullable()->comment('사용자 수정 보존 추적');
$table->enum('status', ['active', 'orphaned'])->default('active')->comment('상태 (active: 활성, orphaned: 고아)');
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete()->comment('생성자');
$table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete()->comment('수정자');
$table->foreignId('created_by')->nullable()->comment('생성자')->constrained('users')->nullOnDelete();
$table->foreignId('updated_by')->nullable()->comment('수정자')->constrained('users')->nullOnDelete();
$table->timestamps();
$table->unique(['template_id', 'translation_key'], 'template_custom_translations_template_key_unique');
+4 -1
View File
@@ -201,7 +201,7 @@ location ~* \.(js|css|json)$ { expires max; access_log off; }
> 각 확장이 자신의 API 문서를 소유합니다. 아래 표는 자동 생성됩니다.
<!-- @generated:start:api-readme-extensions -->
- **확장 수**: 10 · **엔드포인트 수**: 384
- **확장 수**: 13 · **엔드포인트 수**: 385
| 확장 | 유형 | API 문서 목차 | 문서/엔드포인트 |
| --- | --- | --- | --- |
@@ -214,6 +214,9 @@ location ~* \.(js|css|json)$ { expires max; access_log off; }
| `sirsoft-marketing` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-marketing/docs/api/README.md) | 2 / 2 |
| `sirsoft-message_bizppurio` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md) | 6 / 12 |
| `sirsoft-pay_kginicis` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-pay_kginicis/docs/api/README.md) | 5 / 22 |
| `sirsoft-pay_nhnkcp` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/README.md) | 0 / 0 |
| `sirsoft-pay_nicepayments` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-pay_nicepayments/docs/api/README.md) | 0 / 0 |
| `sirsoft-verification_kginicis` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-verification_kginicis/docs/api/README.md) | 1 / 1 |
| `sirsoft-verification_nhnkcp` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-verification_nhnkcp/docs/api/README.md) | 1 / 1 |
<!-- @generated:end -->
+1 -2
View File
@@ -1883,8 +1883,7 @@ _단건 응답: `data` 객체의 필드._
| purpose | string | `sensitive_action` | 인증 목적 (signup\|password_reset\|self_update\|sensitive_action\|login\|*module-defined*) — 코어 5종은 App\Enums\IdentityVerificationPurpose enum, 모듈/플러그인은 declaredPurposes 레지스트리 |
| render_hint | string | `text_code` | 프론트 렌더 힌트 (text_code\|link\|external_redirect) |
| expires_at | string | `2026-05-12T18:14:19+00:00` | expires 일시 |
| attempts | integer | `3` | 시도 횟수 |
| max_attempts | integer | `5` | 허용 최대 시도 횟수 |
| max_attempts | integer | `5` | 허용 최대 시도 횟수 (정책 상수). 누적 시도 횟수(attempts)는 잠금 직전까지 시도 횟수를 맞춰 보는 데 쓰일 수 있어 본 응답에 포함하지 않는다 |
| public_payload | array | `[]` | 프론트 렌더에 필요한 공개 안전 페이로드 (민감 metadata 제외, 프로바이더별 UI 힌트 — 없으면 빈 배열) |
**응답 예시**
+33
View File
@@ -126,6 +126,39 @@ return new class extends Migration
};
```
### 컬럼 comment 와 FK 체인 순서
`->comment()` 는 `->constrained()` / `->references()` / `->on()` **앞**에 둔다. 뒤에 체인하면 comment 가 컬럼이 아니라 외래키 정의에 부착되어 DB 에 방출되지 않는다.
```php
// ✅ comment 가 컬럼에 부착된다
$table->foreignId('user_id')
->nullable()
->comment('사용자 ID')
->constrained('users')
->nullOnDelete();
// ❌ comment 가 사라진다 — COLUMN_COMMENT 가 빈 문자열로 생성된다
$table->foreignId('user_id')
->nullable()
->constrained('users')
->nullOnDelete()
->comment('사용자 ID');
```
원인은 반환 타입 전이다. `ForeignIdColumnDefinition::constrained()` 는 `$this->references(...)->on(...)` 을 반환하며 이것은 `ColumnDefinition` 이 아니라 `ForeignKeyDefinition` 이다. `ForeignKeyDefinition` 은 `Fluent` 라서 `->comment()` 호출이 예외 없이 통과하지만, 그 값은 외래키 커맨드의 속성으로만 남고 MySQL grammar 의 `compileForeign` 은 comment 를 다루지 않는다. 그래서 **에러도 경고도 없이 조용히 사라진다.**
```sql
-- 잘못된 순서로 생성된 테이블의 실측 결과
SELECT COLUMN_NAME, COLUMN_COMMENT FROM information_schema.COLUMNS ...
g7_menus name [메뉴 이름 (다국어 JSON)]
g7_menus created_by [] ← 소스에는 comment 가 적혀 있다
```
이미 `migrate` 를 마친 설치본은 마이그레이션을 다시 실행하지 않으므로, 소스를 교정해도 기존 DB 의 comment 는 비어 있는 채로 남는다. 소스 교정과 **업그레이드 스텝 백필**을 함께 수행한다. 백필은 comment 가 비어 있을 때만 채워 운영자가 직접 넣은 값을 보존하고, 자료형·NULL 허용·기본값을 현재 스키마에서 읽어 그대로 재적용해 설명 외에는 아무 것도 바꾸지 않는다.
면제: `// audit:allow migration-fk-comment-order <사유>` 인라인 주석 (해당 구문에 부착)
### 마이그레이션 멱등성
```
+42
View File
@@ -263,8 +263,50 @@ export function initModule(): void {
// IIFE 빌드 시 즉시 실행
initModule();
// 코어 재초기화 시 재등록 진입점 노출 (아래 "코어 재초기화 시 핸들러 재등록" 참조)
(window as any).__SirsoftEcommerce = {
identifier: MODULE_IDENTIFIER,
initModule,
};
```
### 코어 재초기화 시 핸들러 재등록
로케일 전환처럼 `TemplateApp` 이 다시 초기화되는 시점에 ActionDispatcher 는 **새 인스턴스로 교체**된다.
이때 앞서 등록해 둔 확장 핸들러는 전부 사라지므로, 코어가 각 확장에 재등록을 요청한다.
요청 방식은 **window 전역 객체에서 약속된 이름의 함수를 찾아 호출**하는 것 하나뿐이다.
| 확장 타입 | 전역 객체 | 재등록 진입점 |
| --- | --- | --- |
| 모듈 | `window.__[ModuleName]` | `initModule()` |
| 플러그인 | `window.__[PluginName]` | `initPlugin()` |
| 템플릿 | `window.G7TemplateHandlers` | 코어가 직접 재등록 (확장 작업 불필요) |
```typescript
// 플러그인 엔트리 파일 (index.ts)
function initPlugin(): void {
registerHandlersWithRetry(); // 핸들러 재등록만 수행
}
initPlugin();
(window as any).__SirsoftDaumPostcode = {
identifier: PLUGIN_IDENTIFIER,
initPlugin,
};
```
이름은 고정이다. 전역 객체를 노출하지 않거나 진입점 이름이 다르면(`init`, `bootstrap`, `setup` 등)
코어는 그 확장을 재등록 대상에서 조용히 건너뛴다. 그 결과는 다음과 같다:
- 사용자가 언어를 한 번 바꾼 뒤부터 해당 확장의 모든 액션이 **무반응**이 된다
- 핸들러가 없으므로 dispatch 는 그대로 무시된다 — 콘솔 에러도, 토스트도, 네트워크 요청도 없다
- 새로고침하면 정상으로 돌아오므로 재현 조건을 모르면 원인 추적이 어렵다
진입점은 **핸들러 재등록만** 수행한다. 최초 진입 1회로 충분한 작업(리다이렉트 복귀 처리,
MutationObserver·인터셉터 설치, DOM 주입 등)은 넣지 않는다 — 재초기화마다 중복 실행된다.
### 핸들러 정의
```typescript
@@ -67,6 +67,24 @@
- 지정 시: 해당 ID 의 DOM 요소에만 spinner mount. 요소 미발견 시 `transition_overlay.fallback_target` → `#app` 순으로 3단계 폴백
- `replace: true` 아닌 일반 navigate(다른 path)는 `handleRouteChange` 경로로 가며 이 옵션은 효과 없음
`replace: true` 는 이 옵션의 **전제 조건**이다. 같은 `params` 에 함께 두지 않으면 엔진이 값을
읽지 않아 오버레이가 표시되지 않는다. 키를 잘못 적은 것도 아니고 값이 틀린 것도 아니라 경고·예외가
전혀 남지 않으므로, 화면을 직접 보지 않으면 무효 상태를 알아차릴 수 없다. 검색·필터 변경처럼
목록을 다시 그리는 이동에도 동일하게 적용된다.
```json
// ❌ 조용히 무시된다 — 오버레이가 뜨지 않는다
{ "handler": "navigate", "params": { "path": "/admin/users", "mergeQuery": true,
"transition_overlay_target": "users_data_grid__body" } }
// ✅ replace 를 함께 선언
{ "handler": "navigate", "params": { "path": "/admin/users", "mergeQuery": true, "replace": true,
"transition_overlay_target": "users_data_grid__body" } }
```
정적 검사가 이 조합을 강제한다. 의도적으로 오버레이를 끄려면 키 자체를 지우고, 예외가 필요하면
액션 노드 `comment` 에 `audit:allow layout-transition-overlay-target-requires-replace <사유>` 를 남긴다.
**DataGrid body 영역 한정 spinner 컨벤션**:
목록 페이지 페이지네이션 시 **pagination 영역은 제외하고 그리드 본문만** spinner 를 표시하려면 `transition_overlay_target` 에 DataGrid 의 `${id}__body` suffix 를 사용한다. DataGrid composite 컴포넌트는 root Div 에 `id` 를, 테이블/카드 목록을 감싸는 내부 wrapper Div 에 `${id}__body` 를 자동으로 부여한다 (pagination 은 body wrapper 밖의 형제).
+23
View File
@@ -68,6 +68,29 @@
| `desktop` | 1024px 이상 | 데스크톱 전용 |
| `portable` | 0 ~ 1023px | 모바일 + 태블릿 (비데스크톱) |
### 확장 코드에서 같은 경계를 판정할 때
모듈·플러그인이 화면 폭에 따라 다른 동작을 고르는 경우(팝업 대신 페이지 전환 등), 그 판정은
레이아웃이 `responsive` 로 띄우는 안내와 **같은 값을 같은 방법으로** 읽어야 한다.
```ts
// ✅ 엔진과 동일 — ResponsiveManager 가 window.innerWidth 로 breakpoint 를 정한다
const PORTABLE_MAX_WIDTH = 1023;
const isPortable = window.innerWidth <= PORTABLE_MAX_WIDTH;
// ❌ 경계에서 어긋난다
const isPortable = window.matchMedia('(max-width: 1023px)').matches;
```
`matchMedia` 는 CSS 픽셀 기준이라 `devicePixelRatio` 가 정수가 아닌 환경에서 `window.innerWidth`
와 1px 어긋난다. 실측(Chrome, dPR 1.0000000447)에서 `innerWidth === 1023` 인데
`matchMedia('(max-width: 1023px)')` 가 `false` 였다. 이 어긋남은 딱 경계 폭에서만 나타나므로
개발 중에는 드러나지 않고, 화면은 "페이지로 이동합니다" 라고 안내해 놓고 실제로는 팝업이 열리는
형태로 사용자에게만 보인다.
경계값 자체도 상수로 두고 SSoT(`ResponsiveManager` 의 `portable` 정의)를 주석에 밝힌다 —
프리셋 범위가 바뀌면 확장도 함께 고쳐야 한다는 신호가 코드에 남는다.
### portable 프리셋 사용 규칙
`portable` 프리셋은 `mobile`과 `tablet`을 합친 범위입니다. 다음 규칙을 준수하세요:
@@ -4,6 +4,16 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.2] - 2026-07-29
### Added
- 본인인증 결과 중계 페이지 제목의 일본어 번역 추가 — 인증 창에서 결과를 전달하는 중계 페이지의 제목이 일본어 로케일로 표시됩니다.
### Changed
- 설정 화면의 중복 가입 관련 항목 일본어 표기를 원문 용어 정비에 맞춰 갱신했습니다 (「同一人識別」 → 「重複登録ブロック」 계열).
## [1.0.1] - 2026-07-14
### Changed
@@ -10,7 +10,7 @@ return [
'test_mode' => 'テストモード',
'live_mid' => 'ライブMID(SRBプレフィックス)',
'live_api_key' => 'ライブAPIキー',
'duplicate_field' => '同一人識別基準',
'duplicate_field' => '重複チェック基準',
'duplicate_block_enabled' => [
'label' => '重複登録ブロック',
'description' => '有効化時に本人認証を通過した人が以前に別のメールアドレスで登録した場合、登録を拒否します。家族携帯電話共有またはB2Bシナリオなど、1人が複数のアカウントを登録する必要がある場合は無効化してください。この設定とは関係なく、同一メールアドレスの再登録は常にブロックされます(コア基本動作)。',
@@ -40,4 +40,5 @@ return [
'description' => '必ずKGイニシスプロバイダーのみにマッピングしてください。メール/SMSプロバイダーは生年月日を返さないため、成人かどうかの判定ができません。誤りマッピング時に未成年ユーザーに成人向けコンテンツが露出する可能性があります。',
],
],
'bridge_page_title' => '本人認証結果',
];
@@ -3,7 +3,7 @@
"description": "KGイニシス統合認証の本人確認サービスをG7コア本人認証インフラに接続するプラグインです。",
"settings": {
"title": "KGイニシス本人確認設定",
"description": "運用モードとライブ資格情報、同一人識別基準を設定します。",
"description": "運用モードとライブ資格情報、重複登録ブロック基準を設定します。",
"info": {
"activation": "本人認証の有効化は [環境設定 > 本人認証] から設定します。",
"logs": "イニシス認証履歴は [管理者 > 本人認証履歴] から「KGイニシス本人確認」でフィルターして閲覧できます。"
@@ -17,13 +17,13 @@
"live_mid_hint": "イニシスライブ加盟店MIDはSRBで始まります (例:SRB1234567)。運用モード使用前に必須入力です。",
"live_api_key": "ライブAPIキー",
"section": {
"duplicate_field": "同一人識別"
"duplicate_field": "重複登録ブロック"
},
"fields": {
"duplicate_field": {
"label": "重複チェック基準",
"hint": "DIは本加盟店でのみ同一人識別、CIはすべての機関で共通識別が可能です。(デフォルト:DI)",
"warning": "運用中に変更した場合、既存登録者の同一人チェックが一時的に不完全になる可能性があります。"
"hint": "DIは本加盟店でのみ重複登録を確認し、CIはすべての機関で共通して識別できます。(デフォルト:DI)",
"warning": "運用中に変更した場合、既存登録者の重複チェックが一時的に不完全になる可能性があります。"
},
"duplicate_block_enabled": {
"label": "重複登録ブロック",
@@ -34,10 +34,10 @@
}
},
"duplicate_field": {
"di": "DI (加盟店従属同一人識別値)",
"ci": "CI (全機関共通同一人識別値)",
"hint": "DIは本加盟店でのみ同一人識別、CIはすべての機関で共通識別が可能です。",
"warning": "運用中に変更した場合、既存登録者の同一人チェックが一時的に不完全になる可能性があります。"
"di": "DI (加盟店従属の固有値)",
"ci": "CI (全機関共通の固有値)",
"hint": "DIは本加盟店でのみ重複登録を確認し、CIはすべての機関で共通して識別できます。",
"warning": "運用中に変更した場合、既存登録者の重複チェックが一時的に不完全になる可能性があります。"
}
},
"modal": {
@@ -12,7 +12,7 @@
"en": "G7 plugin (sirsoft-verification_kginicis) Japanese language pack (bundled)",
"ja": "G7 プラグイン (sirsoft-verification_kginicis) 日本語 言語パック(バンドル)"
},
"version": "1.0.1",
"version": "1.0.2",
"license": "MIT",
"scope": "plugin",
"target_identifier": "sirsoft-verification_kginicis",
@@ -0,0 +1,17 @@
# Changelog
이 언어팩의 모든 주요 변경사항을 기록합니다.
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.0] - 2026-07-29
### Added
- NHN KCP 휴대폰 본인확인 플러그인(sirsoft-verification_nhnkcp)의 일본어 번들 언어팩 초기 제공
- 본인확인 안내 창 문구(시작 안내·진행 중 안내·모바일 페이지 이동 안내·팝업 차단 안내)의 일본어 번역
- 실패 안내 문구(취소·성인 인증 필요·중복 가입·인증기관 통신 실패·인증 정보 불완전·인증 세션 만료·저장 실패)의 일본어 번역
- 관리자 환경설정 문구(테스트 모드·운영 인증 정보·사이트 코드·암호화 키·웹사이트 ID·중복 가입 차단 기준)의 일본어 번역
- 마이페이지 본인확인 카드 문구(이름·생년월일·휴대폰·인증 일시·성인 여부, 생년월일 미제공·성인 여부 확인 불가 안내 포함)의 일본어 번역
- 레이아웃 편집기 상태 변종 라벨(본인확인 완료 / 생년월일 미제공 / 내역 없음)의 일본어 번역
- 본인확인 결과 중계 페이지 제목의 일본어 번역 — 인증 창에서 결과를 전달하는 중계 페이지의 제목이 일본어 로케일로 표시됩니다.
@@ -0,0 +1,14 @@
<?php
return [
'encrypt_failed' => '本人認証リクエストデータを準備できませんでした。しばらく後に再度お試しください。',
'decrypt_failed' => '本人認証情報の復号化に失敗しました。再度お試しください。',
'remote_call_failed' => 'NHN KCP認証サーバーとの通信に失敗しました。しばらく後に再度お試しください。',
'not_found' => '本人認証情報が見つかりません。認証を再度実行してください。',
'already_consumed' => '既に処理済みの本人認証です。',
'duplicate_register' => '本人認証で登録済みのアカウントが既に存在します。ログインまたはパスワード再設定をご利用ください。',
'binding_mismatch' => '本人認証情報が対象アカウントと一致していません。',
'not_adult' => '成人認証が必要なサービスです。満19歳以上のみご利用いただけます。',
'incomplete_identity' => '本人認証情報が完全ではありません。別の認証方法で再度お試しください。',
'storage_failed' => '本人認証情報の保存に失敗しました。しばらく後に再度お試しください。',
];
@@ -0,0 +1,45 @@
<?php
return [
'title' => 'NHN KCP 携帯電話本人確認',
'description' => 'NHN KCP の携帯電話本人確認サービスを G7 コア本人認証インフラストラクチャに接続するプラグインです。',
'channels' => [
'phone' => '携帯電話',
],
'settings' => [
'test_mode' => 'テストモード',
'test_site_cd' => 'テスト サイトコード',
'test_enc_key' => 'テスト 暗号化キー',
'live_site_cd' => '運営 サイトコード (SM プレフィックス)',
'live_enc_key' => '運営 暗号化キー',
'web_siteid' => 'ウェブサイト ID',
'live_site_cd_attribute' => '運営 サイトコード',
'live_enc_key_attribute' => '運営 暗号化キー',
'duplicate_field' => '重複チェック基準',
'duplicate_block_enabled' => [
'label' => '重複登録ブロック',
'description' => '有効化した場合、本人確認を完了したユーザーが以前に異なるメールアドレスで登録した履歴がある場合は登録を拒否します。ファミリー携帯電話の共有または B2B シナリオなど、1 人が複数のアカウントを登録する必要がある場合は無効化してください。この設定に関わらず、同一メールアドレスの再登録は常にブロックされます (コア デフォルト動作)。',
],
],
'card' => [
'title' => '本人認証情報',
'method' => '認証方式',
'method_value' => 'NHN KCP 携帯電話本人確認',
'verified_at' => '認証日時',
'name' => '実名',
'birthday' => '生年月日',
'phone' => '携帯電話',
'is_adult' => [
'label' => '成人否定状況',
'true' => '成人',
'false' => '未成年者',
],
],
'purposes' => [
'adult_verification' => [
'label' => '成人認証 (NHN KCP 本人確認専用)',
'description' => '必ず NHN KCP プロバイダーのみにマッピングしてください。メール/SMS プロバイダーは生年月日を返さないため成人判定ができません。誤ったマッピングの場合、成人向けコンテンツが未成年ユーザーに公開される可能性があります。',
],
],
'bridge_page_title' => '本人確認結果',
];
@@ -0,0 +1,111 @@
{
"editor": {
"data_source": {
"nhnkcpRecord": "本人認証記録",
"settings": "本人認証設定"
},
"state": {
"test_mode_on": "テストモード オン",
"test_mode_off": "ライブモード",
"identity_challenge_nhnkcp": "本人認証ガイダンス画面 (NHN KCP)",
"record_verified": "本人認証完了 (成人)",
"record_birthday_missing": "本人認証完了 (生年月日未提供)",
"record_none": "本人認証履歴なし"
}
},
"title": "NHN KCP 携帯電話本人認証",
"description": "NHN KCP の携帯電話本人認証サービスを G7 コア本人認証インフラストラクチャに接続するプラグインです。",
"settings": {
"title": "NHN KCP 携帯電話本人認証設定",
"description": "ライブモードと運用認証情報、重複登録ブロック基準を設定します。",
"info": {
"activation": "本人認証の有効化は [環境設定 > 本人認証] で設定します。",
"logs": "認証履歴は [管理者 > 本人認証履歴] で「NHN KCP 携帯電話本人認証」でフィルタリングして閲覧できます。"
},
"test_mode": "テストモード",
"test_mode_hint": "テストモードでは KCP テスト環境に接続され、実際の認証手数料は請求されません。",
"live_mode_warning_title": "ライブモード有効化のお知らせ",
"live_mode_warning_body": "ライブモードは実際の加盟店サイトコードで本人認証を実行します。運用サイトコードと暗号化キーを入力してから運用してください。",
"section_live_keys": "運用認証情報 (テストモード OFF 時は必須)",
"live_site_cd": "サイトコード",
"live_site_cd_hint": "KCP が発行したサイトコードの前の SM を除いた値のみ入力してください。SM は自動的に付加されます。",
"live_enc_key": "暗号化キー",
"live_enc_key_hint": "保存時に暗号化して保管されます。",
"web_siteid": "ウェブサイト ID",
"web_siteid_hint": "KCP 加盟店管理画面で発行されたサイト識別コードを入力してください。重複登録確認情報 (DI) の受信に使用されます。",
"section": {
"duplicate_field": "重複登録ブロック"
},
"fields": {
"duplicate_field": {
"label": "重複チェック基準",
"hint": "DI はこのサイトでのみ重複登録を確認し、CI はすべての機関で共通に識別できます。(デフォルト: DI)"
},
"duplicate_block_enabled": {
"label": "重複登録ブロック",
"hint": "有効化時、本人認証を通過した人が以前に別のメールアドレスで登録したことがあれば登録を拒否します。家族の携帯電話共有または B2B シナリオで 1 人が複数のアカウント登録が必要な場合は無効化してください。(デフォルト: 有効化)",
"note": "この設定に関わらず、同一メールアドレスの再登録は常にブロックされます (コア デフォルト動作)。"
}
},
"duplicate_field": {
"di": "DI — サイト別固有値 (推奨)",
"ci": "CI — サービス共通固有値",
"warning": "運用中に変更すると、既存登録者の重複チェックが一時的に不完全になる可能性があります。"
}
},
"modal": {
"title": "携帯電話本人認証",
"description": "KCP 認証画面で通信事業者認証を進めてください。",
"mobile_description": "認証画面に移動します。認証完了後、自動的にこの画面に戻ります。",
"adult_title": "成人認証",
"adult_description": "満19歳以上の成人否か確認するため、携帯電話本人認証を進めます。認証後、自動的に前の画面に戻ります。",
"start_button": "本人認証開始",
"test_mode_notice": "テストモードです。実際の認証は発生しません。",
"in_progress": {
"title": "認証画面で本人認証を進めてください",
"hint": "認証画面が表示されない場合は、ブラウザのポップアップブロックを解除してください。ウィンドウを閉じると再試行できます。"
},
"popup_blocked": "ポップアップがブロックされました。ブラウザのポップアップ許可設定後、再試行してください。",
"payload_not_ready": "本人認証の準備が完了していません。しばらく経ってから再試行してください。",
"mobile_return_success": "本人認証が完了しました。続けて進めてください。"
},
"card": {
"title": "本人認証情報",
"method": "認証方式",
"verified_at": "認証日時",
"name": "名前",
"birthday": "生年月日",
"phone": "携帯電話",
"not_provided": "提供されていません",
"is_adult": {
"label": "成人否か",
"true": "確認済み",
"false": "未確認 (満19歳未満)",
"unknown": "確認不可 (生年月日未提供)"
},
"pending": {
"title": "まだ本人認証履歴がありません。",
"hint": "本人認証が必要な作業 (パスワード再設定、情報変更など) を進める際に認証できます。"
},
"provider_badge": "NHN KCP"
},
"errors": {
"cancelled": "本人認証をキャンセルしました。",
"not_adult": "成人認証が必要なサービスです。満19歳以上のみご利用できます。",
"duplicate": "すでに同一の名義で登録されたアカウントがあります。ログインまたはパスワード検索を使用してください。",
"remote_call_failed": "認証機関との通信に失敗しました。しばらく経ってから再試行してください。",
"decrypt_failed": "正常な認証ではありません。再試行してください。",
"incomplete_identity": "正常な認証ではありません。再試行してください。",
"not_found": "認証セッションが期限切れになりました。最初から再試行してください。",
"already_consumed": "認証セッションが期限切れになりました。最初から再試行してください。",
"binding_mismatch": "本人認証情報が対象アカウントと一致しません。",
"storage_failed": "本人認証情報の保存に失敗しました。しばらく経ってから再試行してください。",
"verify_failed": "本人認証に失敗しました。再試行してください。"
},
"purposes": {
"adult_verification": {
"label": "成人認証 (NHN KCP 本人認証専用)",
"description": "必ず NHN KCP プロバイダーにのみマッピングしてください。メール/SMS プロバイダーは生年月日を返さないため、成人否か判定ができません。"
}
}
}
@@ -0,0 +1,30 @@
{
"identifier": "g7-plugin-sirsoft-verification_nhnkcp-ja",
"namespace": "g7",
"vendor": "sirsoft",
"name": {
"ko": "G7 플러그인 (sirsoft-verification_nhnkcp) 일본어 언어팩",
"en": "G7 plugin (sirsoft-verification_nhnkcp) Japanese language pack",
"ja": "G7 プラグイン (sirsoft-verification_nhnkcp) 日本語 言語パック"
},
"description": {
"ko": "G7 플러그인 (sirsoft-verification_nhnkcp) 일본어 언어팩 (번들)",
"en": "G7 plugin (sirsoft-verification_nhnkcp) Japanese language pack (bundled)",
"ja": "G7 プラグイン (sirsoft-verification_nhnkcp) 日本語 言語パック(バンドル)"
},
"version": "1.0.0",
"license": "MIT",
"scope": "plugin",
"target_identifier": "sirsoft-verification_nhnkcp",
"locale": "ja",
"locale_name": "Japanese",
"locale_native_name": "日本語",
"text_direction": "ltr",
"g7_version": ">=7.0.0-beta.4",
"requires": {
"target_version": null,
"depends_on_core_locale": true
},
"github_url": "",
"github_changelog_url": ""
}
@@ -0,0 +1,4 @@
{
"name": "NHN KCP 携帯電話本人確認",
"description": "NHN KCP 携帯電話本人確認(V2 REST)をG7コアIDVインフラにProviderとして登録するプラグイン"
}
@@ -29,6 +29,7 @@
- 설정 화면의 선택 항목(라디오 버튼)을 키보드 방향키로 고를 때 선택이 저장되지 않던 문제를 수정했습니다. 마우스 클릭은 정상 동작했으나, 키보드만 사용하는 경우 화면 표시와 실제 저장 값이 어긋날 수 있었습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
- 글을 수정하면서 미리 올려 둔 파일을 첨부하면 저장은 되는데 첨부만 연결되지 않던 문제를 수정했습니다. 관리자 화면에서는 정상 연결되어 같은 요청이 화면에 따라 다르게 동작했습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
- 개별 게시판을 수정할 때 목록 표시 개수·제목/내용 길이·댓글 깊이·첨부 개수 등 숫자 항목의 허용 범위가 화면에 표시되지 않던 문제를 수정했습니다. 게시판 환경설정(기본값) 화면에는 범위가 표시되는데 개별 게시판 화면에는 없어, 범위를 벗어난 값을 넣어도 저장을 눌러 봐야 알 수 있었습니다. 이제 두 화면이 같은 범위를 안내합니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
- 데이터베이스에서 일부 컬럼의 설명이 비어 있던 문제를 바로잡았습니다. 회원·작성자 등 다른 표를 가리키는 컬럼이 대상이며, 업그레이드하면 이미 운영 중인 데이터베이스에도 설명이 채워집니다(직접 적어 넣은 설명은 그대로 둡니다).
- 관리자 게시글 목록에서 글을 열어 이전글·다음글로 넘어가거나 수정 후 돌아올 때 보고 있던 페이지·검색어·분류가 사라지던 문제를 수정했습니다. 목록 ↔ 상세 ↔ 이전/다음 글 ↔ 작성·수정 폼 어디로 오가든 목록 상태가 유지되며, 신고 관리와 게시판 목록도 동일하게 개선됐습니다. 새로고침 버튼도 걸어 둔 검색·필터를 지운 채 새로 부르지 않고 보고 있던 목록을 그대로 다시 부릅니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.)
- 관리자 게시글 목록에서 뒤쪽 페이지를 보던 중 검색하거나 게시 상태를 바꾸면 그 페이지 번호가 그대로 유지돼 결과가 없는 빈 화면이 열리던 문제를 수정했습니다. 검색과 상태 변경은 1페이지부터 보여주며, 사용자가 고른 표시 개수와 정렬은 그대로 유지됩니다. 검색창에서 엔터를 치는 경우와 검색 버튼을 누르는 경우가 서로 다르게 동작하던 것도 함께 맞췄습니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.)
- 첨부파일을 사용하지 않는 게시판을 아무것도 바꾸지 않고 저장해도 "허용 파일 확장자를 최소 1개 이상 입력하세요" 오류가 나며 저장이 막히던 문제를 수정했습니다. 첨부를 쓰지 않는 게시판에서는 허용 확장자를 비워 두어도 정상적으로 저장됩니다. 게시판 환경설정의 기본값도 동일하게 동작합니다. (#78 @jiwonpapa 님께서 제보해주셨습니다.)
@@ -49,6 +50,9 @@
- 대댓글 깊이가 게시판 설정값을 넘어 계속 달리던 문제를 수정했습니다. 예전에는 6단계부터 들여쓰기가 더 들어가지 않고 설정한 최대 깊이도 지켜지지 않았습니다. 이제 설정한 깊이까지만 답글을 달 수 있고 화면의 단계 표시도 정확해집니다. (#80 @jiwonpapa 님께서 제보해주셨습니다.)
- 기존에 쌓인 댓글 중 깊이가 어긋났거나 다른 게시글의 댓글을 부모로 가진 것을 업데이트 시 자동으로 정리합니다. 잘못 연결된 댓글은 삭제하지 않고 최상위 댓글로 옮기며, 내용은 그대로 보존됩니다.
- 연동 기능이 블라인드·삭제된 글에 댓글을 등록하려 할 때 "댓글 등록에 실패했습니다" 라는 서버 오류만 표시되던 문제를 수정했습니다. 이제 글쓰기 화면에서와 똑같이 왜 등록할 수 없는지 사유가 표시됩니다.
- 관리자 목록에서 검색·필터·정렬을 바꿀 때 표가 다시 그려지는 동안 로딩 표시가 나오지 않던 문제를 수정했습니다. 응답이 늦으면 아무 반응이 없는 것처럼 보여 같은 조작을 반복하게 되던 상황이 사라집니다.
- 관리자 화면 일부 아이콘이 의도한 크기보다 크거나 작게 보이던 문제를 수정했습니다.
- 관리자 화면 일부 버튼·배지가 다크 모드에서 밝은 색 그대로 표시되던 문제를 수정했습니다.
## [1.0.2] - 2026-07-14
@@ -15,7 +15,7 @@ return new class extends Migration
// 신고 로그
Schema::create('boards_report_logs', function (Blueprint $table) {
$table->id()->comment('신고 로그 ID');
$table->foreignId('report_id')->constrained('boards_reports')->cascadeOnDelete()->comment('케이스 ID (boards_reports.id)');
$table->foreignId('report_id')->comment('케이스 ID (boards_reports.id)')->constrained('boards_reports')->cascadeOnDelete();
$table->unsignedBigInteger('reporter_id')->nullable()->comment('신고자 ID (탈퇴 시 NULL)');
$table->mediumText('snapshot')->nullable()->comment('신고 당시 게시물 스냅샷 (JSON: board_name, title, content, content_mode, author_name)');
$table->string('reason_type', 50)->nullable()->comment('신고 사유 유형 (abuse, hate_speech, spam, copyright, privacy, misinformation, sexual, violence, other)');
@@ -21,7 +21,9 @@ return new class extends Migration
}
if (! Schema::hasTable('notification_templates')) {
throw new \RuntimeException(
// audit:allow i18n-throw-hardcoded-korean reason: 마이그레이션 선행조건 가드 —
// 콘솔에서 개발자·운영자가 보는 진단 메시지이며 사용자 화면에 닿지 않는다.
throw new RuntimeException(
'board_mail_templates 제거 전 notification_templates 가 존재해야 합니다. '
.'코어 Upgrade_7_0_0_beta_2 + 게시판 Upgrade_1_0_0_beta_2 업그레이드 스텝을 먼저 실행하세요.'
);
@@ -52,7 +54,7 @@ return new class extends Migration
$table->boolean('is_active')->default(true)->comment('활성 여부');
$table->boolean('is_default')->default(true)->comment('시더 생성 여부');
$table->text('user_overrides')->nullable()->comment('유저가 수정한 필드명 목록');
$table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete()->comment('수정자');
$table->foreignId('updated_by')->nullable()->comment('수정자')->constrained('users')->nullOnDelete();
$table->timestamps();
});
}
@@ -164,7 +164,7 @@
"name": "Icon",
"props": {
"name": "refresh",
"className": "w-5 h-5"
"className": "text-xl"
}
}
]
@@ -194,7 +194,7 @@
"name": "Icon",
"props": {
"name": "plus",
"className": "w-4 h-4"
"className": "text-base"
}
},
{
@@ -320,7 +320,7 @@
"name": "Icon",
"props": {
"name": "{{_global.showAdvancedSearch ? 'chevron-up' : 'chevron-down'}}",
"className": "w-4 h-4 text-white dark:text-gray-300"
"className": "text-base text-white dark:text-gray-300"
}
}
]
@@ -373,6 +373,7 @@
"category": "{{$event.target.value === 'all' ? '' : $event.target.value}}",
"page": 1
},
"replace": true,
"transition_overlay_target": "posts_data_grid__body"
}
}
@@ -672,6 +673,7 @@
"sort_order": "{{$event.target.value.split('_').pop()}}",
"page": 1
},
"replace": true,
"transition_overlay_target": "posts_data_grid__body"
}
}
@@ -714,6 +716,7 @@
"per_page": "{{$event.target.value}}",
"page": 1
},
"replace": true,
"transition_overlay_target": "posts_data_grid__body"
}
}
@@ -785,7 +788,7 @@
"name": "Icon",
"props": {
"name": "level-up-alt",
"className": "w-3.5 h-3.5 fa-rotate-90 mt-1 text-gray-400 dark:text-gray-500"
"className": "text-sm fa-rotate-90 mt-1 text-gray-400 dark:text-gray-500"
},
"if": "{{row.is_reply}}"
},
@@ -819,7 +822,7 @@
"name": "Icon",
"props": {
"name": "lock",
"className": "w-3.5 h-3.5 text-gray-500 dark:text-gray-400"
"className": "text-sm text-gray-500 dark:text-gray-400"
},
"if": "{{row.is_secret}}"
},
@@ -859,7 +862,7 @@
"name": "Icon",
"props": {
"name": "paperclip",
"className": "w-3.5 h-3.5 text-gray-400 dark:text-gray-500"
"className": "text-sm text-gray-400 dark:text-gray-500"
},
"if": "{{row.has_attachment}}"
},
@@ -879,7 +882,7 @@
"name": "Span",
"if": "{{row?.is_new}}",
"props": {
"className": "inline-flex items-center px-2 py-0.5 rounded text-xs font-bold bg-red-500 text-white"
"className": "inline-flex items-center px-2 py-0.5 rounded text-xs font-bold bg-red-500 dark:bg-red-600 text-white"
},
"text": "N"
},
@@ -1019,6 +1022,7 @@
"query": {
"page": "{{$args[0]}}"
},
"replace": true,
"transition_overlay_target": "posts_data_grid__body"
}
}
@@ -1101,7 +1105,7 @@
"type": "basic",
"name": "Button",
"props": {
"className": "px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed",
"className": "px-4 py-2 bg-red-600 dark:bg-red-700 text-white rounded-lg hover:bg-red-700 dark:hover:bg-red-800 disabled:opacity-50 disabled:cursor-not-allowed",
"disabled": "{{_global.deleteModalLoading}}"
},
"actions": [
@@ -136,7 +136,7 @@
"name": "Icon",
"props": {
"name": "arrows-rotate",
"className": "w-5 h-5"
"className": "text-xl"
}
}
]
@@ -324,7 +324,7 @@
"name": "Icon",
"props": {
"name": "{{_global.showAdvancedSearch ? 'chevron-up' : 'chevron-down'}}",
"className": "w-4 h-4 text-white dark:text-gray-300"
"className": "text-base text-white dark:text-gray-300"
}
}
]
@@ -520,7 +520,7 @@
"name": "Icon",
"props": {
"name": "{{_global.showAdvancedSearch ? 'chevron-up' : 'chevron-down'}}",
"className": "w-4 h-4 text-white dark:text-gray-300"
"className": "text-base text-white dark:text-gray-300"
}
}
]
@@ -2192,6 +2192,7 @@
"query": {
"page": "{{$args[0]}}"
},
"replace": true,
"transition_overlay_target": "reports_data_grid__body"
}
}
@@ -2302,7 +2303,7 @@
"name": "Icon",
"props": {
"name": "exclamation-triangle",
"className": "w-4 h-4 text-red-600 dark:text-red-400 flex-shrink-0"
"className": "text-base text-red-600 dark:text-red-400 flex-shrink-0"
}
},
{
@@ -2328,7 +2329,7 @@
"name": "Icon",
"props": {
"name": "exclamation-triangle",
"className": "w-4 h-4 text-yellow-600 dark:text-yellow-400 flex-shrink-0"
"className": "text-base text-yellow-600 dark:text-yellow-400 flex-shrink-0"
}
},
{
@@ -2354,7 +2355,7 @@
"name": "Icon",
"props": {
"name": "eye",
"className": "w-4 h-4 text-orange-600 dark:text-orange-400 flex-shrink-0"
"className": "text-base text-orange-600 dark:text-orange-400 flex-shrink-0"
}
},
{
@@ -2380,7 +2381,7 @@
"name": "Icon",
"props": {
"name": "spinner",
"className": "w-5 h-5 text-blue-600 dark:text-blue-400 animate-spin mr-2"
"className": "text-xl text-blue-600 dark:text-blue-400 animate-spin mr-2"
}
},
{
@@ -0,0 +1,175 @@
<?php
namespace App\Upgrades\Data\Ext\Modules\SirsoftBoard\V1_0_3\Migrations;
use App\Extension\Upgrade\DataMigration;
use App\Extension\UpgradeContext;
use Illuminate\Support\Facades\DB;
/**
* 외래키 컬럼의 누락된 한국어 comment 를 채웁니다.
*
* 배경:
* `$table->foreignId('user_id')->constrained('users')->comment('사용자 ID')` 형태는 comment 가
* 컬럼이 아니라 외래키 정의에 부착되어 DB 에 생성되지 않습니다. `constrained()` 가 컬럼 정의가
* 아닌 외래키 정의를 돌려주기 때문입니다. 마이그레이션 소스는 교정했지만, 이미 설치를 마친
* 사이트는 마이그레이션을 다시 실행하지 않으므로 설명이 비어 있는 채로 남습니다.
*
* 멱등: 설명이 이미 있는 컬럼은 건드리지 않습니다. 재실행해도 결과가 같습니다.
*
* 안전:
* - 설명이 **비어 있을 때만** 채웁니다 — 운영자가 직접 넣어 둔 설명을 덮어쓰지 않습니다.
* - 자료형·NULL 허용·기본값·자동증가를 현재 스키마에서 읽어 그대로 재적용하므로 설명 외에는
* 아무 것도 바뀌지 않습니다.
* - MySQL 계열에서만 동작합니다(다른 DB 는 건너뜁니다).
*
* 실패 정책: 컬럼 단위로 실패를 흡수합니다 — 한 컬럼이 실패해도 나머지를 계속 처리합니다.
*
* V-1 안전: `Illuminate\Support\Facades\DB` 와 로컬 private 헬퍼만 사용하고, 대상 목록을
* 본 클래스에 동결 상수로 둡니다(마이그레이션 파일·모델 미참조).
*/
class BackfillForeignKeyColumnComments implements DataMigration
{
/**
* 보정 대상 (1.0.3 시점 동결) — 테이블 => [컬럼 => 설명].
*/
private const TARGETS = [
'boards_report_logs' => [
'report_id' => '케이스 ID (boards_reports.id)',
],
];
/**
* 마이그레이션 식별자 (로그용).
*
* @return string 식별자
*/
public function name(): string
{
return 'BackfillForeignKeyColumnComments';
}
/**
* 비어 있는 외래키 컬럼 설명을 채웁니다.
*
* @param UpgradeContext $context 업그레이드 컨텍스트
*/
public function run(UpgradeContext $context): void
{
if (! in_array(DB::getDriverName(), ['mysql', 'mariadb'], true)) {
$context->logger->info('[1.0.3] 컬럼 설명 보정 — MySQL 계열이 아니어서 건너뜁니다');
return;
}
$filled = 0;
$skipped = 0;
$failed = 0;
foreach (self::TARGETS as $table => $columns) {
$prefixed = $context->table($table);
foreach ($columns as $column => $comment) {
try {
$meta = $this->columnMeta($prefixed, $column);
if ($meta === null || trim((string) $meta->COLUMN_COMMENT) !== '') {
$skipped++;
continue;
}
DB::statement($this->buildModifyStatement($prefixed, $column, $meta, $comment));
$filled++;
} catch (\Throwable $e) {
$failed++;
$context->logger->warning(sprintf(
'[1.0.3] 컬럼 설명 보정 실패 (계속 진행): %s.%s — %s',
$prefixed,
$column,
$e->getMessage(),
));
}
}
}
$context->logger->info(sprintf(
'[1.0.3] 외래키 컬럼 설명 보정 — 채움 %d건 / 대상 아님 %d건 / 실패 %d건',
$filled,
$skipped,
$failed,
));
}
/**
* 컬럼의 현재 스키마 메타데이터를 조회합니다.
*
* @param string $table 프리픽스가 적용된 테이블명
* @param string $column 컬럼명
* @return object|null 컬럼 메타 (테이블/컬럼 부재 시 null)
*/
private function columnMeta(string $table, string $column): ?object
{
$rows = DB::select(
'SELECT COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT, EXTRA, COLUMN_COMMENT
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?',
[$table, $column],
);
return $rows[0] ?? null;
}
/**
* 설명만 덧붙이는 MODIFY COLUMN 문을 조립합니다.
*
* @param string $table 프리픽스가 적용된 테이블명
* @param string $column 컬럼명
* @param object $meta columnMeta() 결과
* @param string $comment 넣을 설명
* @return string 실행할 SQL
*/
private function buildModifyStatement(string $table, string $column, object $meta, string $comment): string
{
$sql = sprintf(
'ALTER TABLE %s MODIFY COLUMN %s %s',
$this->quoteIdentifier($table),
$this->quoteIdentifier($column),
$meta->COLUMN_TYPE,
);
$sql .= $meta->IS_NULLABLE === 'YES' ? ' NULL' : ' NOT NULL';
if ($meta->COLUMN_DEFAULT !== null) {
$sql .= ' DEFAULT '.$this->quoteValue((string) $meta->COLUMN_DEFAULT);
}
if (stripos(trim((string) $meta->EXTRA), 'auto_increment') !== false) {
$sql .= ' AUTO_INCREMENT';
}
return $sql.' COMMENT '.$this->quoteValue($comment);
}
/**
* 식별자를 백틱으로 감쌉니다.
*
* @param string $identifier 테이블/컬럼명
* @return string 이스케이프된 식별자
*/
private function quoteIdentifier(string $identifier): string
{
return '`'.str_replace('`', '``', $identifier).'`';
}
/**
* 문자열 값을 SQL 리터럴로 인용합니다.
*
* @param string $value 값
* @return string 인용된 리터럴
*/
private function quoteValue(string $value): string
{
return DB::connection()->getPdo()->quote($value);
}
}
@@ -39,6 +39,7 @@
- 부분취소 후에도 주문의 부가세가 취소 전 금액으로 남아 과세표준과 어긋나던 문제를 수정했습니다. 취소로 과세표준이 줄면 부가세도 함께 다시 계산되며, 계산에는 주문 당시의 세율이 사용됩니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
- 주문의 부가세가 항상 0으로 표시되던 문제를 수정했습니다. 부가세는 부분취소를 거친 주문에서만 채워져 같은 목록에서도 주문마다 세금 표기가 달랐습니다. 이제 주문을 만들 때 계산해 기록하며, 상품별 세율이 다르면 상품별로 계산해 합산합니다. 기존 주문의 부가세는 업데이트 시 자동으로 채워지며, 채운 주문 건수가 업그레이드 로그에 남습니다(이미 값이 있는 주문은 건드리지 않습니다). (#81 @jiwonpapa 님께서 제보해주셨습니다.)
- 주문서의 마일리지 '사용 가능 최대' 안내가 실제 결제 기준 금액과 최소 사용액을 반영하도록 수정했습니다. 이전에는 할인·배송비 반영 전 상품합계를 기준으로 계산해 실제로 쓸 수 있는 금액보다 크게 안내되었고, 최소 사용액에 못 미치는 경우에도 사용 가능한 것처럼 보였습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
- 데이터베이스에서 일부 컬럼의 설명이 비어 있던 문제를 바로잡았습니다. 회원·작성자 등 다른 표를 가리키는 컬럼이 대상이며, 업그레이드하면 이미 운영 중인 데이터베이스에도 설명이 채워집니다(직접 적어 넣은 설명은 그대로 둡니다).
- 휴대폰으로 관리자 주문 목록을 볼 때 왼쪽 위 '뒤로' 버튼을 누르면 이전 화면이 아니라 빈 페이지로 넘어가던 문제를 수정했습니다.
- 관리자 주문·상품·쿠폰·배송정책·리뷰·마일리지 목록에서 상세나 등록/수정 화면에 다녀오면 걸어 둔 필터와 페이지가 사라지던 문제를 수정했습니다. 필터를 여러 개 건 상태에서 주문 하나를 확인하고 돌아와도 조건이 그대로 유지됩니다. 정렬·표시 개수를 바꾸거나 새로고침해도 걸어 둔 조건은 그대로입니다. 반대로 초기화 버튼과 프리셋 적용은 종전처럼 조건을 새로 세팅합니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.)
- 관리자 브랜드·상품 공통정보·상품 고시정보 목록에서 검색어(브랜드는 정렬 포함)를 걸어 둔 채 새로고침 버튼을 누르면 걸어 둔 검색이 풀려 전체 목록으로 돌아가던 문제를 수정했습니다. 이제 새로고침은 보던 조건 그대로 다시 불러옵니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.)
@@ -63,6 +64,9 @@
- 카테고리를 자기 자신이나 자기 하위 카테고리 아래로 옮길 때 화면이 응답하지 않고 해당 카테고리가 목록에서 사라지던 문제를 수정했습니다. 카테고리 수정과 순서 변경 양쪽 모두에 적용됩니다.
- 카테고리를 최상위로 옮길 때 상위 연결은 풀렸지만 계층 정보(경로·단계)가 이전 값 그대로 남아, 하위 카테고리의 위치가 어긋나 보이던 문제를 수정했습니다.
- 기존에 잘못 연결된 카테고리가 있으면 업데이트 시 자동으로 정리하고 계층 정보를 다시 계산합니다. 카테고리는 삭제하지 않고 최상위로 옮깁니다.
- 관리자 목록에서 검색·필터·정렬을 바꿀 때 표가 다시 그려지는 동안 로딩 표시가 나오지 않던 문제를 수정했습니다. 응답이 늦으면 아무 반응이 없는 것처럼 보여 같은 조작을 반복하게 되던 상황이 사라집니다.
- 관리자 화면 일부 아이콘이 의도한 크기보다 크거나 작게 보이던 문제를 수정했습니다.
- 관리자 화면 일부 버튼·배지가 다크 모드에서 밝은 색 그대로 표시되던 문제를 수정했습니다.
## [1.0.4] - 2026-07-16
@@ -33,8 +33,8 @@ return new class extends Migration
$table->text('reply_content')->nullable()->comment('판매자 답변 내용');
$table->string('reply_content_mode', 10)->default('text')->comment('답변 콘텐츠 모드: text / html');
$table->foreignId('reply_admin_id')->nullable()
->constrained('users')->nullOnDelete()
->comment('답변 등록 관리자 ID');
->comment('답변 등록 관리자 ID')
->constrained('users')->nullOnDelete();
$table->timestamp('replied_at')->nullable()->comment('답변 등록일시');
$table->timestamp('reply_updated_at')->nullable()->comment('답변 수정일시');
@@ -43,8 +43,8 @@ return new class extends Migration
// 감사 필드
$table->foreignId('created_by')->nullable()
->constrained('users')->nullOnDelete()
->comment('업로더 ID');
->comment('업로더 ID')
->constrained('users')->nullOnDelete();
$table->timestamps();
$table->softDeletes()->comment('소프트 삭제 일시');
@@ -21,7 +21,9 @@ return new class extends Migration
}
if (! Schema::hasTable('notification_templates')) {
throw new \RuntimeException(
// audit:allow i18n-throw-hardcoded-korean reason: 마이그레이션 선행조건 가드 —
// 콘솔에서 개발자·운영자가 보는 진단 메시지이며 사용자 화면에 닿지 않는다.
throw new RuntimeException(
'ecommerce_mail_templates 제거 전 notification_templates 가 존재해야 합니다. '
.'코어 Upgrade_7_0_0_beta_2 + 이커머스 Upgrade_1_0_0_beta_2 업그레이드 스텝을 먼저 실행하세요.'
);
@@ -52,7 +54,7 @@ return new class extends Migration
$table->boolean('is_active')->default(true)->comment('활성 여부');
$table->boolean('is_default')->default(true)->comment('시더 생성 여부');
$table->text('user_overrides')->nullable()->comment('유저가 수정한 필드명 목록');
$table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete()->comment('수정자');
$table->foreignId('updated_by')->nullable()->comment('수정자')->constrained('users')->nullOnDelete();
$table->timestamps();
});
}
@@ -13,13 +13,13 @@ return new class extends Migration
{
Schema::create('ecommerce_mileage_transactions', function (Blueprint $table) {
$table->id()->comment('거래 ID');
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete()->comment('회원 ID');
$table->foreignId('user_id')->comment('회원 ID')->constrained('users')->cascadeOnDelete();
$table->string('currency', 10)->default('KRW')->comment('통화 코드 (주문 기준통화 스냅샷)');
$table->string('type', 30)->comment('거래 유형 (MileageTransactionTypeEnum)');
$table->decimal('amount', 12, 2)->comment('거래 금액 (양수=적립, 음수=차감)');
$table->decimal('remaining_amount', 12, 2)->default(0)->comment('잔여 금액 (적립건만 양수, FIFO 차감용)');
$table->decimal('balance_after', 12, 2)->comment('거래 후 잔액 (감사 스냅샷, 베스트에포트)');
$table->foreignId('order_id')->nullable()->constrained('ecommerce_orders')->nullOnDelete()->comment('관련 주문 ID');
$table->foreignId('order_id')->nullable()->comment('관련 주문 ID')->constrained('ecommerce_orders')->nullOnDelete();
// 옵션 병합 시 하드 삭제(SoftDeletes 미사용)되므로 FK 미설정 — unsignedBigInteger + 인덱스만 유지
$table->unsignedBigInteger('order_option_id')->nullable()->comment('관련 주문옵션 ID (병합 하드삭제 대응 — FK 미설정)');
$table->unsignedBigInteger('order_cancel_id')->nullable()->comment('관련 주문취소 ID (복원 멱등 기준)');
@@ -15,7 +15,7 @@ return new class extends Migration
// 진실의 원천(SSoT)은 ecommerce_mileage_transactions(원장)이며, 본 테이블은 표시 전용 캐시.
Schema::create('ecommerce_mileage_balances', function (Blueprint $table) {
$table->id()->comment('잔액 캐시 ID');
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete()->comment('회원 ID');
$table->foreignId('user_id')->comment('회원 ID')->constrained('users')->cascadeOnDelete();
$table->string('currency', 10)->default('KRW')->comment('통화 코드 (통화별 행)');
$table->decimal('available', 12, 2)->default(0)->comment('사용 가능 잔액 (활성 lot SUM 스냅샷)');
$table->decimal('pending', 12, 2)->default(0)->comment('적립 예정 (미취소·earn ledger 부재 옵션 적립액 합)');
@@ -243,7 +243,7 @@
"name": "Button",
"props": {
"type": "button",
"className": "px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed",
"className": "px-4 py-2 bg-blue-600 dark:bg-blue-700 text-white rounded-lg hover:bg-blue-700 dark:hover:bg-blue-800 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed",
"disabled": "{{!_local.selectedProducts || _local.selectedProducts.length === 0 || order?.data?.abilities?.can_update !== true}}"
},
"text": "$t:sirsoft-ecommerce.admin.order.detail.order_info.batch_change",
@@ -1307,6 +1307,7 @@
"orderer_uuid": "",
"page": 1
},
"replace": true,
"transition_overlay_target": "order_product_datagrid__body"
}
}
@@ -184,7 +184,7 @@
"name": "Icon",
"props": {
"name": "arrow-down-tray",
"className": "w-4 h-4"
"className": "text-base"
}
},
{
@@ -610,6 +610,7 @@
"search_keyword": "",
"page": 1
},
"replace": true,
"transition_overlay_target": "order_datagrid__body"
}
}
@@ -639,6 +640,7 @@
"orderer_uuid": "",
"page": 1
},
"replace": true,
"transition_overlay_target": "order_datagrid__body"
}
}
@@ -0,0 +1,206 @@
<?php
namespace App\Upgrades\Data\Ext\Modules\SirsoftEcommerce\V1_0_5\Migrations;
use App\Extension\Upgrade\DataMigration;
use App\Extension\UpgradeContext;
use Illuminate\Support\Facades\DB;
/**
* 외래키 컬럼의 누락된 한국어 comment 를 채웁니다.
*
* 배경:
* `$table->foreignId('user_id')->constrained('users')->comment('사용자 ID')` 형태는 comment 가
* 컬럼이 아니라 외래키 정의에 부착되어 DB 에 생성되지 않습니다. `constrained()` 가 컬럼 정의가
* 아닌 외래키 정의를 돌려주기 때문입니다. 마이그레이션 소스는 교정했지만, 이미 설치를 마친
* 사이트는 마이그레이션을 다시 실행하지 않으므로 설명이 비어 있는 채로 남습니다.
*
* 멱등: 설명이 이미 있는 컬럼은 건드리지 않습니다. 재실행해도 결과가 같습니다.
*
* 안전:
* - 설명이 **비어 있을 때만** 채웁니다 — 운영자가 직접 넣어 둔 설명을 덮어쓰지 않습니다.
* - 자료형·NULL 허용·기본값·자동증가를 현재 스키마에서 읽어 그대로 재적용하므로 설명 외에는
* 아무 것도 바뀌지 않습니다.
* - MySQL 계열에서만 동작합니다(다른 DB 는 건너뜁니다).
*
* 실패 정책: 컬럼 단위로 실패를 흡수합니다 — 한 컬럼이 실패해도 나머지를 계속 처리합니다.
*
* V-1 안전: `Illuminate\Support\Facades\DB` 와 로컬 private 헬퍼만 사용하고, 대상 목록을
* 본 클래스에 동결 상수로 둡니다(마이그레이션 파일·모델 미참조).
*/
class BackfillForeignKeyColumnComments implements DataMigration
{
/**
* 보정 대상 (1.0.5 시점 동결) — 테이블 => [컬럼 => 설명].
*/
private const TARGETS = [
'ecommerce_mileage_balances' => [
'user_id' => '회원 ID',
],
'ecommerce_mileage_transactions' => [
'order_id' => '관련 주문 ID',
'user_id' => '회원 ID',
],
'ecommerce_order_cancel_options' => [
'order_cancel_id' => '취소 FK',
'order_id' => '주문 FK',
'order_option_id' => '주문옵션 FK',
'processed_by' => '처리 관리자',
],
'ecommerce_order_cancels' => [
'cancelled_by' => '취소 요청자',
'order_id' => '주문 FK',
],
'ecommerce_order_refund_options' => [
'order_id' => '주문 FK',
'order_option_id' => '주문옵션 FK',
'order_refund_id' => '환불 FK',
'processed_by' => '처리 관리자',
],
'ecommerce_order_refunds' => [
'order_cancel_id' => '취소 FK (추후 polymorphic 전환 가능)',
'order_id' => '주문 FK',
'processed_by' => '처리 관리자',
],
'ecommerce_product_review_images' => [
'created_by' => '업로더 ID',
],
'ecommerce_product_reviews' => [
'reply_admin_id' => '답변 등록 관리자 ID',
],
];
/**
* 마이그레이션 식별자 (로그용).
*
* @return string 식별자
*/
public function name(): string
{
return 'BackfillForeignKeyColumnComments';
}
/**
* 비어 있는 외래키 컬럼 설명을 채웁니다.
*
* @param UpgradeContext $context 업그레이드 컨텍스트
*/
public function run(UpgradeContext $context): void
{
if (! in_array(DB::getDriverName(), ['mysql', 'mariadb'], true)) {
$context->logger->info('[1.0.5] 컬럼 설명 보정 — MySQL 계열이 아니어서 건너뜁니다');
return;
}
$filled = 0;
$skipped = 0;
$failed = 0;
foreach (self::TARGETS as $table => $columns) {
$prefixed = $context->table($table);
foreach ($columns as $column => $comment) {
try {
$meta = $this->columnMeta($prefixed, $column);
if ($meta === null || trim((string) $meta->COLUMN_COMMENT) !== '') {
$skipped++;
continue;
}
DB::statement($this->buildModifyStatement($prefixed, $column, $meta, $comment));
$filled++;
} catch (\Throwable $e) {
$failed++;
$context->logger->warning(sprintf(
'[1.0.5] 컬럼 설명 보정 실패 (계속 진행): %s.%s — %s',
$prefixed,
$column,
$e->getMessage(),
));
}
}
}
$context->logger->info(sprintf(
'[1.0.5] 외래키 컬럼 설명 보정 — 채움 %d건 / 대상 아님 %d건 / 실패 %d건',
$filled,
$skipped,
$failed,
));
}
/**
* 컬럼의 현재 스키마 메타데이터를 조회합니다.
*
* @param string $table 프리픽스가 적용된 테이블명
* @param string $column 컬럼명
* @return object|null 컬럼 메타 (테이블/컬럼 부재 시 null)
*/
private function columnMeta(string $table, string $column): ?object
{
$rows = DB::select(
'SELECT COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT, EXTRA, COLUMN_COMMENT
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?',
[$table, $column],
);
return $rows[0] ?? null;
}
/**
* 설명만 덧붙이는 MODIFY COLUMN 문을 조립합니다.
*
* @param string $table 프리픽스가 적용된 테이블명
* @param string $column 컬럼명
* @param object $meta columnMeta() 결과
* @param string $comment 넣을 설명
* @return string 실행할 SQL
*/
private function buildModifyStatement(string $table, string $column, object $meta, string $comment): string
{
$sql = sprintf(
'ALTER TABLE %s MODIFY COLUMN %s %s',
$this->quoteIdentifier($table),
$this->quoteIdentifier($column),
$meta->COLUMN_TYPE,
);
$sql .= $meta->IS_NULLABLE === 'YES' ? ' NULL' : ' NOT NULL';
if ($meta->COLUMN_DEFAULT !== null) {
$sql .= ' DEFAULT '.$this->quoteValue((string) $meta->COLUMN_DEFAULT);
}
if (stripos(trim((string) $meta->EXTRA), 'auto_increment') !== false) {
$sql .= ' AUTO_INCREMENT';
}
return $sql.' COMMENT '.$this->quoteValue($comment);
}
/**
* 식별자를 백틱으로 감쌉니다.
*
* @param string $identifier 테이블/컬럼명
* @return string 이스케이프된 식별자
*/
private function quoteIdentifier(string $identifier): string
{
return '`'.str_replace('`', '``', $identifier).'`';
}
/**
* 문자열 값을 SQL 리터럴로 인용합니다.
*
* @param string $value 값
* @return string 인용된 리터럴
*/
private function quoteValue(string $value): string
{
return DB::connection()->getPdo()->quote($value);
}
}
@@ -23,10 +23,12 @@
### Fixed
- 데이터베이스에서 일부 컬럼의 설명이 비어 있던 문제를 바로잡았습니다. 회원·작성자 등 다른 표를 가리키는 컬럼이 대상이며, 업그레이드하면 이미 운영 중인 데이터베이스에도 설명이 채워집니다(직접 적어 넣은 설명은 그대로 둡니다).
- 관리자 페이지 목록에서 상세나 수정 화면에 다녀오면 보고 있던 페이지와 검색 조건이 사라지고 1페이지로 되돌아가던 문제를 수정했습니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.)
- 페이지 활동 로그에서 "제목"·"내용" 변경 항목의 이름이 비어 보이던 문제를 수정했습니다.
- 다른 페이지의 주소로 특정 페이지의 이전 버전 내용을 열람할 수 있던 문제를 수정했습니다.
- 다른 페이지에 속한 버전으로 복원을 시도하면 "복원에 실패했습니다" 라는 서버 오류가 나던 문제를 수정했습니다. 이제 열람과 동일하게 "찾을 수 없음" 으로 안내합니다.
- 관리자 목록에서 검색·필터·정렬을 바꿀 때 표가 다시 그려지는 동안 로딩 표시가 나오지 않던 문제를 수정했습니다. 응답이 늦으면 아무 반응이 없는 것처럼 보여 같은 조작을 반복하게 되던 상황이 사라집니다.
## [1.0.1] - 2026-07-08
@@ -14,7 +14,7 @@ return new class extends Migration
{
Schema::create('page_versions', function (Blueprint $table) {
$table->id()->comment('버전 ID');
$table->foreignId('page_id')->constrained('pages')->cascadeOnDelete()->comment('페이지 ID');
$table->foreignId('page_id')->comment('페이지 ID')->constrained('pages')->cascadeOnDelete();
$table->unsignedInteger('version')->comment('버전 번호');
$table->text('title')->comment('제목 스냅샷 (다국어 JSON)');
$table->mediumText('content')->nullable()->comment('본문 스냅샷 (다국어 JSON)');
@@ -243,6 +243,7 @@
"filters[0][operator]": "like",
"page": 1
},
"replace": true,
"transition_overlay_target": "pages_data_grid__body"
}
}
@@ -268,6 +269,7 @@
"filters[0][operator]": "like",
"page": 1
},
"replace": true,
"transition_overlay_target": "pages_data_grid__body"
}
}
@@ -362,6 +364,7 @@
"filters[0][operator]": "like",
"page": 1
},
"replace": true,
"transition_overlay_target": "pages_data_grid__body"
}
}
@@ -389,6 +392,7 @@
"filters[0][operator]": "like",
"page": 1
},
"replace": true,
"transition_overlay_target": "pages_data_grid__body"
}
}
@@ -470,6 +474,7 @@
"published": "{{$event.target.value}}",
"page": 1
},
"replace": true,
"transition_overlay_target": "pages_data_grid__body"
}
}
@@ -513,6 +518,7 @@
"sort_order": "{{$event.target.value.split('_').pop()}}",
"page": 1
},
"replace": true,
"transition_overlay_target": "pages_data_grid__body"
}
}
@@ -555,6 +561,7 @@
"per_page": "{{$event.target.value}}",
"page": 1
},
"replace": true,
"transition_overlay_target": "pages_data_grid__body"
}
}
@@ -820,6 +827,7 @@
"query": {
"page": "{{$args[0]}}"
},
"replace": true,
"transition_overlay_target": "pages_data_grid__body"
}
},
@@ -0,0 +1,16 @@
<?php
namespace Modules\Sirsoft\Page\Upgrades;
use App\Extension\AbstractUpgradeStep;
/**
* sirsoft-page 모듈 1.0.2 업그레이드 스텝
*
* 외래키 컬럼의 비어 있는 한국어 comment 를 채운다(1컬럼). `->comment()` 가
* `->constrained()` 뒤에 체인되어 컬럼이 아닌 외래키 정의에 부착되던 문제를 소스에서
* 교정했으나, 기설치본은 마이그레이션이 재실행되지 않아 그대로 남기 때문이다.
*
* 모든 비즈니스 로직은 data/1.0.2/migrations/ 로 격리(AbstractUpgradeStep 규약).
*/
class Upgrade_1_0_2 extends AbstractUpgradeStep {}
@@ -0,0 +1,175 @@
<?php
namespace App\Upgrades\Data\Ext\Modules\SirsoftPage\V1_0_2\Migrations;
use App\Extension\Upgrade\DataMigration;
use App\Extension\UpgradeContext;
use Illuminate\Support\Facades\DB;
/**
* 외래키 컬럼의 누락된 한국어 comment 를 채웁니다.
*
* 배경:
* `$table->foreignId('user_id')->constrained('users')->comment('사용자 ID')` 형태는 comment 가
* 컬럼이 아니라 외래키 정의에 부착되어 DB 에 생성되지 않습니다. `constrained()` 가 컬럼 정의가
* 아닌 외래키 정의를 돌려주기 때문입니다. 마이그레이션 소스는 교정했지만, 이미 설치를 마친
* 사이트는 마이그레이션을 다시 실행하지 않으므로 설명이 비어 있는 채로 남습니다.
*
* 멱등: 설명이 이미 있는 컬럼은 건드리지 않습니다. 재실행해도 결과가 같습니다.
*
* 안전:
* - 설명이 **비어 있을 때만** 채웁니다 — 운영자가 직접 넣어 둔 설명을 덮어쓰지 않습니다.
* - 자료형·NULL 허용·기본값·자동증가를 현재 스키마에서 읽어 그대로 재적용하므로 설명 외에는
* 아무 것도 바뀌지 않습니다.
* - MySQL 계열에서만 동작합니다(다른 DB 는 건너뜁니다).
*
* 실패 정책: 컬럼 단위로 실패를 흡수합니다 — 한 컬럼이 실패해도 나머지를 계속 처리합니다.
*
* V-1 안전: `Illuminate\Support\Facades\DB` 와 로컬 private 헬퍼만 사용하고, 대상 목록을
* 본 클래스에 동결 상수로 둡니다(마이그레이션 파일·모델 미참조).
*/
class BackfillForeignKeyColumnComments implements DataMigration
{
/**
* 보정 대상 (1.0.2 시점 동결) — 테이블 => [컬럼 => 설명].
*/
private const TARGETS = [
'page_versions' => [
'page_id' => '페이지 ID',
],
];
/**
* 마이그레이션 식별자 (로그용).
*
* @return string 식별자
*/
public function name(): string
{
return 'BackfillForeignKeyColumnComments';
}
/**
* 비어 있는 외래키 컬럼 설명을 채웁니다.
*
* @param UpgradeContext $context 업그레이드 컨텍스트
*/
public function run(UpgradeContext $context): void
{
if (! in_array(DB::getDriverName(), ['mysql', 'mariadb'], true)) {
$context->logger->info('[1.0.2] 컬럼 설명 보정 — MySQL 계열이 아니어서 건너뜁니다');
return;
}
$filled = 0;
$skipped = 0;
$failed = 0;
foreach (self::TARGETS as $table => $columns) {
$prefixed = $context->table($table);
foreach ($columns as $column => $comment) {
try {
$meta = $this->columnMeta($prefixed, $column);
if ($meta === null || trim((string) $meta->COLUMN_COMMENT) !== '') {
$skipped++;
continue;
}
DB::statement($this->buildModifyStatement($prefixed, $column, $meta, $comment));
$filled++;
} catch (\Throwable $e) {
$failed++;
$context->logger->warning(sprintf(
'[1.0.2] 컬럼 설명 보정 실패 (계속 진행): %s.%s — %s',
$prefixed,
$column,
$e->getMessage(),
));
}
}
}
$context->logger->info(sprintf(
'[1.0.2] 외래키 컬럼 설명 보정 — 채움 %d건 / 대상 아님 %d건 / 실패 %d건',
$filled,
$skipped,
$failed,
));
}
/**
* 컬럼의 현재 스키마 메타데이터를 조회합니다.
*
* @param string $table 프리픽스가 적용된 테이블명
* @param string $column 컬럼명
* @return object|null 컬럼 메타 (테이블/컬럼 부재 시 null)
*/
private function columnMeta(string $table, string $column): ?object
{
$rows = DB::select(
'SELECT COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT, EXTRA, COLUMN_COMMENT
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?',
[$table, $column],
);
return $rows[0] ?? null;
}
/**
* 설명만 덧붙이는 MODIFY COLUMN 문을 조립합니다.
*
* @param string $table 프리픽스가 적용된 테이블명
* @param string $column 컬럼명
* @param object $meta columnMeta() 결과
* @param string $comment 넣을 설명
* @return string 실행할 SQL
*/
private function buildModifyStatement(string $table, string $column, object $meta, string $comment): string
{
$sql = sprintf(
'ALTER TABLE %s MODIFY COLUMN %s %s',
$this->quoteIdentifier($table),
$this->quoteIdentifier($column),
$meta->COLUMN_TYPE,
);
$sql .= $meta->IS_NULLABLE === 'YES' ? ' NULL' : ' NOT NULL';
if ($meta->COLUMN_DEFAULT !== null) {
$sql .= ' DEFAULT '.$this->quoteValue((string) $meta->COLUMN_DEFAULT);
}
if (stripos(trim((string) $meta->EXTRA), 'auto_increment') !== false) {
$sql .= ' AUTO_INCREMENT';
}
return $sql.' COMMENT '.$this->quoteValue($comment);
}
/**
* 식별자를 백틱으로 감쌉니다.
*
* @param string $identifier 테이블/컬럼명
* @return string 이스케이프된 식별자
*/
private function quoteIdentifier(string $identifier): string
{
return '`'.str_replace('`', '``', $identifier).'`';
}
/**
* 문자열 값을 SQL 리터럴로 인용합니다.
*
* @param string $value 값
* @return string 인용된 리터럴
*/
private function quoteValue(string $value): string
{
return DB::connection()->getPdo()->quote($value);
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"preset": "laravel",
"rules": {
"no_superfluous_phpdoc_tags": false
}
}
@@ -22,9 +22,11 @@
- 「동의하지 않고 계속하기」를 눌러도 필수 쿠키가 「동의함」으로 기록되지 않도록 바로잡았습니다 — 필수 쿠키는 서비스 이용에 반드시 필요하여 동의 대상이 아닙니다.
- 거부 시 안내 문구를 「동의하지 않고 계속합니다」로 바로잡았습니다 (이전에는 「동의가 저장되었습니다」로 잘못 표시).
- 관리자 환경설정의 「쿠키 배너 노출」 안내 문구를 실제 동작에 맞게 바로잡았습니다 — 이 토글은 배너와 자동 차단만 제어하며, 마이페이지 「쿠키 동의 관리」 카드는 동의·철회 이력이 있는 회원에게 이 토글과 무관하게 항상 노출됩니다.
- 사이트 언어를 바꾼 뒤 쿠키 동의를 저장하면 차단 해제·재차단이 즉시 반영되지 않던 문제를 수정했습니다. 페이지를 새로 고쳐야 반영되던 증상입니다.
### Fixed
- 데이터베이스에서 일부 컬럼의 설명이 비어 있던 문제를 바로잡았습니다. 회원·작성자 등 다른 표를 가리키는 컬럼이 대상이며, 업그레이드하면 이미 운영 중인 데이터베이스에도 설명이 채워집니다(직접 적어 넣은 설명은 그대로 둡니다).
- 관리자 환경설정의 쿠키 카테고리·차단 도메인 목록에서 항목이 여러 개일 때 화면 요소 식별자가 중복되던 문제를 바로잡았습니다.
- 관리자 환경설정의 카테고리 「필수」 배지가 다크 모드에서 배경·글자색이 적용되지 않던 문제를 바로잡았습니다.
- 관리자 환경설정의 「운영 주체 / 개인정보처리방침」 섹션 제목 영역이 다른 카드와 다르게 표시되던 문제를 바로잡았습니다.
@@ -1,7 +1,7 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"identifier": "sirsoft-gdpr",
"version": "1.0.0",
"version": "1.0.2",
"components": {
"basic": [],
"composite": [],
@@ -17,9 +17,9 @@ return new class extends Migration
Schema::create('gdpr_user_consents', function (Blueprint $table) {
$table->id()->comment('행 ID');
$table->foreignId('user_id')
->comment('사용자 ID')
->constrained('users')
->cascadeOnDelete()
->comment('사용자 ID');
->cascadeOnDelete();
$table->string('consent_key', 50)->comment('동의 항목 키 (cookie_necessary 등)');
$table->string('consent_category', 30)->nullable()->comment('동의 분류 (cookie 등)');
$table->boolean('is_consented')->default(false)->comment('현재 동의 여부');
@@ -18,9 +18,9 @@ return new class extends Migration
$table->id()->comment('행 ID');
$table->foreignId('user_id')
->nullable()
->comment('사용자 ID (게스트면 NULL, 삭제 시 NULL 익명화)')
->constrained('users')
->nullOnDelete()
->comment('사용자 ID (게스트면 NULL, 삭제 시 NULL 익명화)');
->nullOnDelete();
$table->string('session_id', 100)->nullable()->comment('게스트 세션 ID (회원이면 NULL)');
$table->string('consent_key', 50)->comment('동의 항목 키');
$table->string('action', 20)->comment('변경 유형 (granted/revoked/acknowledged)');
@@ -4,6 +4,7 @@ use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Plugins\Sirsoft\Gdpr\Plugin;
return new class extends Migration
{
@@ -22,9 +23,9 @@ return new class extends Migration
$table->json('snapshot')->comment('발행 시점 settings 스냅샷 (cookie_categories + blocked_domains + privacy_policy_slug 등)');
$table->foreignId('created_by')
->nullable()
->comment('발행 운영자 user_id (운영자 삭제 시 NULL — 이력은 영구 보존)')
->constrained('users')
->nullOnDelete()
->comment('발행 운영자 user_id (운영자 삭제 시 NULL — 이력은 영구 보존)');
->nullOnDelete();
$table->timestamp('created_at')->nullable()->comment('발행 일시 (UPDATED_AT 없음 — 불변 레코드)');
$table->index('created_at');
@@ -45,7 +46,7 @@ return new class extends Migration
// 빈 snapshot 으로 두면 회원이 v1 에 동의했을 때 "어떤 정책에 동의했는지" 입증 불가.
// cookie_categories 는 settings 컬럼이 string 이라 json_encode 된 형태이므로 snapshot
// 에는 디코드된 배열로 정규화 (snapshot 스키마는 항상 객체/배열 — admin 측 발행 경로와 일치).
$defaults = (new \Plugins\Sirsoft\Gdpr\Plugin())->getConfigValues();
$defaults = (new Plugin)->getConfigValues();
if (isset($defaults['cookie_categories']) && is_string($defaults['cookie_categories'])) {
$decoded = json_decode($defaults['cookie_categories'], true);
$defaults['cookie_categories'] = is_array($decoded) ? $decoded : [];
File diff suppressed because one or more lines are too long
@@ -0,0 +1,80 @@
/**
* @file handlerReregistration.test.ts
* @description 코어 재초기화(로케일 전환 등) 후 syncConsent 핸들러 재등록 회귀 검증
*
* TemplateApp 은 ActionDispatcher 를 새로 만든 뒤
* `window.__[Plugin].initPlugin()` 을 호출해 플러그인이 핸들러를 다시 등록하게 한다.
* 이 이름으로 노출하지 않으면 재초기화 이후 동의 저장 직후의 차단 엔진·인터셉터 동기화가
* 조용히 멈춘다 (핸들러 미등록 → dispatch 무시 → 에러도 로그도 없음).
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { initPlugin, PLUGIN_IDENTIFIER } from '../index';
const HANDLER_NAME = `${PLUGIN_IDENTIFIER}.syncConsent`;
/** 코어 ActionDispatcher 를 흉내낸다 — 재초기화 시 새 인스턴스가 만들어진다. */
function createDispatcher() {
const handlers = new Map<string, unknown>();
return {
handlers,
registerHandler: vi.fn((name: string, handler: unknown) => {
handlers.set(name, handler);
}),
};
}
function installG7Core(dispatcher: ReturnType<typeof createDispatcher>): void {
(window as any).G7Core = {
getActionDispatcher: () => dispatcher,
};
}
describe('코어 재초기화 시 핸들러 재등록', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
delete (window as any).G7Core;
vi.restoreAllMocks();
});
it('window 전역에 initPlugin 을 노출한다 (코어가 이 이름으로만 재호출한다)', () => {
const exposed = (window as any).__SirsoftGdpr;
expect(exposed).toBeDefined();
expect(typeof exposed.initPlugin).toBe('function');
});
it('새 ActionDispatcher 에 syncConsent 핸들러를 다시 등록한다', () => {
const first = createDispatcher();
installG7Core(first);
initPlugin();
expect(first.handlers.has(HANDLER_NAME)).toBe(true);
// 로케일 전환 — 코어가 ActionDispatcher 를 새로 만든다 (기존 핸들러 전부 소실)
const second = createDispatcher();
installG7Core(second);
expect(second.handlers.has(HANDLER_NAME)).toBe(false);
initPlugin();
expect(second.handlers.has(HANDLER_NAME)).toBe(true);
expect(typeof second.handlers.get(HANDLER_NAME)).toBe('function');
});
it('재초기화 시점에 ActionDispatcher 가 아직 없으면 준비될 때까지 재시도한다', () => {
(window as any).G7Core = undefined;
initPlugin();
const dispatcher = createDispatcher();
installG7Core(dispatcher);
vi.advanceTimersByTime(100);
expect(dispatcher.handlers.has(HANDLER_NAME)).toBe(true);
});
});
@@ -250,6 +250,28 @@ async function bootstrap(): Promise<void> {
}
}
/**
* 코어 재초기화 시 호출되는 진입점.
*
* 로케일 전환 등으로 TemplateApp 이 ActionDispatcher 를 새로 만들면
* `TemplateApp.reinitializePluginHandlers()` 가 `window.__[Plugin].initPlugin()` 을 호출한다.
* 이 이름으로 노출하지 않으면 재초기화 후 syncConsent 핸들러가 소실되어
* 동의 저장 직후 차단 엔진·인터셉터 동기화가 조용히 멈춘다.
*
* 핸들러 재등록만 수행한다 — 차단 엔진/인터셉터는 이미 설치되어 있으므로 재설치하지 않는다.
*/
function initPlugin(): void {
registerSyncHandler();
}
if (typeof window !== 'undefined') {
(window as unknown as Record<string, unknown>).__SirsoftGdpr = {
identifier: PLUGIN_IDENTIFIER,
// 코어 재초기화 시 핸들러 재등록 진입점 — 이름 고정 (TemplateApp.reinitializePluginHandlers)
initPlugin,
};
}
if (typeof document !== 'undefined') {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
@@ -258,4 +280,6 @@ if (typeof document !== 'undefined') {
} else {
void bootstrap();
}
}
}
export { initPlugin, PLUGIN_IDENTIFIER };
@@ -0,0 +1,16 @@
<?php
namespace Plugins\Sirsoft\Gdpr\Upgrades;
use App\Extension\AbstractUpgradeStep;
/**
* sirsoft-gdpr 플러그인 1.0.2 업그레이드 스텝
*
* 외래키 컬럼의 비어 있는 한국어 comment 를 채운다(3컬럼). `->comment()` 가
* `->constrained()` 뒤에 체인되어 컬럼이 아닌 외래키 정의에 부착되던 문제를 소스에서
* 교정했으나, 기설치본은 마이그레이션이 재실행되지 않아 그대로 남기 때문이다.
*
* 모든 비즈니스 로직은 data/1.0.2/migrations/ 로 격리(AbstractUpgradeStep 규약).
*/
class Upgrade_1_0_2 extends AbstractUpgradeStep {}
@@ -0,0 +1,181 @@
<?php
namespace App\Upgrades\Data\Ext\Plugins\SirsoftGdpr\V1_0_2\Migrations;
use App\Extension\Upgrade\DataMigration;
use App\Extension\UpgradeContext;
use Illuminate\Support\Facades\DB;
/**
* 외래키 컬럼의 누락된 한국어 comment 를 채웁니다.
*
* 배경:
* `$table->foreignId('user_id')->constrained('users')->comment('사용자 ID')` 형태는 comment 가
* 컬럼이 아니라 외래키 정의에 부착되어 DB 에 생성되지 않습니다. `constrained()` 가 컬럼 정의가
* 아닌 외래키 정의를 돌려주기 때문입니다. 마이그레이션 소스는 교정했지만, 이미 설치를 마친
* 사이트는 마이그레이션을 다시 실행하지 않으므로 설명이 비어 있는 채로 남습니다.
*
* 멱등: 설명이 이미 있는 컬럼은 건드리지 않습니다. 재실행해도 결과가 같습니다.
*
* 안전:
* - 설명이 **비어 있을 때만** 채웁니다 — 운영자가 직접 넣어 둔 설명을 덮어쓰지 않습니다.
* - 자료형·NULL 허용·기본값·자동증가를 현재 스키마에서 읽어 그대로 재적용하므로 설명 외에는
* 아무 것도 바뀌지 않습니다.
* - MySQL 계열에서만 동작합니다(다른 DB 는 건너뜁니다).
*
* 실패 정책: 컬럼 단위로 실패를 흡수합니다 — 한 컬럼이 실패해도 나머지를 계속 처리합니다.
*
* V-1 안전: `Illuminate\Support\Facades\DB` 와 로컬 private 헬퍼만 사용하고, 대상 목록을
* 본 클래스에 동결 상수로 둡니다(마이그레이션 파일·모델 미참조).
*/
class BackfillForeignKeyColumnComments implements DataMigration
{
/**
* 보정 대상 (1.0.2 시점 동결) — 테이블 => [컬럼 => 설명].
*/
private const TARGETS = [
'gdpr_policy_versions' => [
'created_by' => '발행 운영자 user_id (운영자 삭제 시 NULL — 이력은 영구 보존)',
],
'gdpr_user_consent_histories' => [
'user_id' => '사용자 ID (게스트면 NULL, 삭제 시 NULL 익명화)',
],
'gdpr_user_consents' => [
'user_id' => '사용자 ID',
],
];
/**
* 마이그레이션 식별자 (로그용).
*
* @return string 식별자
*/
public function name(): string
{
return 'BackfillForeignKeyColumnComments';
}
/**
* 비어 있는 외래키 컬럼 설명을 채웁니다.
*
* @param UpgradeContext $context 업그레이드 컨텍스트
*/
public function run(UpgradeContext $context): void
{
if (! in_array(DB::getDriverName(), ['mysql', 'mariadb'], true)) {
$context->logger->info('[1.0.2] 컬럼 설명 보정 — MySQL 계열이 아니어서 건너뜁니다');
return;
}
$filled = 0;
$skipped = 0;
$failed = 0;
foreach (self::TARGETS as $table => $columns) {
$prefixed = $context->table($table);
foreach ($columns as $column => $comment) {
try {
$meta = $this->columnMeta($prefixed, $column);
if ($meta === null || trim((string) $meta->COLUMN_COMMENT) !== '') {
$skipped++;
continue;
}
DB::statement($this->buildModifyStatement($prefixed, $column, $meta, $comment));
$filled++;
} catch (\Throwable $e) {
$failed++;
$context->logger->warning(sprintf(
'[1.0.2] 컬럼 설명 보정 실패 (계속 진행): %s.%s — %s',
$prefixed,
$column,
$e->getMessage(),
));
}
}
}
$context->logger->info(sprintf(
'[1.0.2] 외래키 컬럼 설명 보정 — 채움 %d건 / 대상 아님 %d건 / 실패 %d건',
$filled,
$skipped,
$failed,
));
}
/**
* 컬럼의 현재 스키마 메타데이터를 조회합니다.
*
* @param string $table 프리픽스가 적용된 테이블명
* @param string $column 컬럼명
* @return object|null 컬럼 메타 (테이블/컬럼 부재 시 null)
*/
private function columnMeta(string $table, string $column): ?object
{
$rows = DB::select(
'SELECT COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT, EXTRA, COLUMN_COMMENT
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?',
[$table, $column],
);
return $rows[0] ?? null;
}
/**
* 설명만 덧붙이는 MODIFY COLUMN 문을 조립합니다.
*
* @param string $table 프리픽스가 적용된 테이블명
* @param string $column 컬럼명
* @param object $meta columnMeta() 결과
* @param string $comment 넣을 설명
* @return string 실행할 SQL
*/
private function buildModifyStatement(string $table, string $column, object $meta, string $comment): string
{
$sql = sprintf(
'ALTER TABLE %s MODIFY COLUMN %s %s',
$this->quoteIdentifier($table),
$this->quoteIdentifier($column),
$meta->COLUMN_TYPE,
);
$sql .= $meta->IS_NULLABLE === 'YES' ? ' NULL' : ' NOT NULL';
if ($meta->COLUMN_DEFAULT !== null) {
$sql .= ' DEFAULT '.$this->quoteValue((string) $meta->COLUMN_DEFAULT);
}
if (stripos(trim((string) $meta->EXTRA), 'auto_increment') !== false) {
$sql .= ' AUTO_INCREMENT';
}
return $sql.' COMMENT '.$this->quoteValue($comment);
}
/**
* 식별자를 백틱으로 감쌉니다.
*
* @param string $identifier 테이블/컬럼명
* @return string 이스케이프된 식별자
*/
private function quoteIdentifier(string $identifier): string
{
return '`'.str_replace('`', '``', $identifier).'`';
}
/**
* 문자열 값을 SQL 리터럴로 인용합니다.
*
* @param string $value 값
* @return string 인용된 리터럴
*/
private function quoteValue(string $value): string
{
return DB::connection()->getPdo()->quote($value);
}
}
@@ -4,6 +4,12 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.1] - 2026-07-29
### Fixed
- 데이터베이스에서 일부 컬럼의 설명이 비어 있던 문제를 바로잡았습니다. 회원을 가리키는 컬럼이 대상이며, 업그레이드하면 이미 운영 중인 데이터베이스에도 설명이 채워집니다(직접 적어 넣은 설명은 그대로 둡니다).
## [1.0.0] - 2026-07-01
### Added
@@ -2,7 +2,7 @@
"name": "plugins/sirsoft-marketing",
"description": "Marketing consent and subscription management plugin for Gnuboard7 platform",
"type": "library",
"version": "1.0.0",
"version": "1.0.1",
"autoload": {
"psr-4": {
"Plugins\\Sirsoft\\Marketing\\": ["src/", "./"]
@@ -17,9 +17,9 @@ return new class extends Migration
Schema::create('user_marketing_consents', function (Blueprint $table) {
$table->id()->comment('고유 ID');
$table->foreignId('user_id')
->comment('사용자 ID')
->constrained('users')
->cascadeOnDelete()
->comment('사용자 ID');
->cascadeOnDelete();
$table->string('consent_key', 30)->comment('동의 항목 키 (email_subscription, marketing_consent 등)');
$table->boolean('is_consented')->default(false)->comment('현재 동의 여부');
$table->timestamp('consented_at')->nullable()->comment('최근 동의 일시');
@@ -17,9 +17,9 @@ return new class extends Migration
Schema::create('user_marketing_consent_histories', function (Blueprint $table) {
$table->id()->comment('고유 ID');
$table->foreignId('user_id')
->comment('사용자 ID')
->constrained('users')
->cascadeOnDelete()
->comment('사용자 ID');
->cascadeOnDelete();
$table->string('channel_key', 30)->comment('동의 항목 키 (email_subscription, marketing_consent 등)');
$table->string('action', 10)->comment('변경 유형 (granted/revoked)');
$table->string('source', 20)->comment('변경 경로 (register/profile/admin)');
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,6 @@
{
"name": "@g7/sirsoft-marketing",
"version": "1.0.0",
"version": "1.0.1",
"description": "G7 마케팅 동의 플러그인 프론트엔드 에셋",
"private": true,
"type": "module",
@@ -5,7 +5,7 @@
"ko": "마케팅 동의",
"en": "Marketing Consent"
},
"version": "1.0.0",
"version": "1.0.1",
"description": {
"ko": "이메일 구독, 마케팅 동의, 제3자 제공 동의 등을 관리하는 플러그인",
"en": "Plugin for managing email subscriptions, marketing consent, and third-party data sharing consent"
@@ -0,0 +1,16 @@
<?php
namespace Plugins\Sirsoft\Marketing\Upgrades;
use App\Extension\AbstractUpgradeStep;
/**
* sirsoft-marketing 플러그인 1.0.1 업그레이드 스텝
*
* 외래키 컬럼의 비어 있는 한국어 comment 를 채운다(2컬럼). `->comment()` 가
* `->constrained()` 뒤에 체인되어 컬럼이 아닌 외래키 정의에 부착되던 문제를 소스에서
* 교정했으나, 기설치본은 마이그레이션이 재실행되지 않아 그대로 남기 때문이다.
*
* 모든 비즈니스 로직은 data/1.0.1/migrations/ 로 격리(AbstractUpgradeStep 규약).
*/
class Upgrade_1_0_1 extends AbstractUpgradeStep {}
@@ -0,0 +1,178 @@
<?php
namespace App\Upgrades\Data\Ext\Plugins\SirsoftMarketing\V1_0_1\Migrations;
use App\Extension\Upgrade\DataMigration;
use App\Extension\UpgradeContext;
use Illuminate\Support\Facades\DB;
/**
* 외래키 컬럼의 누락된 한국어 comment 를 채웁니다.
*
* 배경:
* `$table->foreignId('user_id')->constrained('users')->comment('사용자 ID')` 형태는 comment 가
* 컬럼이 아니라 외래키 정의에 부착되어 DB 에 생성되지 않습니다. `constrained()` 가 컬럼 정의가
* 아닌 외래키 정의를 돌려주기 때문입니다. 마이그레이션 소스는 교정했지만, 이미 설치를 마친
* 사이트는 마이그레이션을 다시 실행하지 않으므로 설명이 비어 있는 채로 남습니다.
*
* 멱등: 설명이 이미 있는 컬럼은 건드리지 않습니다. 재실행해도 결과가 같습니다.
*
* 안전:
* - 설명이 **비어 있을 때만** 채웁니다 — 운영자가 직접 넣어 둔 설명을 덮어쓰지 않습니다.
* - 자료형·NULL 허용·기본값·자동증가를 현재 스키마에서 읽어 그대로 재적용하므로 설명 외에는
* 아무 것도 바뀌지 않습니다.
* - MySQL 계열에서만 동작합니다(다른 DB 는 건너뜁니다).
*
* 실패 정책: 컬럼 단위로 실패를 흡수합니다 — 한 컬럼이 실패해도 나머지를 계속 처리합니다.
*
* V-1 안전: `Illuminate\Support\Facades\DB` 와 로컬 private 헬퍼만 사용하고, 대상 목록을
* 본 클래스에 동결 상수로 둡니다(마이그레이션 파일·모델 미참조).
*/
class BackfillForeignKeyColumnComments implements DataMigration
{
/**
* 보정 대상 (1.0.1 시점 동결) — 테이블 => [컬럼 => 설명].
*/
private const TARGETS = [
'user_marketing_consent_histories' => [
'user_id' => '사용자 ID',
],
'user_marketing_consents' => [
'user_id' => '사용자 ID',
],
];
/**
* 마이그레이션 식별자 (로그용).
*
* @return string 식별자
*/
public function name(): string
{
return 'BackfillForeignKeyColumnComments';
}
/**
* 비어 있는 외래키 컬럼 설명을 채웁니다.
*
* @param UpgradeContext $context 업그레이드 컨텍스트
*/
public function run(UpgradeContext $context): void
{
if (! in_array(DB::getDriverName(), ['mysql', 'mariadb'], true)) {
$context->logger->info('[1.0.1] 컬럼 설명 보정 — MySQL 계열이 아니어서 건너뜁니다');
return;
}
$filled = 0;
$skipped = 0;
$failed = 0;
foreach (self::TARGETS as $table => $columns) {
$prefixed = $context->table($table);
foreach ($columns as $column => $comment) {
try {
$meta = $this->columnMeta($prefixed, $column);
if ($meta === null || trim((string) $meta->COLUMN_COMMENT) !== '') {
$skipped++;
continue;
}
DB::statement($this->buildModifyStatement($prefixed, $column, $meta, $comment));
$filled++;
} catch (\Throwable $e) {
$failed++;
$context->logger->warning(sprintf(
'[1.0.1] 컬럼 설명 보정 실패 (계속 진행): %s.%s — %s',
$prefixed,
$column,
$e->getMessage(),
));
}
}
}
$context->logger->info(sprintf(
'[1.0.1] 외래키 컬럼 설명 보정 — 채움 %d건 / 대상 아님 %d건 / 실패 %d건',
$filled,
$skipped,
$failed,
));
}
/**
* 컬럼의 현재 스키마 메타데이터를 조회합니다.
*
* @param string $table 프리픽스가 적용된 테이블명
* @param string $column 컬럼명
* @return object|null 컬럼 메타 (테이블/컬럼 부재 시 null)
*/
private function columnMeta(string $table, string $column): ?object
{
$rows = DB::select(
'SELECT COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT, EXTRA, COLUMN_COMMENT
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?',
[$table, $column],
);
return $rows[0] ?? null;
}
/**
* 설명만 덧붙이는 MODIFY COLUMN 문을 조립합니다.
*
* @param string $table 프리픽스가 적용된 테이블명
* @param string $column 컬럼명
* @param object $meta columnMeta() 결과
* @param string $comment 넣을 설명
* @return string 실행할 SQL
*/
private function buildModifyStatement(string $table, string $column, object $meta, string $comment): string
{
$sql = sprintf(
'ALTER TABLE %s MODIFY COLUMN %s %s',
$this->quoteIdentifier($table),
$this->quoteIdentifier($column),
$meta->COLUMN_TYPE,
);
$sql .= $meta->IS_NULLABLE === 'YES' ? ' NULL' : ' NOT NULL';
if ($meta->COLUMN_DEFAULT !== null) {
$sql .= ' DEFAULT '.$this->quoteValue((string) $meta->COLUMN_DEFAULT);
}
if (stripos(trim((string) $meta->EXTRA), 'auto_increment') !== false) {
$sql .= ' AUTO_INCREMENT';
}
return $sql.' COMMENT '.$this->quoteValue($comment);
}
/**
* 식별자를 백틱으로 감쌉니다.
*
* @param string $identifier 테이블/컬럼명
* @return string 이스케이프된 식별자
*/
private function quoteIdentifier(string $identifier): string
{
return '`'.str_replace('`', '``', $identifier).'`';
}
/**
* 문자열 값을 SQL 리터럴로 인용합니다.
*
* @param string $value 값
* @return string 인용된 리터럴
*/
private function quoteValue(string $value): string
{
return DB::connection()->getPdo()->quote($value);
}
}
@@ -14,6 +14,7 @@
- 관리자 주문 목록에서 주문번호를 눌러 상세로 들어갈 때 걸어 둔 필터와 페이지가 사라지던 문제를 수정했습니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.)
- 관리자 주문 목록에서 '이 주문자의 주문 검색'을 실행하면 검색창은 비어 보이는데 직전 검색어가 계속 적용돼 결과가 어긋나던 문제를 수정했습니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.)
- 해외결제(CBT) 연결 진단을 실행했을 때 결과 안내가 실제 문장 대신 내부 식별자로 표시되던 문제를 수정했습니다.
- 관리자 목록에서 검색·필터·정렬을 바꿀 때 표가 다시 그려지는 동안 로딩 표시가 나오지 않던 문제를 수정했습니다. 응답이 늦으면 아무 반응이 없는 것처럼 보여 같은 조작을 반복하게 되던 상황이 사라집니다.
## [1.0.1] - 2026-07-16
@@ -178,14 +178,14 @@
"handler": "sequence",
"actions": [
{ "handler": "setState", "params": { "target": "_local", "filter.ordererUuid": "{{row.user?.uuid}}", "filter.searchField": "all", "filter.searchKeyword": "", "ordererSearchResults": "{{[{ uuid: row.user?.uuid, name: row.user?.name || row.address?.orderer_name, email: row.user?.email || '' }]}}" } },
{ "comment": "이 주문자의 주문만 보기 — 앞선 setState 가 검색어를 비우므로 URL 의 검색 조건도 함께 비운다. 비우지 않으면 화면 입력창은 빈 채로 이전 검색어가 계속 적용된다 (#75).", "handler": "navigate", "params": { "path": "/admin/ecommerce/orders", "mergeQuery": true, "query": { "orderer_uuid": "{{row.user?.uuid}}", "search_field": "", "search_keyword": "", "page": 1 }, "transition_overlay_target": "order_datagrid__body" } }
{ "comment": "이 주문자의 주문만 보기 — 앞선 setState 가 검색어를 비우므로 URL 의 검색 조건도 함께 비운다. 비우지 않으면 화면 입력창은 빈 채로 이전 검색어가 계속 적용된다 (#75).", "handler": "navigate", "params": { "path": "/admin/ecommerce/orders", "mergeQuery": true, "query": { "orderer_uuid": "{{row.user?.uuid}}", "search_field": "", "search_keyword": "", "page": 1 }, "replace": true, "transition_overlay_target": "order_datagrid__body" } }
]
},
"search_by_orderer_guest": {
"handler": "sequence",
"actions": [
{ "handler": "setState", "params": { "target": "_local", "filter.searchField": "orderer_name", "filter.searchKeyword": "{{row.address?.orderer_name || row.user?.name}}", "filter.ordererUuid": "" } },
{ "comment": "비회원 주문자명으로 검색 — 회원 지정 필터(orderer_uuid)를 함께 비우지 않으면 이름 검색과 회원 필터가 겹쳐 결과가 비어 보인다 (#75).", "handler": "navigate", "params": { "path": "/admin/ecommerce/orders", "mergeQuery": true, "query": { "search_field": "orderer_name", "search_keyword": "{{row.address?.orderer_name || row.user?.name}}", "orderer_uuid": "", "page": 1 }, "transition_overlay_target": "order_datagrid__body" } }
{ "comment": "비회원 주문자명으로 검색 — 회원 지정 필터(orderer_uuid)를 함께 비우지 않으면 이름 검색과 회원 필터가 겹쳐 결과가 비어 보인다 (#75).", "handler": "navigate", "params": { "path": "/admin/ecommerce/orders", "mergeQuery": true, "query": { "search_field": "orderer_name", "search_keyword": "{{row.address?.orderer_name || row.user?.name}}", "orderer_uuid": "", "page": 1 }, "replace": true, "transition_overlay_target": "order_datagrid__body" } }
]
}
}
@@ -12,6 +12,7 @@
- 관리자 주문 목록에서 '이 주문자의 주문 검색'을 실행하면 검색창은 비어 보이는데 직전 검색어가 계속 적용돼 결과가 어긋나던 문제를 수정했습니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.)
- 오류 안내가 뜨기는 하지만 내용이 비어 있던 문제를 수정했습니다. 설정 저장에 실패하면 서버가 알려 준 사유가 그대로 표시됩니다.
- 설정 화면의 연동 점검 결과 아이콘이 의도한 크기보다 크거나 작게 보이던 문제를 수정했습니다.
- 관리자 목록에서 검색·필터·정렬을 바꿀 때 표가 다시 그려지는 동안 로딩 표시가 나오지 않던 문제를 수정했습니다. 응답이 늦으면 아무 반응이 없는 것처럼 보여 같은 조작을 반복하게 되던 상황이 사라집니다.
## [1.0.0] - 2026-07-16
@@ -199,14 +199,14 @@
"handler": "sequence",
"actions": [
{ "handler": "setState", "params": { "target": "_local", "filter.ordererUuid": "{{row.user?.uuid}}", "filter.searchField": "all", "filter.searchKeyword": "", "ordererSearchResults": "{{[{ uuid: row.user?.uuid, name: row.user?.name || row.address?.orderer_name, email: row.user?.email || '' }]}}" } },
{ "comment": "이 주문자의 주문만 보기 — 앞선 setState 가 검색어를 비우므로 URL 의 검색 조건도 함께 비운다. 비우지 않으면 화면 입력창은 빈 채로 이전 검색어가 계속 적용된다 (#75).", "handler": "navigate", "params": { "path": "/admin/ecommerce/orders", "mergeQuery": true, "query": { "orderer_uuid": "{{row.user?.uuid}}", "search_field": "", "search_keyword": "", "page": 1 }, "transition_overlay_target": "order_datagrid__body" } }
{ "comment": "이 주문자의 주문만 보기 — 앞선 setState 가 검색어를 비우므로 URL 의 검색 조건도 함께 비운다. 비우지 않으면 화면 입력창은 빈 채로 이전 검색어가 계속 적용된다 (#75).", "handler": "navigate", "params": { "path": "/admin/ecommerce/orders", "mergeQuery": true, "query": { "orderer_uuid": "{{row.user?.uuid}}", "search_field": "", "search_keyword": "", "page": 1 }, "replace": true, "transition_overlay_target": "order_datagrid__body" } }
]
},
"search_by_orderer_guest": {
"handler": "sequence",
"actions": [
{ "handler": "setState", "params": { "target": "_local", "filter.searchField": "orderer_name", "filter.searchKeyword": "{{row.address?.orderer_name || row.user?.name}}", "filter.ordererUuid": "" } },
{ "comment": "비회원 주문자명으로 검색 — 회원 지정 필터(orderer_uuid)를 함께 비우지 않으면 이름 검색과 회원 필터가 겹쳐 결과가 비어 보인다 (#75).", "handler": "navigate", "params": { "path": "/admin/ecommerce/orders", "mergeQuery": true, "query": { "search_field": "orderer_name", "search_keyword": "{{row.address?.orderer_name || row.user?.name}}", "orderer_uuid": "", "page": 1 }, "transition_overlay_target": "order_datagrid__body" } }
{ "comment": "비회원 주문자명으로 검색 — 회원 지정 필터(orderer_uuid)를 함께 비우지 않으면 이름 검색과 회원 필터가 겹쳐 결과가 비어 보인다 (#75).", "handler": "navigate", "params": { "path": "/admin/ecommerce/orders", "mergeQuery": true, "query": { "search_field": "orderer_name", "search_keyword": "{{row.address?.orderer_name || row.user?.name}}", "orderer_uuid": "", "page": 1 }, "replace": true, "transition_overlay_target": "order_datagrid__body" } }
]
}
}
@@ -12,6 +12,7 @@
- 관리자 주문 목록에서 '이 주문자의 주문 검색'을 실행하면 검색창은 비어 보이는데 직전 검색어가 계속 적용돼 결과가 어긋나던 문제를 수정했습니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.)
- 오류 안내가 뜨기는 하지만 내용이 비어 있던 문제를 수정했습니다. 설정 저장에 실패하면 서버가 알려 준 사유가 그대로 표시됩니다.
- 설정 화면의 아이콘이 의도한 크기보다 크거나 작게 보이던 문제를 수정했습니다.
- 관리자 목록에서 검색·필터·정렬을 바꿀 때 표가 다시 그려지는 동안 로딩 표시가 나오지 않던 문제를 수정했습니다. 응답이 늦으면 아무 반응이 없는 것처럼 보여 같은 조작을 반복하게 되던 상황이 사라집니다.
## [1.0.0] - 2026-07-16
@@ -177,14 +177,14 @@
"handler": "sequence",
"actions": [
{ "handler": "setState", "params": { "target": "_local", "filter.ordererUuid": "{{row.user?.uuid}}", "filter.searchField": "all", "filter.searchKeyword": "", "ordererSearchResults": "{{[{ uuid: row.user?.uuid, name: row.user?.name || row.address?.orderer_name, email: row.user?.email || '' }]}}" } },
{ "comment": "이 주문자의 주문만 보기 — 앞선 setState 가 검색어를 비우므로 URL 의 검색 조건도 함께 비운다. 비우지 않으면 화면 입력창은 빈 채로 이전 검색어가 계속 적용된다 (#75).", "handler": "navigate", "params": { "path": "/admin/ecommerce/orders", "mergeQuery": true, "query": { "orderer_uuid": "{{row.user?.uuid}}", "search_field": "", "search_keyword": "", "page": 1 }, "transition_overlay_target": "order_datagrid__body" } }
{ "comment": "이 주문자의 주문만 보기 — 앞선 setState 가 검색어를 비우므로 URL 의 검색 조건도 함께 비운다. 비우지 않으면 화면 입력창은 빈 채로 이전 검색어가 계속 적용된다 (#75).", "handler": "navigate", "params": { "path": "/admin/ecommerce/orders", "mergeQuery": true, "query": { "orderer_uuid": "{{row.user?.uuid}}", "search_field": "", "search_keyword": "", "page": 1 }, "replace": true, "transition_overlay_target": "order_datagrid__body" } }
]
},
"search_by_orderer_guest": {
"handler": "sequence",
"actions": [
{ "handler": "setState", "params": { "target": "_local", "filter.searchField": "orderer_name", "filter.searchKeyword": "{{row.address?.orderer_name || row.user?.name}}", "filter.ordererUuid": "" } },
{ "comment": "비회원 주문자명으로 검색 — 회원 지정 필터(orderer_uuid)를 함께 비우지 않으면 이름 검색과 회원 필터가 겹쳐 결과가 비어 보인다 (#75).", "handler": "navigate", "params": { "path": "/admin/ecommerce/orders", "mergeQuery": true, "query": { "search_field": "orderer_name", "search_keyword": "{{row.address?.orderer_name || row.user?.name}}", "orderer_uuid": "", "page": 1 }, "transition_overlay_target": "order_datagrid__body" } }
{ "comment": "비회원 주문자명으로 검색 — 회원 지정 필터(orderer_uuid)를 함께 비우지 않으면 이름 검색과 회원 필터가 겹쳐 결과가 비어 보인다 (#75).", "handler": "navigate", "params": { "path": "/admin/ecommerce/orders", "mergeQuery": true, "query": { "search_field": "orderer_name", "search_keyword": "{{row.address?.orderer_name || row.user?.name}}", "orderer_uuid": "", "page": 1 }, "replace": true, "transition_overlay_target": "order_datagrid__body" } }
]
}
}
@@ -4,6 +4,18 @@ All notable changes to this plugin will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [1.0.2] - 2026-07-29
### Changed
- 코어 최소 요구 버전을 7.0.6 으로 상향했습니다. 운영 모드 전환 시 라이브 자격증명을 필수로 검사하는 기능이 코어 7.0.6 의 설정 저장 검증 표면을 사용합니다. 그보다 낮은 코어에서는 이 검사가 동작하지 않아 라이브 모드를 빈 자격증명으로 저장할 수 있으므로, 플러그인을 업데이트하기 전에 코어를 7.0.6 이상으로 먼저 업데이트해 주세요.
- 설정 화면의 중복 가입 관련 항목 이름을 「중복체크 기준」·「중복 가입 차단」으로 통일했습니다. 같은 화면에 다른 본인확인 수단의 카드가 함께 놓일 때 용어가 갈리지 않도록 맞췄습니다.
### Fixed
- 데이터베이스에서 일부 컬럼의 설명이 비어 있던 문제를 바로잡았습니다. 회원·작성자 등 다른 표를 가리키는 컬럼이 대상이며, 업그레이드하면 이미 운영 중인 데이터베이스에도 설명이 채워집니다(직접 적어 넣은 설명은 그대로 둡니다).
- 사이트 언어를 바꾼 뒤에는 「본인확인 시작」을 눌러도 인증창이 열리지 않던 문제를 수정했습니다. 아무 반응도 오류 안내도 없어 원인을 알기 어려운 증상이었으며, 페이지를 새로 고치면 정상 동작했습니다.
## [1.0.1] - 2026-07-14
### Security
@@ -1,7 +1,7 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"identifier": "sirsoft-verification_kginicis",
"version": "1.0.1",
"version": "1.0.2",
"components": {
"basic": [],
"composite": [],
@@ -1,7 +1,7 @@
{
"name": "plugins/sirsoft-verification_kginicis",
"description": "KG Inicis Identity Verification provider for G7",
"version": "1.0.1",
"version": "1.0.2",
"type": "library",
"authors": [
{
@@ -20,8 +20,8 @@ return new class extends Migration
$table->id()->comment('고유 ID');
$table->foreignId('user_id')
->unique()
->constrained('users')
->comment('사용자 ID — UNIQUE 1:1. CASCADE 미설정 (탈퇴/삭제 시 listener 명시 삭제)');
->comment('사용자 ID — UNIQUE 1:1. CASCADE 미설정 (탈퇴/삭제 시 listener 명시 삭제)')
->constrained('users');
$table->uuid('latest_log_id')
->nullable()
->comment('현재 PII 가 발급된 challenge UUID — identity_verification_logs.id 참조 (감사 link)');
@@ -1 +1,2 @@
var SirsoftVerificationKginicis=function(s){"use strict";const o="sirsoft-verification_kginicis",w="inicis",A="width=400,height=640,scrollbars=yes,resizable=yes",D="https://sa.inicis.com/auth",I="identity-challenge-modal",l={info:(...e)=>console.info(`[${o}]`,...e),warn:(...e)=>console.warn(`[${o}]`,...e),error:(...e)=>console.error(`[${o}]`,...e)};function a(){return window.G7Core??null}function v(){return`${window.location.origin}/plugins/${o}/plugin/inicis/callback`}function C(e,i){const t=document.createElement("form");t.setAttribute("name","saForm"),t.setAttribute("method","POST"),t.setAttribute("action",D),t.setAttribute("target",i),t.setAttribute("accept-charset","UTF-8"),t.style.display="none";const u={mid:e.mid,reqSvcCd:e.reqSvcCd,mTxId:e.mtxid,authHash:e.authHash,flgFixedUser:e.flgFixedUser,successUrl:v(),failUrl:v(),reservedMsg:e.reservedMsg};for(const[d,c]of Object.entries(u)){const r=document.createElement("input");r.type="hidden",r.name=d,r.value=c,t.appendChild(r)}document.body.appendChild(t),t.submit(),window.setTimeout(()=>{try{t.remove()}catch{}},0)}function _(e){const t=a()?.state?.set;typeof t=="function"&&t({identityChallenge:{providerInProgress:e}})}const L={NOT_ADULT:"$t:sirsoft-verification_kginicis.errors.not_adult",INVALID_AUTH_URL:"$t:sirsoft-verification_kginicis.errors.invalid_auth_url",DECRYPT_FAILED:"$t:sirsoft-verification_kginicis.errors.decrypt_failed",REMOTE_CALL_FAILED:"$t:sirsoft-verification_kginicis.errors.remote_call_failed",NOT_FOUND:"$t:sirsoft-verification_kginicis.errors.not_found",ALREADY_CONSUMED:"$t:sirsoft-verification_kginicis.errors.already_consumed",IDENTITY_BINDING_MISMATCH:"$t:sirsoft-verification_kginicis.errors.binding_mismatch",INCOMPLETE_IDENTITY:"$t:sirsoft-verification_kginicis.errors.incomplete_identity",STORAGE_FAILED:"$t:sirsoft-verification_kginicis.errors.storage_failed"},P="$t:sirsoft-verification_kginicis.errors.verify_failed",S=new Set(["NOT_ADULT"]);function T(e){return L[e]??P}function U(){const e=a()?.identity?.markDomainNoticeShown;if(typeof e=="function")try{e()}catch{}}async function g(e,i="error"){const t=a()?.dispatch;if(typeof t=="function")try{await t({handler:"toast",params:{type:i,message:e}})}catch{}}function O(e){const i=a()?.dispatch;return typeof i!="function"?Promise.resolve():i({handler:"sequence",params:{actions:[{handler:"resolveIdentityChallenge",params:e},{handler:"closeModal",target:I}]}}).catch(()=>{})}async function h(){const e=a(),i=e?.state?.getGlobal?.()?.identityChallenge??e?.state?.get?.()?.identityChallenge;if(!i){l.error("_global.identityChallenge 미설정 — 코어 모달 진입 흐름 확인 필요");return}const t=i.public_payload;if(!t?.mid||!t?.mtxid||!t?.authHash){l.error("public_payload 의 mid/mtxid/authHash 부재 — Provider.requestChallenge 응답 확인 필요",t),await g("본인인증 페이로드가 준비되지 않았습니다. 잠시 후 다시 시도해 주세요.");return}const u="sa_popup",d=window.open("",u,A);if(!d){l.error("window.open null — 팝업 차단됨"),await g("팝업이 차단되었습니다. 브라우저 팝업 허용 설정 후 다시 시도해 주세요.");return}_(!0),C(t,u);let c=!1,r=null,f=null;const p=()=>{f&&(window.removeEventListener("message",f),f=null),r!==null&&(window.clearInterval(r),r=null)};f=y=>{if(y.origin!==window.location.origin)return;const n=y.data;if(!n||n.type!=="identity_result"||c)return;c=!0,p();try{d.close()}catch{}_(!1);const b=n.verification_token?{result:"verified",token:n.verification_token}:n.identity_error?{result:"failed",failureCode:n.identity_error}:{result:"cancelled"};n.identity_error&&(S.has(n.identity_error)&&U(),g(T(n.identity_error),"error")),O(b)},window.addEventListener("message",f),r=window.setInterval(()=>{if(c){p();return}d.closed&&(c=!0,p(),_(!1))},500)}function E(){const i=a()?.getActionDispatcher;if(typeof i!="function")return!1;const t=i();return!t||typeof t.registerHandler!="function"?!1:(t.registerHandler(`${o}.startAuth`,h,{category:"plugin",source:o}),l.info("startAuth handler registered"),!0)}function m(){if(E())return;let e=0;const i=window.setInterval(()=>{e++,(E()||e>=50)&&(window.clearInterval(i),e>=50&&l.warn("G7Core ActionDispatcher 미준비 — handler 등록 실패"))},100)}return m(),window.__SirsoftVerificationKginicis={identifier:o,init:m,startAuthHandler:h},s.MODAL_ID=I,s.PLUGIN_IDENTIFIER=o,s.PROVIDER_ID=w,s.init=m,s.startAuthHandler=h,Object.defineProperty(s,Symbol.toStringTag,{value:"Module"}),s}({});
var SirsoftVerificationKginicis=(function(o){"use strict";const s="sirsoft-verification_kginicis",D="inicis",C="width=400,height=640,scrollbars=yes,resizable=yes",L="https://sa.inicis.com/auth",I="identity-challenge-modal",l={info:(...e)=>console.info(`[${s}]`,...e),warn:(...e)=>console.warn(`[${s}]`,...e),error:(...e)=>console.error(`[${s}]`,...e)};function a(){return window.G7Core??null}function v(){return`${window.location.origin}/plugins/${s}/plugin/inicis/callback`}function P(e,i){const t=document.createElement("form");t.setAttribute("name","saForm"),t.setAttribute("method","POST"),t.setAttribute("action",L),t.setAttribute("target",i),t.setAttribute("accept-charset","UTF-8"),t.style.display="none";const f={mid:e.mid,reqSvcCd:e.reqSvcCd,mTxId:e.mtxid,authHash:e.authHash,flgFixedUser:e.flgFixedUser,successUrl:v(),failUrl:v(),reservedMsg:e.reservedMsg};for(const[d,c]of Object.entries(f)){const r=document.createElement("input");r.type="hidden",r.name=d,r.value=c,t.appendChild(r)}document.body.appendChild(t),t.submit(),window.setTimeout(()=>{try{t.remove()}catch{}},0)}function _(e){const t=a()?.state?.set;typeof t=="function"&&t({identityChallenge:{providerInProgress:e}})}const S={NOT_ADULT:"$t:sirsoft-verification_kginicis.errors.not_adult",INVALID_AUTH_URL:"$t:sirsoft-verification_kginicis.errors.invalid_auth_url",DECRYPT_FAILED:"$t:sirsoft-verification_kginicis.errors.decrypt_failed",REMOTE_CALL_FAILED:"$t:sirsoft-verification_kginicis.errors.remote_call_failed",NOT_FOUND:"$t:sirsoft-verification_kginicis.errors.not_found",ALREADY_CONSUMED:"$t:sirsoft-verification_kginicis.errors.already_consumed",IDENTITY_BINDING_MISMATCH:"$t:sirsoft-verification_kginicis.errors.binding_mismatch",INCOMPLETE_IDENTITY:"$t:sirsoft-verification_kginicis.errors.incomplete_identity",STORAGE_FAILED:"$t:sirsoft-verification_kginicis.errors.storage_failed"},T="$t:sirsoft-verification_kginicis.errors.verify_failed",U=new Set(["NOT_ADULT"]);function O(e){return S[e]??T}function b(){const e=a()?.identity?.markDomainNoticeShown;if(typeof e=="function")try{e()}catch{}}async function g(e,i="error"){const t=a()?.dispatch;if(typeof t=="function")try{await t({handler:"toast",params:{type:i,message:e}})}catch{}}function N(e){const i=a()?.dispatch;return typeof i!="function"?Promise.resolve():i({handler:"sequence",params:{actions:[{handler:"resolveIdentityChallenge",params:e},{handler:"closeModal",target:I}]}}).catch(()=>{})}async function h(){const e=a(),i=e?.state?.getGlobal?.()?.identityChallenge??e?.state?.get?.()?.identityChallenge;if(!i){l.error("_global.identityChallenge 미설정 — 코어 모달 진입 흐름 확인 필요");return}const t=i.public_payload;if(!t?.mid||!t?.mtxid||!t?.authHash){l.error("public_payload 의 mid/mtxid/authHash 부재 — Provider.requestChallenge 응답 확인 필요",t),await g("본인인증 페이로드가 준비되지 않았습니다. 잠시 후 다시 시도해 주세요.");return}const f="sa_popup",d=window.open("",f,C);if(!d){l.error("window.open null — 팝업 차단됨"),await g("팝업이 차단되었습니다. 브라우저 팝업 허용 설정 후 다시 시도해 주세요.");return}_(!0),P(t,f);let c=!1,r=null,u=null;const p=()=>{u&&(window.removeEventListener("message",u),u=null),r!==null&&(window.clearInterval(r),r=null)};u=A=>{if(A.origin!==window.location.origin)return;const n=A.data;if(!n||n.type!=="identity_result"||c)return;c=!0,p();try{d.close()}catch{}_(!1);const R=n.verification_token?{result:"verified",token:n.verification_token}:n.identity_error?{result:"failed",failureCode:n.identity_error}:{result:"cancelled"};n.identity_error&&(U.has(n.identity_error)&&b(),g(O(n.identity_error),"error")),N(R)},window.addEventListener("message",u),r=window.setInterval(()=>{if(c){p();return}d.closed&&(c=!0,p(),_(!1))},500)}function y(){const i=a()?.getActionDispatcher;if(typeof i!="function")return!1;const t=i();return!t||typeof t.registerHandler!="function"?!1:(t.registerHandler(`${s}.startAuth`,h,{category:"plugin",source:s}),l.info("startAuth handler registered"),!0)}function E(){if(y())return;let e=0;const i=window.setInterval(()=>{e++,(y()||e>=50)&&(window.clearInterval(i),e>=50&&l.warn("G7Core ActionDispatcher 미준비 — handler 등록 실패"))},100)}function w(){E()}function m(){E()}return m(),window.__SirsoftVerificationKginicis={identifier:s,init:m,initPlugin:w,startAuthHandler:h},o.MODAL_ID=I,o.PLUGIN_IDENTIFIER=s,o.PROVIDER_ID=D,o.init=m,o.initPlugin=w,o.startAuthHandler=h,Object.defineProperty(o,Symbol.toStringTag,{value:"Module"}),o})({});
//# sourceMappingURL=plugin.iife.js.map
@@ -3,6 +3,7 @@
declare(strict_types=1);
return [
'bridge_page_title' => 'Identity verification result',
'title' => 'KG Inicis Identity Verification',
'description' => 'Plugin that connects KG Inicis identity verification service to G7 core IDV infrastructure.',
@@ -3,6 +3,7 @@
declare(strict_types=1);
return [
'bridge_page_title' => '본인인증 결과',
'title' => 'KG이니시스 본인확인',
'description' => 'KG이니시스 통합인증의 본인확인 서비스를 G7 코어 본인인증 인프라에 연결하는 플러그인입니다.',
@@ -17,7 +18,7 @@ return [
// 검증 에러 메시지의 필드 이름용 짧은 라벨 (화면 라벨의 부가 설명 "(SRB 프리픽스)" 제외)
'live_mid_attribute' => '라이브 MID',
'live_api_key_attribute' => '라이브 API 키',
'duplicate_field' => '동일인 식별 기준',
'duplicate_field' => '중복체크 기준',
'duplicate_block_enabled' => [
'label' => '중복 가입 차단',
'description' => '활성화 시 본인인증을 통과한 사람이 이전에 다른 이메일로 가입한 적이 있으면 가입을 거부합니다. 가족 휴대폰 공유 또는 B2B 시나리오 등에서 한 사람이 여러 계정을 가입해야 한다면 비활성화하세요. 이 설정과 무관하게 동일 이메일 재가입은 항상 차단됩니다 (코어 기본 동작).',
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,6 @@
{
"name": "@g7/sirsoft-verification_kginicis",
"version": "1.0.1",
"version": "1.0.2",
"type": "module",
"private": true,
"scripts": {
@@ -5,13 +5,13 @@
"ko": "KG이니시스 본인인증",
"en": "KG Inicis Identity Verification"
},
"version": "1.0.1",
"version": "1.0.2",
"license": "MIT",
"description": {
"ko": "KG이니시스 통합인증의 본인확인(reqSvcCd=03)을 G7 코어 IDV 인프라에 Provider 로 등록하는 플러그인",
"en": "KG Inicis Identity Verification (reqSvcCd=03) provider for G7 core IDV infrastructure"
},
"g7_version": ">=7.0.0",
"g7_version": ">=7.0.6",
"dependencies": {
"modules": {},
"plugins": {}
@@ -0,0 +1,80 @@
/**
* @file handlerReregistration.test.ts
* @description 코어 재초기화(로케일 전환 등) 후 startAuth 핸들러 재등록 회귀 검증
*
* TemplateApp 은 ActionDispatcher 를 새로 만든 뒤
* `window.__[Plugin].initPlugin()` 을 호출해 플러그인이 핸들러를 다시 등록하게 한다.
* 이 이름으로 노출하지 않으면 재초기화 이후 본인확인 시작 버튼이 무반응이 된다
* (핸들러 미등록 → dispatch 무시 → 콘솔 에러도 토스트도 없음).
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { initPlugin, PLUGIN_IDENTIFIER } from '../index';
const HANDLER_NAME = `${PLUGIN_IDENTIFIER}.startAuth`;
/** 코어 ActionDispatcher 를 흉내낸다 — 재초기화 시 새 인스턴스가 만들어진다. */
function createDispatcher() {
const handlers = new Map<string, unknown>();
return {
handlers,
registerHandler: vi.fn((name: string, handler: unknown) => {
handlers.set(name, handler);
}),
};
}
function installG7Core(dispatcher: ReturnType<typeof createDispatcher>): void {
(window as any).G7Core = {
getActionDispatcher: () => dispatcher,
};
}
describe('코어 재초기화 시 핸들러 재등록', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
delete (window as any).G7Core;
vi.restoreAllMocks();
});
it('window 전역에 initPlugin 을 노출한다 (코어가 이 이름으로만 재호출한다)', () => {
const exposed = (window as any).__SirsoftVerificationKginicis;
expect(exposed).toBeDefined();
expect(typeof exposed.initPlugin).toBe('function');
});
it('새 ActionDispatcher 에 startAuth 핸들러를 다시 등록한다', () => {
const first = createDispatcher();
installG7Core(first);
initPlugin();
expect(first.handlers.has(HANDLER_NAME)).toBe(true);
// 로케일 전환 — 코어가 ActionDispatcher 를 새로 만든다 (기존 핸들러 전부 소실)
const second = createDispatcher();
installG7Core(second);
expect(second.handlers.has(HANDLER_NAME)).toBe(false);
initPlugin();
expect(second.handlers.has(HANDLER_NAME)).toBe(true);
expect(typeof second.handlers.get(HANDLER_NAME)).toBe('function');
});
it('재초기화 시점에 ActionDispatcher 가 아직 없으면 준비될 때까지 재시도한다', () => {
(window as any).G7Core = undefined;
initPlugin();
const dispatcher = createDispatcher();
installG7Core(dispatcher);
vi.advanceTimersByTime(100);
expect(dispatcher.handlers.has(HANDLER_NAME)).toBe(true);
});
});
@@ -334,7 +334,10 @@ function registerHandlers(): boolean {
return true;
}
function init(): void {
/**
* ActionDispatcher 가 준비될 때까지 재시도하며 핸들러를 등록한다.
*/
function registerHandlersWithRetry(): void {
if (registerHandlers()) return;
let retries = 0;
@@ -349,6 +352,22 @@ function init(): void {
}, 100);
}
/**
* 코어 재초기화 시 호출되는 진입점.
*
* 로케일 전환 등으로 TemplateApp 이 ActionDispatcher 를 새로 만들면
* `TemplateApp.reinitializePluginHandlers()` 가 `window.__[Plugin].initPlugin()` 을 호출한다.
* 이 이름으로 노출하지 않으면 재초기화 후 startAuth 핸들러가 소실되어
* 본인확인 시작 버튼이 무반응이 된다.
*/
function initPlugin(): void {
registerHandlersWithRetry();
}
function init(): void {
registerHandlersWithRetry();
}
// 테스트 환경에서는 vitest 가 jsdom 으로 window 를 제공하지만 G7Core 를 직접 mock 하므로
// 자동 init 을 건너뛰도록 한다 (`import.meta.env.MODE === 'test'` 시).
if (typeof import.meta === 'undefined' || (import.meta as any).env?.MODE !== 'test') {
@@ -358,8 +377,10 @@ if (typeof import.meta === 'undefined' || (import.meta as any).env?.MODE !== 'te
(window as any).__SirsoftVerificationKginicis = {
identifier: PLUGIN_IDENTIFIER,
init,
// 코어 재초기화 시 핸들러 재등록 진입점 — 이름 고정 (TemplateApp.reinitializePluginHandlers)
initPlugin,
startAuthHandler,
};
// 테스트 / 외부 도구가 import 로 직접 호출할 수 있도록 named export 도 노출
export { startAuthHandler, init, PLUGIN_IDENTIFIER, PROVIDER_ID, MODAL_ID };
export { startAuthHandler, init, initPlugin, PLUGIN_IDENTIFIER, PROVIDER_ID, MODAL_ID };
@@ -9,7 +9,7 @@
"description": "KG이니시스 통합인증의 본인확인 서비스를 G7 코어 본인인증 인프라에 연결하는 플러그인입니다.",
"settings": {
"title": "KG이니시스 본인확인 설정",
"description": "운영 모드와 라이브 자격증명, 동일인 식별 기준을 설정합니다.",
"description": "운영 모드와 라이브 자격증명, 중복 가입 차단 기준을 설정합니다.",
"info": {
"activation": "본인인증 활성화는 [환경설정 > 본인인증] 에서 설정합니다.",
"logs": "이니시스 인증 이력은 [관리자 > 본인인증 이력] 에서 'KG이니시스 본인확인' 으로 필터하여 조회할 수 있습니다."
@@ -23,13 +23,13 @@
"live_mid_hint": "이니시스 라이브 가맹점 MID 는 SRB 로 시작합니다 (예: SRB1234567). 운영 모드 사용 전 필수 입력.",
"live_api_key": "라이브 API 키",
"section": {
"duplicate_field": "동일인 식별"
"duplicate_field": "중복 가입 차단"
},
"fields": {
"duplicate_field": {
"label": "중복체크 기준",
"hint": "DI 는 본 가맹점에서만 동일인 식별, CI 는 모든 기관에서 공통 식별 가능합니다. (기본값: DI)",
"warning": "운영 중 변경 시 기존 가입자의 동일인 체크가 일시적으로 불완전해질 수 있습니다."
"hint": "DI 는 본 가맹점에서만 중복 가입 여부를 확인하고, CI 는 모든 기관에서 공통으로 식별할 수 있습니다. (기본값: DI)",
"warning": "운영 중 변경 시 기존 가입자의 중복 체크가 일시적으로 불완전해질 수 있습니다."
},
"duplicate_block_enabled": {
"label": "중복 가입 차단",
@@ -40,10 +40,10 @@
}
},
"duplicate_field": {
"di": "DI (가맹점 종속 동일인 식별값)",
"ci": "CI (전 기관 공통 동일인 식별값)",
"hint": "DI 는 본 가맹점에서만 동일인 식별, CI 는 모든 기관에서 공통 식별 가능합니다.",
"warning": "운영 중 변경 시 기존 가입자의 동일인 체크가 일시적으로 불완전해질 수 있습니다."
"di": "DI (가맹점 종속 고유값)",
"ci": "CI (전 기관 공통 고유값)",
"hint": "DI 는 본 가맹점에서만 중복 가입 여부를 확인하고, CI 는 모든 기관에서 공통으로 식별할 수 있습니다.",
"warning": "운영 중 변경 시 기존 가입자의 중복 체크가 일시적으로 불완전해질 수 있습니다."
}
},
"modal": {
@@ -21,6 +21,10 @@ use Illuminate\Http\Response;
*
* @since 1.0.0-beta.1
*/
// audit:allow api-doc-coverage reason: 이 컨트롤러는 인증창 결과를 부모 창으로 넘기는
// web 브리지 페이지이며 API 표면이 아니다(docs/api 에 수록된 엔드포인트가 아님).
// 이번 변경은 HTML <title>/lang 을 현재 로케일로 맞춘 것으로 요청·응답 계약이 그대로다
// (api:docgen --check 결과 drift 없음).
class InicisPopupBridgeController extends PublicBaseController
{
/**
@@ -62,13 +66,18 @@ class InicisPopupBridgeController extends PublicBaseController
*/
protected function renderBridgeHtml(string $payloadJson): string
{
// 즉시 닫히는 중계 페이지지만, 로딩이 지연되면 브라우저 탭 제목으로 노출된다.
// 문서 언어와 제목을 현재 로케일에 맞춘다.
$locale = e(app()->getLocale());
$title = e(__('sirsoft-verification_kginicis::messages.bridge_page_title'));
return <<<HTML
<!DOCTYPE html>
<html lang="ko">
<html lang="{$locale}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>본인인증 결과</title>
<title>{$title}</title>
</head>
<body>
<script>
@@ -60,21 +60,21 @@ class ValidateInicisSettingsListener implements HookListenerInterface
*
* @param array<string, mixed> $rules 코어가 스키마로 생성한 검증 규칙
* @param string $identifier 검증 대상 플러그인 식별자
* @param array<string, mixed> $input 이번 요청의 입력값 (코어가 전달)
* @return array<string, mixed> 조정된 검증 규칙
*/
public function addLiveModeRules(array $rules, string $identifier): array
public function addLiveModeRules(array $rules, string $identifier, array $input = []): array
{
if ($identifier !== self::IDENTIFIER) {
return $rules;
}
// is_test_mode 가 명시적으로 false 일 때만 라이브 모드로 간주.
// 본 필터 훅(core.plugin_settings.update_rules)은 코어 UpdatePluginSettingsRequest 가
// "현재 요청의 is_test_mode 에 따른 조건부 검증 규칙"을 만들도록 발행하는 확장점이므로,
// 현재 입력 모드 참조가 본질적으로 필요하다 (FormRequest 우회가 아님 — 코어 검증기 자체의 입력).
// is_test_mode 가 명시적으로 false 일 때만 라이브 모드로 간주한다.
// 입력값은 코어 UpdatePluginSettingsRequest 가 본 필터의 3번째 인자로 넘겨준다 —
// "현재 입력한 모드에 따라 필수 항목이 달라지는" 조건부 규칙을 만들기 위한 값이며,
// 확장이 request() 를 직접 들여다볼 필요가 없다.
$isTestMode = filter_var(
// audit:allow listener-formrequest-bypass reason: 검증 규칙 필터 훅의 의도된 입력 모드 참조
request()->input('is_test_mode', true),
$input['is_test_mode'] ?? true,
FILTER_VALIDATE_BOOLEAN,
FILTER_NULL_ON_FAILURE
);
@@ -3,13 +3,14 @@
namespace Plugins\Sirsoft\VerificationKginicis\Tests\Feature\Listeners;
use App\Contracts\Extension\CacheInterface;
use App\Extension\Cache\PluginCacheDriver;
use App\Contracts\Repositories\IdentityVerificationLogRepositoryInterface;
use App\Enums\IdentityOriginType;
use App\Enums\IdentityVerificationStatus;
use App\Extension\Cache\PluginCacheDriver;
use App\Extension\HookManager;
use App\Models\IdentityVerificationLog;
use App\Models\User;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Str;
use Plugins\Sirsoft\VerificationKginicis\Identity\InicisIdentityProvider;
use Plugins\Sirsoft\VerificationKginicis\Repositories\InicisIdentityRecordRepositoryInterface;
@@ -50,6 +51,11 @@ class CompleteInicisRecordAfterRegisterTest extends PluginTestCase
$this->cache = new PluginCacheDriver('sirsoft-verification_kginicis');
}
/**
* @scenario case=all_present
*
* @effects success_with_record_stored
*/
public function test_listener_upserts_record_backfills_user_id_and_clears_cache_on_after_register_hook(): void
{
// 1. 비로그인 사용자가 이니시스 본인확인 완료한 상태 시뮬레이션 — log + verification_token + Cache PII stash
@@ -257,10 +263,10 @@ class CompleteInicisRecordAfterRegisterTest extends PluginTestCase
$this->cache->put(
InicisIdentityProvider::PENDING_RECORD_CACHE_PREFIX.$logId,
[
'name_encrypted' => \Illuminate\Support\Facades\Crypt::encryptString('홍길동'),
'phone_encrypted' => \Illuminate\Support\Facades\Crypt::encryptString('01012345678'),
'birthday_encrypted' => \Illuminate\Support\Facades\Crypt::encryptString('19900101'),
'di_encrypted' => \Illuminate\Support\Facades\Crypt::encryptString('DI-VAL'),
'name_encrypted' => Crypt::encryptString('홍길동'),
'phone_encrypted' => Crypt::encryptString('01012345678'),
'birthday_encrypted' => Crypt::encryptString('19900101'),
'di_encrypted' => Crypt::encryptString('DI-VAL'),
'di_hash' => hash('sha256', 'DI-VAL'),
'ci_encrypted' => null,
'ci_hash' => null,
@@ -1,3 +1,5 @@
# audit:allow scenario-referenced-test-file-marked reason: test_files 의 코어 엔진 테스트 2본(IdentityGuardInterceptor / ActionDispatcher.identityGuardToastSuppress)은 provider 비종속 인프라 회귀 가드로 참조된 것이며, 본 매니페스트의 조합 축(purpose × is_adult)을 소유하지 않는다. 플러그인 소유 테스트가 전 조합·effects 를 마킹으로 커버한다.
feature: KG이니시스 본인인증 — 성인인증 미성년자 차단 + verify 실패 안내 toast
description: |

Some files were not shown because too many files have changed in this diff Show More