fix(core,admin_basic,board,ecommerce,page,gdpr,verification): 설정 미러·자동 정리·탈퇴 원자성·일괄 선택 범위 정비
공개이슈 ··· 및 그 전수 해소분. - 설정 저장·복원·초기화가 상주 프로세스 미러를 갱신하도록 단일 소유자화 - 만료 데이터 자동 정리 예약 12종 신설, 파기는 기본키 배치로 분할 - 회원 탈퇴를 전 경로 원자화하고 관리자 상태변경 경로를 정식 탈퇴로 통일 - 일괄 처리 목록의 선택을 화면에 보이는 행으로 한정(selectionScope) - 비밀번호 정책 문구를 도메인 중립 라벨로 정정 - 정적 검사 2종·검증 스킬 설계 정합성 축 신설
This commit is contained in:
@@ -94,6 +94,40 @@ g7_core_settings('drivers.queue_driver'); // dev 공유 drivers.json 의 datab
|
||||
|
||||
---
|
||||
|
||||
## config 미러 갱신 시점 (부팅 + 저장)
|
||||
|
||||
`g7_settings.core` / `g7_settings.modules.{id}` / `g7_settings.plugins.{id}` 는 in-memory config 미러다. `g7_core_settings()` / `g7_module_settings()` / `g7_plugin_settings()` 가 이 미러를 읽는다.
|
||||
|
||||
미러는 **부팅 때와 저장 때** 모두 채워진다. 채움 로직은 `App\Support\ExtensionSettingsMirror` 가 단일 소유하며, Provider 와 저장 경로가 같은 코드를 호출한다.
|
||||
|
||||
| 축 | 채우는 지점 |
|
||||
|----|-----------|
|
||||
| 코어 | 부팅 `SettingsServiceProvider::register()` / 저장 `SettingsService::invalidateSettingsCache()` |
|
||||
| 모듈 | 부팅 `CoreServiceProvider` / 저장 각 모듈 SettingsService 의 캐시 초기화 지점에서 `g7_refresh_module_settings_config($id)` |
|
||||
| 플러그인 | 부팅 `CoreServiceProvider` / 저장 `PluginSettingsService::save()` |
|
||||
|
||||
부팅만으로 채우면 FPM 에서는 요청마다 재부팅되어 드러나지 않지만, 큐 워커·`schedule:work`·Reverb 처럼 프로세스가 상주하는 환경에서는 저장 후에도 그 프로세스가 옛 값을 영원히 읽는다. 새 저장 경로를 만들면 그 자리에서 미러 재채움을 함께 호출한다.
|
||||
|
||||
> 다중 상주 워커 간 브로드캐스트 동기화는 범위 밖이다 — 저장을 수행한 프로세스와 이후 새로 부팅되는 프로세스가 정합하면 충분하고, 워커 재기동은 드라이버 탭의 큐 재시작이 담당한다.
|
||||
|
||||
### 미러는 민감 항목을 담지 않는다 — 민감값은 전용 게터로
|
||||
|
||||
스키마에 `sensitive: true` 로 선언된 필드는 미러에서 **키 자체가 제거**된다 (암호문도 싣지 않는다).
|
||||
|
||||
미러는 봇 전용 화면 생성기가 레이아웃 표현식에서 참조한 키를 **제한 없이** 조회하는 경로다. 민감값이 실려 있으면 레이아웃이 그 키를 참조하는 순간 평문이 봇 HTML 로 나간다. 브라우저용 설정 전달 경로는 애초에 민감 항목을 제외하므로 화면만 봐서는 드러나지 않는 비대칭이다.
|
||||
|
||||
민감값이 필요한 서버 코드는 전용 게터를 쓴다.
|
||||
|
||||
```php
|
||||
// 표시·분기용 (민감 항목 제외)
|
||||
g7_plugin_settings('sirsoft-gdpr', 'duplicate_block_enabled');
|
||||
|
||||
// 민감값 (복호화 포함)
|
||||
plugin_setting('sirsoft-pay_kginicis', 'api_key');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 새 admin 환경설정 키 추가 시 점검
|
||||
|
||||
새 카테고리/키를 `storage/app/settings/*.json` 에 추가할 때:
|
||||
|
||||
@@ -62,6 +62,30 @@ _이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지
|
||||
|
||||
현재 로그인한 사용자가 자신의 계정을 탈퇴한다. 프론트의 회원 탈퇴 모달(`_modal_withdraw.json`)이 호출한다. 별도 요청 파라미터 없이 인증 토큰의 사용자를 대상으로 하며, `UserService::withdrawUser()` 가 아바타·토큰 삭제와 개인정보 익명화를 수행한다. 되돌릴 수 없는 작업이므로 호출 전 사용자 확인 절차를 두는 것을 권장한다.
|
||||
|
||||
탈퇴는 원자적으로 처리된다 — 약관 동의 이력 삭제·토큰 삭제·익명화가 하나의 트랜잭션이며, 어느 단계에서 실패해도 전부 취소된다. 아바타 파일 삭제는 커밋이 확정된 뒤에 수행되고, 파일 삭제가 실패해도 탈퇴는 되돌리지 않는다(참조되지 않는 파일만 남는다).
|
||||
|
||||
익명화된 이메일에는 사용자 ID 가 포함되어 구조적으로 유일하다 — 같은 이메일로 재가입한 회원이 같은 날 다시 탈퇴해도 충돌하지 않는다.
|
||||
|
||||
**추가 오류 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 422 | Validation Error | 관리자 역할 보유 계정 또는 수퍼관리자 계정의 탈퇴 시도 (`errors.general` 에 사유) |
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"message": "회원 탈퇴에 실패했습니다.",
|
||||
"errors": {
|
||||
"general": [
|
||||
"관리자 계정은 탈퇴할 수 없습니다."
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> 차단 응답은 종전에 500 이었으나, 잘못된 요청이지 서버 오류가 아니므로 422 로 바뀌었다. 활동 로그도 탈퇴가 성공한 경우에만 기록된다.
|
||||
|
||||
|
||||
### GET /api/me
|
||||
<!-- @generated:start:api.me.show -->
|
||||
|
||||
@@ -428,6 +428,8 @@ _단건 응답: `data` 객체의 필드 (UserService::bulkUpdateStatus() 반환
|
||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| updated_count | integer | `3` | 실제로 상태가 변경된 사용자 수 (요청자 본인 제외 규칙 적용 후 갱신된 행 수) |
|
||||
| failed_count | integer | `1` | 처리하지 못한 사용자 수 (탈퇴 전환에서 관리자·수퍼관리자 등 차단 대상이 섞인 경우). 탈퇴 외 상태 전환은 항상 `0` |
|
||||
| failed_reasons | array\<string\> | `["관리자 계정은 탈퇴할 수 없습니다."]` | 처리하지 못한 사유 (중복 제거). 탈퇴 외 상태 전환은 항상 `[]` |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
@@ -440,7 +442,9 @@ HTTP/1.1 200
|
||||
"success": true,
|
||||
"message": ":count명의 사용자 상태가 변경되었습니다.",
|
||||
"data": {
|
||||
"updated_count": 3
|
||||
"updated_count": 3,
|
||||
"failed_count": 0,
|
||||
"failed_reasons": []
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -462,6 +466,10 @@ HTTP/1.1 200
|
||||
|
||||
여러 사용자의 계정 상태를 한 번에 변경합니다. `ids` 는 대상 사용자 UUID 배열이며, `status` 는 `active`/`inactive`/`blocked`/`withdrawn`/`pending_verification`(UserStatus Enum 값) 중 하나이다. `ExcludeCurrentUser` 규칙으로 요청자 본인은 대상에서 제외된다. 목록 화면의 다중 선택 후 일괄 상태 변경에 사용한다.
|
||||
|
||||
`status=withdrawn` 요청은 상태 컬럼만 바꾸지 않고 **건별로 정식 탈퇴 처리**를 수행한다 (익명화 + 연계 데이터 정리 + 탈퇴 훅). 회원이 직접 탈퇴한 경우와 결과가 같다. 관리자 역할을 가진 대상은 건너뛰고 `failed_count` 로 집계되며, 나머지 대상의 처리는 계속된다.
|
||||
|
||||
전건이 차단되어도 응답은 200 이다. 소비자는 `updated_count` 와 `failed_count` 를 함께 읽어 결과를 판정해야 한다 — 200 만 보고 성공으로 안내하면 아무 일도 일어나지 않은 요청이 처리된 것으로 표시된다. 사유는 `failed_reasons` 로 전달되므로 그대로 노출하면 된다.
|
||||
|
||||
|
||||
### POST /api/admin/users/check-email
|
||||
<!-- @generated:start:api.admin.users.check-email -->
|
||||
@@ -1003,7 +1011,17 @@ _이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: http-422 — 응답 예시는 사람이 작성하세요. -->
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "사용자가 삭제되었습니다.",
|
||||
"data": null
|
||||
}
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
@@ -1020,6 +1038,8 @@ _이 엔드포인트는 `data` 를 반환하지 않습니다 (성공 메시지
|
||||
|
||||
지정한 사용자(경로 파라미터는 UUID 로 바인딩)를 삭제합니다. 슈퍼관리자 계정은 삭제할 수 없으며 시도 시 422(`exceptions.cannot_delete_super_admin`)를 반환한다. 그 외 삭제 실패 시에는 실패 상세 사유가 담긴 422 를, 나머지 오류는 500 을 반환한다. 삭제는 Service 계층에서 관련 데이터 정리와 훅을 거쳐 처리된다.
|
||||
|
||||
삭제는 원자적으로 처리된다 — 역할 연결 해제·약관 동의 이력 삭제·토큰 삭제·계정 삭제가 하나의 트랜잭션이며, 마지막 삭제가 외래키 제약 등으로 실패하면 앞 단계도 전부 취소된다(연결만 끊긴 활성 계정이 남지 않는다). 아바타 파일 삭제는 커밋 후에 수행된다.
|
||||
|
||||
|
||||
### GET /api/admin/users/{user}
|
||||
<!-- @generated:start:api.admin.users.show -->
|
||||
@@ -1091,7 +1111,7 @@ _단건 응답: `data` 객체의 필드._
|
||||
| withdrawn_at | null | `null` | withdrawn 일시 |
|
||||
| blocked_at | null | `null` | blocked 일시 |
|
||||
| failed_login_attempts | integer | `0` | 연속 로그인 실패 횟수 |
|
||||
| locked_permanently | boolean | `false` | <!-- TODO: 설명 --> |
|
||||
| locked_permanently | boolean | `false` | 무기한 잠금 여부 (보안 설정의 잠금 시간이 `0` 이면 자동 해제 없이 관리자가 직접 풀어야 한다) |
|
||||
| locked_until | null | `null` | 계정 잠금 해제 시각 (NULL = 잠금 없음) |
|
||||
| is_locked | boolean | `false` | locked 여부 |
|
||||
| notify_post_complete | boolean | `false` | 게시글 작성 완료 알림 수신 여부 (게시판 모듈 알림 설정) |
|
||||
|
||||
@@ -125,6 +125,14 @@ core.attachment.delete
|
||||
# 컨텍스트 6키는 아래 "스토리지·드라이버 확장 훅 페이로드" 표 참조
|
||||
core.storage.filter_url
|
||||
|
||||
# 보존 기간 자동 파기 (7.0.7) — 운영자 일괄 삭제 훅과 별개로 발행한다.
|
||||
# 운영자 삭제 훅에는 본인인증 같은 대화형 가드가 물려 있어 무인 예약이 탈 수 없고,
|
||||
# 그렇다고 훅 없이 지우면 확장이 가장 큰 삭제 경로(첫 실행의 누적분)를 볼 수 없다.
|
||||
# 인자는 (보존일) / (보존일, 삭제건수) — 대상을 ID 로 지목하지 않는다.
|
||||
core.activity_log.before_prune core.activity_log.after_prune
|
||||
core.notification_log.before_prune core.notification_log.after_prune
|
||||
core.schedule.before_prune_history core.schedule.after_prune_history
|
||||
|
||||
# 업로드 트라이어드 — 사용자 첨부 업로드 지점의 표준 3훅 패턴
|
||||
# before_upload(액션) → filter_upload_file(필터: UploadedFile 을 받아 변형본을 반환.
|
||||
# 저장 파일명·MIME·크기가 모두 반환 파일 기준이 된다) → after_upload(액션)
|
||||
|
||||
@@ -527,6 +527,38 @@ $timing = $this->settingsService->getStockDeductionTiming($method);
|
||||
|
||||
> **판단 기준**: `getSetting()` 단일 호출만 필요하면 `module_setting()` 헬퍼 사용, 모듈 전용 메서드가 필요하면 서비스 직접 주입
|
||||
|
||||
### 8.4 저장하면 config 미러를 다시 채운다
|
||||
|
||||
`g7_module_settings()` 가 읽는 값은 in-memory config 미러(`g7_settings.modules.{id}`)다. 이 미러는 부팅 때 채워지므로, 저장 경로가 아무것도 하지 않으면 **그 프로세스는 저장 후에도 옛 값을 계속 읽는다.**
|
||||
|
||||
php-fpm 은 요청마다 부팅해 문제가 드러나지 않는다. 큐 워커 · `schedule:work` · Reverb 처럼 프로세스가 상주하는 환경에서만 나타나고, 예외도 경고도 로그도 남지 않는다 — 운영자는 저장했는데 배경 작업만 옛 설정으로 도는 상태가 된다.
|
||||
|
||||
모듈 설정 저장에는 코어 공통 지점이 없다(각 모듈의 설정 서비스가 직접 저장한다). 그래서 **자기 캐시를 비우는 모든 자리에서** 미러 재채움을 함께 호출한다.
|
||||
|
||||
```php
|
||||
public function saveSettings(array $settings): bool
|
||||
{
|
||||
// ... 저장 ...
|
||||
|
||||
$this->settings = null; // 자기 캐시 비우기
|
||||
g7_refresh_module_settings_config('vendor-module'); // config 미러 다시 채우기
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function clearCache(): void
|
||||
{
|
||||
$this->settings = null;
|
||||
g7_refresh_module_settings_config('vendor-module');
|
||||
}
|
||||
```
|
||||
|
||||
호출 지점은 저장 메서드 하나가 아니다 — 초기화·복원·부분 저장 등 **값을 바꾸는 자리 전부**다. 한 곳만 빠지면 그 경로로 저장했을 때만 미러가 옛 값으로 남는다.
|
||||
|
||||
- 이 헬퍼는 코어 7.0.7 부터 제공된다. 사용하는 모듈은 `module.json` 의 `requires.g7_version` 을 그에 맞춰 올린다.
|
||||
- 스키마에 `sensitive: true` 로 선언한 필드는 미러에 담기지 않는다. 민감값은 자체 설정 서비스(복호화 경로)로 읽는다.
|
||||
- 배경과 코어 축(코어/플러그인)의 처리는 [admin-settings-access.md](../backend/admin-settings-access.md) "config 미러 갱신 시점" 참조.
|
||||
|
||||
---
|
||||
|
||||
## 9. 카탈로그 병합 설정의 공개 응답
|
||||
@@ -641,3 +673,4 @@ GET /api/modules/{id}/settings/payment # 고아 제외 (정상)
|
||||
- [ ] 레이아웃에 `data_sources` 추가
|
||||
- [ ] 저장 버튼 액션 연결
|
||||
- [ ] `frontend_schema`로 민감정보 필터링 설정
|
||||
- [ ] 값을 바꾸는 모든 자리에서 `g7_refresh_module_settings_config()` 호출 (8.4)
|
||||
|
||||
@@ -367,6 +367,7 @@ export interface DataGridProps {
|
||||
rowKey?: string; // 행 고유 키 필드 (기본: 'id')
|
||||
selectable?: boolean; // 행 선택 기능
|
||||
selectedRows?: any[]; // 선택된 행 배열
|
||||
selectionScope?: 'page' | 'free'; // 선택의 유효 범위 (아래 "선택의 유효 범위" 참조)
|
||||
loading?: boolean; // 로딩 상태
|
||||
className?: string; // CSS 클래스
|
||||
|
||||
@@ -394,9 +395,43 @@ export interface DataGridProps {
|
||||
| `rowKey` | `string` | ❌ | `'id'` | 행 고유 키로 사용할 필드명 |
|
||||
| `selectable` | `boolean` | ❌ | `false` | 체크박스 선택 기능 활성화 |
|
||||
| `selectedRows` | `any[]` | ❌ | `[]` | 선택된 행 배열 (ID 배열) |
|
||||
| `selectionScope` | `'page' \| 'free'` | ❌ | `'free'` | 선택의 유효 범위 (아래 참조) |
|
||||
| `loading` | `boolean` | ❌ | `false` | 로딩 상태 표시 |
|
||||
| `className` | `string` | ❌ | - | CSS 클래스 |
|
||||
|
||||
### 선택의 유효 범위 (selectionScope)
|
||||
|
||||
체크박스 선택은 컴포넌트 밖(전역/로컬 상태)에 저장된다. 그래서 검색·필터·페이지 이동으로 행이 목록에서 빠져도 그 행의 선택은 남는다. 그 상태에서 일괄 처리를 실행하면 사용자가 보고 있지도, 체크하지도 않은 행이 대상이 된다 — 확인 모달은 건수만 말하므로 실행 전에 알아챌 방법이 없고, 처리는 정상 성공하므로 오류도 남지 않는다.
|
||||
|
||||
`onSelectionChange` 를 배선하는 DataGrid 는 둘 중 하나를 반드시 명시한다 (기본값에 기대지 않는다).
|
||||
|
||||
| 값 | 동작 | 쓰는 곳 |
|
||||
|----|------|--------|
|
||||
| `"page"` | 지금 그려진 행에 없는 선택을 버린다. 대상은 언제나 "보이고 체크된 행" | 일괄 처리 버튼이 달린 목록 화면 |
|
||||
| `"free"` | 선택을 컴포넌트가 건드리지 않는다 | 선택 자체가 저장 대상인 폼(적용 대상 고르기 등) — 여러 페이지에 걸쳐 고르는 선택기 포함 |
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "composite",
|
||||
"name": "DataGrid",
|
||||
"props": {
|
||||
"selectable": true,
|
||||
"selectionScope": "page",
|
||||
"selectedIds": "{{_global.selectedIds_admin_users || []}}"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"event": "onSelectionChange",
|
||||
"type": "change",
|
||||
"handler": "setState",
|
||||
"params": { "target": "global", "selectedIds_admin_users": "{{$args[0]}}" }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
값은 리터럴로 고정한다. 표현식으로 분기하면 분기마다 보존 여부가 갈려 한쪽이 조용히 대상 밖 행을 싣는다. `selectable` 이 꺼진 화면이라고 안전한 것도 아니다 — 체크박스가 없으면 남은 선택이 더 안 보이므로, 범위 판정은 `selectable` 과 무관하게 적용된다.
|
||||
|
||||
### SubRow Props (engine-v1.6.0+)
|
||||
|
||||
행 아래에 추가 정보를 표시하는 서브 행 기능입니다.
|
||||
|
||||
Reference in New Issue
Block a user