fix(core,board,ecommerce): 조건부 검증 결함 패밀리 일괄 수정 및 실패 안내 노출

여러 화면에서 조건부(FormRequest) 검증이 우회되거나 과도하게 제한되던 결함을 한 계열로 묶어 수정한다.

- 검증 규칙 확장 필터 이름 10개를 나머지 92개와 동일 규칙으로 통일
- 설명 길이를 바이트가 아닌 글자 수로 계산(한글 오검출 해소)
- 비활성 언어팩 번역 보존한 채 수정 허용
- 알림 템플릿 제목 nullable 존중, 미지원 발송 수단 저장 차단
- 일정/실행 이력 목록 조회 검증 정합
- 게시판 첨부 미사용 시 빈 확장자 통과, new_display_hours/최소 길이 0 허용, 일괄 적용 override_values 범위 검증(SSoT)
- 이커머스 클레임 사유 조회 검증, 용어 정정
- 원문 i18n 키 노출 다수 해소, 저장/삭제 실패 시 사유 화면 노출
- 아이콘 크기/다크모드 대비 회귀 수정

번들 확장(모듈3·플러그인6·템플릿2·언어팩5) 버전 new-bump 및 CHANGELOG 반영, ja 언어팩 동기.
This commit is contained in:
HeuJung
2026-07-23 10:54:17 +09:00
parent c001b30a87
commit 7f8fb6870f
288 changed files with 5339 additions and 1049 deletions
+9 -1
View File
@@ -153,7 +153,7 @@
| 코어 | [docs/backend/api/README.md](docs/backend/api/README.md) | 35 / 291 |
### 확장 API 레퍼런스 (9개 확장, 자동 스캔)
### 확장 API 레퍼런스 (11개 확장, 자동 스캔)
> 각 확장이 소유하는 API 문서 목차. `php artisan api:docgen` 이 생성하며, 이 표는 `{modules,plugins}/_bundled/*/docs/api/README.md` 를 패턴 스캔해 자동 편입된다(확장명 하드코딩 없음).
@@ -167,6 +167,8 @@
| `sirsoft-gdpr` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-gdpr/docs/api/README.md) | 4 / 15 |
| `sirsoft-marketing` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-marketing/docs/api/README.md) | 2 / 2 |
| `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 |
@@ -261,6 +263,8 @@ Icon 은 `<i>` 글리프라 박스 크기가 곧 `font-size` 다. `w-N h-N` 은
| `w-3.5 h-3.5` | 14 | `text-sm` |
| `w-4 h-4` | 16 | `text-base` |
| `w-5 h-5` | 20 | `text-xl` |
| `w-6 h-6` | 24 | `text-2xl` |
| `w-12 h-12` | 48 | `text-5xl` |
`size` prop 은 Font Awesome `fa-*` 클래스로 매핑되며 등가가 아니다 — `size="sm"` → `fa-sm` → `font-size: 0.875em`(상대값) + `line-height` 붕괴로 16px 이 12.25×0.88px 이 된다. 새 아이콘에는 써도 되지만, 기존 `w-N h-N` 의 치환용으로는 쓰지 않는다.
@@ -807,14 +811,18 @@ public function createProduct(array $data): Product
| `*.json` (레이아웃만) | `{type}:update {id} --force` 실행 |
| `*.tsx`, `*.ts` + `*.json` | `{type}:build` + `{type}:update {id} --force` |
| `*.tsx`, `*.ts`만 | `{type}:build` + `{type}:update {id} --force` |
| `lang-packs/_bundled/**` (번들 언어팩 콘텐츠/버전) | `language-pack:update {id} --force` (빌드 불필요) |
```bash
# 확장 업데이트 (_bundled → 활성 반영)
php artisan template:update sirsoft-admin_basic --force
php artisan module:update sirsoft-ecommerce --force
php artisan plugin:update sirsoft-payment --force
php artisan language-pack:update g7-core-ja --force
```
번들 언어팩도 `_bundled` 는 배포 원본일 뿐이다. 설치본(`lang-packs/{id}/`)을 갱신하지 않으면 새로 추가한 번역 키가 런타임에 존재하지 않아 해당 로케일이 조용히 기준 로케일로 폴백한다.
### 코어 3-번들 구조 + 공유 런타임 (engine-v1.51.0+)
`core:build` 는 코어 프론트엔드를 3개 IIFE 번들로 빌드한다:
+12
View File
@@ -20,10 +20,22 @@
- 설치할 때 `root` 같은 데이터베이스 최고 권한 계정을 입력하면 설치가 진행되지 않고, 왜 사용할 수 없는지와 어떻게 해야 하는지를 함께 안내합니다. 최고 권한 계정 정보가 유출되면 데이터베이스 전체가 위험해지기 때문이며, 사이트 전용 데이터베이스 계정을 새로 만들어 필요한 권한만 부여해 입력하시면 됩니다. 읽기 전용 데이터베이스를 따로 쓰는 경우에도 동일하게 적용됩니다.
- 서버에 OPcache가 켜져 있는지를 설치 화면과 관리자 환경설정 > 정보에서 확인할 수 있습니다. OPcache는 PHP 코드 해석 결과를 재사용해 사이트 응답 속도를 크게 높여 주는 기능으로, 꺼져 있으면 성능이 떨어진다는 안내와 함께 설정 방법을 알려 드립니다. 꺼져 있어도 설치는 그대로 진행되므로 설치가 막히지 않습니다.
### Changed
- 확장이 검증 규칙을 추가할 때 쓰는 필터 이름 10개를 나머지 92개와 같은 규칙(`{대상}.{동작}_validation_rules`)으로 통일했습니다. 본인인증 메시지와 알림 관련 화면이 대상이며, 예전 이름(`...filter_index_rules` 형태)을 사용하던 확장은 새 이름으로 바꿔야 검증 규칙 추가가 계속 동작합니다. 코어와 기본 제공 확장에는 이 이름을 사용하던 곳이 없어 영향이 없습니다.
### Fixed
- 데이터베이스 계정 설정이 잘못되었을 때 아무 설명 없이 빈 화면만 뜨던 문제를 수정했습니다. 이전에는 최고 권한 계정을 쓰거나 사용자명이 비어 있으면 사이트 기능이 통째로 동작을 멈추면서도 화면에는 아무 안내가 없어 원인을 알 수 없었습니다. 이제 무엇이 잘못되었고 어떻게 고쳐야 하는지 설명하는 전용 안내 화면이 나타납니다. 서버 관리 명령은 평소대로 실행되므로 이 상태에서도 설정을 고칠 수 있습니다.
- SEO 설정의 캐시 항목(SEO 캐시 사용·유지 시간, Sitemap 캐시 유지 시간)이 저장해도 적용되지 않던 문제를 수정했습니다. 이제 고급 설정의 캐시 값이 기준이 되고, SEO 설정에서 값을 지정하면 그 값이 우선하며, 비워두면 고급 설정을 따릅니다. 업그레이드 시 기존에 기본값과 다르게 지정해 두셨던 값은 그대로 살아나 이제부터 실제로 적용되므로 SEO 캐시 동작이 달라질 수 있습니다. 기본값 그대로였던 항목은 "지정 안 함"으로 정리되어 동작이 바뀌지 않습니다.
- 한글로 쓴 설명이 글자 수 제한에 걸리지 않는데도 "너무 깁니다" 로 거부되던 문제를 수정했습니다. 글자 수를 바이트 단위로 세는 바람에 한글 한 글자가 세 글자로 계산되어, 500자 제한에서 167자만 넘어도 저장이 막혔습니다. 역할·권한·모듈·플러그인·템플릿 설명에 모두 해당합니다.
- 사용하던 언어팩을 끈 뒤 기존에 작성해 둔 내용을 수정하려 하면 저장이 통째로 막히던 문제를 수정했습니다. 꺼진 언어로 입력해 둔 번역이 남아 있으면 "지원하지 않는 언어" 로 거부되었습니다. 이제 그 번역은 그대로 보존한 채 수정할 수 있고, 언어팩을 다시 켜면 번역도 함께 되살아납니다.
- 알림 템플릿에서 제목을 비워 둘 수 있는데도 저장할 때 제목을 요구하던 문제를 수정했습니다. 제목 개념이 없는 발송 수단에서도 템플릿을 저장할 수 있습니다.
- 알림 정의에 존재하지 않는 발송 수단 이름이 그대로 저장되던 문제를 수정했습니다. 이제 사용할 수 없는 수단은 저장 시 안내와 함께 막히며, 플러그인이 추가한 수단은 정상적으로 선택할 수 있습니다. 수단을 제공하던 플러그인을 껐더라도 이미 저장된 설정은 계속 수정할 수 있습니다.
- 등록한 일정이 하나라도 있으면 일정 관리 목록이 열리지 않고 "총 0개"로만 표시되던 문제를 수정했습니다. 실행 이력 화면도 동일한 원인으로 열리지 않았으며, 이제 두 화면 모두 정상적으로 목록을 보여줍니다.
- 일부 안내가 실제 문장 대신 내부 식별자(`messages.auth.unauthenticated` 같은 문자열)로 표시되던 문제를 수정했습니다. 로그인하지 않았거나 역할 권한이 없어 요청이 거절될 때, 메뉴에 역할을 지정할 때, 확장 식별자를 잘못 입력했을 때, 활동 로그를 기간·유형으로 검색할 때가 해당됩니다.
- 플러그인을 GitHub 주소나 ZIP 파일로 설치할 때의 입력 안내가 내부 식별자로 표시되던 문제를 수정했습니다. 주소 형식이 잘못됐거나, ZIP이 아닌 파일을 올렸거나, 파일 용량을 초과했을 때 정상 문장으로 안내합니다.
- 레이아웃 편집기 화면이 정상 동작하는데도 서버가 "페이지 없음"(404)으로 응답하던 문제를 수정했습니다. 화면은 그대로 보였지만 브라우저 캐싱과 서버 접속 기록에서 없는 페이지로 취급되던 상태입니다.
## [7.0.5] - 2026-07-16
@@ -0,0 +1,67 @@
<?php
namespace App\Exceptions;
use Exception;
/**
* 스케줄 실행 실패 예외
*
* 저장된 스케줄을 실행하는 시점에 차단 목록에 걸리거나(artisan/shell 화이트리스트, 내부망 URL),
* 실행 자체가 실패했을 때 발생합니다. `ScheduleService` 가 이 예외를 잡아 실행 이력에 기록합니다.
*/
class ScheduleExecutionException extends Exception
{
/**
* 허용되지 않은 Artisan 명령
*
* @return self 생성된 예외
*/
public static function artisanNotAllowed(): self
{
return new self(__('schedule.artisan_not_allowed'));
}
/**
* 허용되지 않은 쉘 명령
*
* @return self 생성된 예외
*/
public static function shellNotAllowed(): self
{
return new self(__('schedule.shell_not_allowed'));
}
/**
* 쉘 명령 실행 실패
*
* @param string $errorOutput 프로세스 표준 에러 출력
* @param int $exitCode 프로세스 종료 코드
* @return self 생성된 예외
*/
public static function shellCommandFailed(string $errorOutput, int $exitCode): self
{
return new self($errorOutput !== '' ? $errorOutput : __('schedule.shell_command_failed'), $exitCode);
}
/**
* 공개되지 않은(내부망) URL 호출 차단
*
* @return self 생성된 예외
*/
public static function urlNotPublic(): self
{
return new self(__('schedule.url_not_public'));
}
/**
* HTTP 요청 실패
*
* @param int $status 응답 상태 코드
* @return self 생성된 예외
*/
public static function httpRequestFailed(int $status): self
{
return new self(__('schedule.http_request_failed', ['status' => $status]), $status);
}
}
+1 -1
View File
@@ -1183,7 +1183,7 @@ PHP;
return $pharPath;
}
throw new \RuntimeException(__('exceptions.extension.composer_binary_not_found'));
throw new \RuntimeException(__('exceptions.vendor.composer_binary_not_found'));
}
/**
+8 -8
View File
@@ -22,8 +22,8 @@ class RoleMiddleware
public function handle(Request $request, Closure $next, string $role, bool $requireAll = true): Response
{
// 인증되지 않은 사용자
if (!Auth::check()) {
return ResponseHelper::unauthorized('messages.auth.unauthenticated');
if (! Auth::check()) {
return ResponseHelper::unauthorized('auth.unauthenticated');
}
$user = Auth::user();
@@ -31,16 +31,16 @@ class RoleMiddleware
// 역할 확인
if (count($roles) === 1) {
if (!$user->hasRole($roles[0])) {
return ResponseHelper::forbidden('messages.auth.role_denied', [
'required_role' => $roles[0]
if (! $user->hasRole($roles[0])) {
return ResponseHelper::forbidden('auth.role_denied', [
'required_role' => $roles[0],
]);
}
} else {
if (!$user->hasRoles($roles, $requireAll)) {
return ResponseHelper::forbidden('messages.auth.role_denied', [
if (! $user->hasRoles($roles, $requireAll)) {
return ResponseHelper::forbidden('auth.role_denied', [
'required_roles' => $roles,
'require_all' => $requireAll
'require_all' => $requireAll,
]);
}
}
@@ -15,7 +15,7 @@ class AdminIdentityMessageDefinitionIndexRequest extends FormRequest
/**
* 권한 확인 (미들웨어에서 처리).
*
* @return bool
* @return bool 항상 true (권한은 라우트 permission 미들웨어가 담당)
*/
public function authorize(): bool
{
@@ -25,7 +25,7 @@ class AdminIdentityMessageDefinitionIndexRequest extends FormRequest
/**
* 검증 규칙.
*
* @return array
* @return array<string, mixed> 검증 규칙 배열
*/
public function rules(): array
{
@@ -50,7 +50,7 @@ class AdminIdentityMessageDefinitionIndexRequest extends FormRequest
];
return HookManager::applyFilters(
'core.identity.message_definition.filter_index_rules',
'core.identity.message_definition.index_validation_rules',
$rules
);
}
@@ -58,7 +58,7 @@ class AdminIdentityMessageDefinitionIndexRequest extends FormRequest
/**
* 검증 메시지.
*
* @return array
* @return array<string, string> 검증 오류 메시지 배열
*/
public function messages(): array
{
@@ -27,7 +27,7 @@ class StoreIdentityMessageDefinitionRequest extends FormRequest
/**
* 라우트 permission 미들웨어가 담당하므로 true 고정.
*
* @return bool
* @return bool 항상 true (권한은 라우트 permission 미들웨어가 담당)
*/
public function authorize(): bool
{
@@ -37,7 +37,7 @@ class StoreIdentityMessageDefinitionRequest extends FormRequest
/**
* 검증 규칙을 반환합니다.
*
* @return array
* @return array<string, mixed> 검증 규칙 배열
*/
public function rules(): array
{
@@ -65,15 +65,13 @@ class StoreIdentityMessageDefinitionRequest extends FormRequest
];
return HookManager::applyFilters(
'core.identity.message_definition.filter_store_rules',
'core.identity.message_definition.store_validation_rules',
$rules,
);
}
/**
* provider_id 가 등록된 IDV 프로바이더인지 확인하는 closure 룰.
*
* @return Closure
*/
protected function providerExistsRule(): Closure
{
@@ -91,8 +89,6 @@ class StoreIdentityMessageDefinitionRequest extends FormRequest
/**
* scope_value 가 source_type='admin' 인 IdentityPolicy.key 와 일치하는지 확인.
*
* @return Closure
*/
protected function adminPolicyKeyRule(): Closure
{
@@ -113,8 +109,6 @@ class StoreIdentityMessageDefinitionRequest extends FormRequest
/**
* (provider_id, scope_type, scope_value) 조합 중복 검사.
*
* @return Closure
*/
protected function scopeUniqueRule(): Closure
{
@@ -18,7 +18,7 @@ class UpdateIdentityMessageDefinitionRequest extends FormRequest
/**
* 권한 확인 (미들웨어에서 처리).
*
* @return bool
* @return bool 항상 true (권한은 미들웨어에서 검증)
*/
public function authorize(): bool
{
@@ -28,20 +28,22 @@ class UpdateIdentityMessageDefinitionRequest extends FormRequest
/**
* 검증 규칙.
*
* @return array
* @return array 필드별 검증 규칙
*/
public function rules(): array
{
$rules = [
'name' => ['sometimes', 'array', new LocaleRequiredTranslatable(maxLength: 200)],
'description' => ['sometimes', 'nullable', 'array', new TranslatableField(maxLength: 1000)],
// audit:allow formrequest-sometimes-array-min-inert reason: 채널 0개는 유효한 상태가 아니다.
// 발송 채널을 모두 해제하면 본인인증 메시지가 전달되지 않으므로 오류를 노출하는 것이 올바른 동작이다.
'channels' => ['sometimes', 'array', 'min:1'],
'channels.*' => ['string', 'max:20'],
'is_active' => ['sometimes', 'boolean'],
];
return HookManager::applyFilters(
'core.identity.message_definition.filter_update_rules',
'core.identity.message_definition.update_validation_rules',
$rules,
$this->route('definition')
);
@@ -18,7 +18,7 @@ class UpdateIdentityMessageTemplateRequest extends FormRequest
/**
* 권한 확인 (미들웨어에서 처리).
*
* @return bool
* @return bool 항상 true (권한은 라우트 permission 미들웨어가 담당)
*/
public function authorize(): bool
{
@@ -28,7 +28,7 @@ class UpdateIdentityMessageTemplateRequest extends FormRequest
/**
* 검증 규칙.
*
* @return array
* @return array<string, mixed> 검증 규칙 배열
*/
public function rules(): array
{
@@ -39,7 +39,7 @@ class UpdateIdentityMessageTemplateRequest extends FormRequest
];
return HookManager::applyFilters(
'core.identity.message_template.filter_update_rules',
'core.identity.message_template.update_validation_rules',
$rules,
$this->route('template')
);
@@ -9,6 +9,8 @@ class NotificationBatchReadRequest extends FormRequest
{
/**
* 권한 확인 (미들웨어에서 처리).
*
* @return bool 항상 true (권한은 라우트 permission 미들웨어가 담당)
*/
public function authorize(): bool
{
@@ -17,6 +19,8 @@ class NotificationBatchReadRequest extends FormRequest
/**
* 검증 규칙을 반환합니다.
*
* @return array<string, mixed> 검증 규칙 배열
*/
public function rules(): array
{
@@ -26,7 +30,7 @@ class NotificationBatchReadRequest extends FormRequest
];
return HookManager::applyFilters(
'core.notification.filter_batch_read_rules',
'core.notification.batch_read_validation_rules',
$rules
);
}
@@ -9,6 +9,8 @@ class NotificationIndexRequest extends FormRequest
{
/**
* 권한 확인 (미들웨어에서 처리).
*
* @return bool 항상 true (권한은 라우트 permission 미들웨어가 담당)
*/
public function authorize(): bool
{
@@ -17,6 +19,8 @@ class NotificationIndexRequest extends FormRequest
/**
* 검증 규칙을 반환합니다.
*
* @return array<string, mixed> 검증 규칙 배열
*/
public function rules(): array
{
@@ -28,7 +32,7 @@ class NotificationIndexRequest extends FormRequest
];
return HookManager::applyFilters(
'core.notification.filter_index_rules',
'core.notification.index_validation_rules',
$rules
);
}
@@ -10,7 +10,7 @@ class NotificationDefinitionIndexRequest extends FormRequest
/**
* 권한 확인 (미들웨어에서 처리).
*
* @return bool
* @return bool 항상 true (권한은 라우트 permission 미들웨어가 담당)
*/
public function authorize(): bool
{
@@ -20,7 +20,7 @@ class NotificationDefinitionIndexRequest extends FormRequest
/**
* 검증 규칙을 반환합니다.
*
* @return array
* @return array<string, mixed> 검증 규칙 배열
*/
public function rules(): array
{
@@ -36,7 +36,7 @@ class NotificationDefinitionIndexRequest extends FormRequest
];
return HookManager::applyFilters(
'core.notification_definition.filter_index_rules',
'core.notification_definition.index_validation_rules',
$rules
);
}
@@ -44,7 +44,7 @@ class NotificationDefinitionIndexRequest extends FormRequest
/**
* 검증 메시지를 반환합니다.
*
* @return array
* @return array<string, string> 검증 오류 메시지 배열
*/
public function messages(): array
{
@@ -2,7 +2,10 @@
namespace App\Http\Requests\NotificationDefinition;
use App\Contracts\Repositories\NotificationDefinitionRepositoryInterface;
use App\Extension\HookManager;
use App\Models\NotificationDefinition;
use App\Rules\AvailableNotificationChannel;
use Illuminate\Foundation\Http\FormRequest;
class UpdateNotificationDefinitionRequest extends FormRequest
@@ -10,7 +13,7 @@ class UpdateNotificationDefinitionRequest extends FormRequest
/**
* 권한 확인 (미들웨어에서 처리).
*
* @return bool
* @return bool 항상 true (권한은 미들웨어에서 검증)
*/
public function authorize(): bool
{
@@ -20,22 +23,47 @@ class UpdateNotificationDefinitionRequest extends FormRequest
/**
* 검증 규칙을 반환합니다.
*
* @return array
* @return array 필드별 검증 규칙
*/
public function rules(): array
{
$rules = [
// audit:allow formrequest-sometimes-array-min-inert reason: 채널 0개는 유효한 상태가 아니다.
// 발송 채널을 모두 해제하면 알림이 사라지므로, 저장을 막고 오류를 노출하는 것이 올바른 동작이다.
'channels' => ['sometimes', 'array', 'min:1'],
'channels.*' => ['string', 'max:50'],
// 허용 채널 목록은 config + filter_available_channels 훅으로 런타임 결정되므로
// 요청 클래스에 하드코딩하지 않는다. 이미 저장된 채널은 통과시켜, 채널 제공
// 플러그인이 비활성화된 뒤에도 기존 레코드 수정이 막히지 않게 한다.
'channels.*' => ['string', 'max:50', new AvailableNotificationChannel($this->persistedChannels())],
'hooks' => ['sometimes', 'array'],
'hooks.*' => ['string', 'max:255'],
'is_active' => ['sometimes', 'boolean'],
];
return HookManager::applyFilters(
'core.notification_definition.filter_update_rules',
'core.notification_definition.update_validation_rules',
$rules,
$this->route('definition')
);
}
/**
* 수정 대상 알림 정의에 이미 저장되어 있는 채널 목록을 반환합니다.
*
* @return array<int, string> 저장된 채널 식별자 목록
*/
private function persistedChannels(): array
{
$definition = $this->route('definition');
if (! $definition instanceof NotificationDefinition) {
$definition = is_numeric($definition)
? app(NotificationDefinitionRepositoryInterface::class)->findById((int) $definition)
: null;
}
$channels = $definition?->channels;
return is_array($channels) ? array_values(array_filter($channels, 'is_string')) : [];
}
}
@@ -12,6 +12,8 @@ class NotificationLogIndexRequest extends FormRequest
{
/**
* 권한 확인 (미들웨어에서 처리).
*
* @return bool 항상 true (권한은 라우트 permission 미들웨어가 담당)
*/
public function authorize(): bool
{
@@ -20,6 +22,8 @@ class NotificationLogIndexRequest extends FormRequest
/**
* 검증 규칙을 반환합니다.
*
* @return array<string, mixed> 검증 규칙 배열
*/
public function rules(): array
{
@@ -37,7 +41,7 @@ class NotificationLogIndexRequest extends FormRequest
];
return HookManager::applyFilters(
'core.notification_log.filter_index_rules',
'core.notification_log.index_validation_rules',
$rules
);
}
@@ -11,7 +11,7 @@ class UpdateNotificationTemplateRequest extends FormRequest
/**
* 권한 확인 (미들웨어에서 처리).
*
* @return bool
* @return bool 항상 true (권한은 미들웨어에서 검증)
*/
public function authorize(): bool
{
@@ -21,12 +21,14 @@ class UpdateNotificationTemplateRequest extends FormRequest
/**
* 검증 규칙을 반환합니다.
*
* @return array
* @return array 필드별 검증 규칙
*/
public function rules(): array
{
$rules = [
'subject' => ['required', 'array', new LocaleRequiredTranslatable(maxLength: 500)],
// subject 컬럼은 nullable 이다 (제목 개념이 없는 채널이 존재).
// 스키마와 정합하도록 미전송/null 을 허용한다.
'subject' => ['sometimes', 'nullable', 'array', new LocaleRequiredTranslatable(maxLength: 500)],
'body' => ['required', 'array', new LocaleRequiredTranslatable(maxLength: 65535)],
'click_url' => ['nullable', 'string', 'max:500'],
'recipients' => ['sometimes', 'nullable', 'array'],
@@ -38,7 +40,7 @@ class UpdateNotificationTemplateRequest extends FormRequest
];
return HookManager::applyFilters(
'core.notification_template.filter_update_rules',
'core.notification_template.update_validation_rules',
$rules,
$this->route('template')
);
@@ -47,7 +49,7 @@ class UpdateNotificationTemplateRequest extends FormRequest
/**
* 검증 메시지를 반환합니다.
*
* @return array
* @return array 규칙별 오류 메시지
*/
public function messages(): array
{
@@ -0,0 +1,55 @@
<?php
namespace App\Rules;
use App\Services\NotificationChannelService;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
/**
* 알림 채널 식별자 검증 규칙
*
* 사용 가능한 채널 목록은 config('notification.default_channels') 에
* `core.notification.filter_available_channels` 훅으로 모듈/플러그인이 추가한 채널을
* 더해 런타임에 결정됩니다. 따라서 허용 목록을 요청 클래스에 하드코딩하면
* 플러그인이 등록한 채널이 저장되지 않습니다.
*
* 이미 저장되어 있던 채널은 통과시킵니다. 채널을 제공하던 플러그인이 비활성화되면
* 그 값이 목록 밖으로 나가는데, 이때 기존 레코드 수정 자체가 차단되면 안 되기 때문입니다.
* (신규로 알 수 없는 채널을 밀어 넣는 것만 차단합니다)
*/
class AvailableNotificationChannel implements ValidationRule
{
/**
* AvailableNotificationChannel 생성자
*
* @param array<int, string> $persistedChannels 대상 레코드에 이미 저장된 채널 목록
*/
public function __construct(
private array $persistedChannels = []
) {}
/**
* 검증 규칙 실행
*
* @param string $attribute 필드명
* @param mixed $value 검증 값
* @param Closure $fail 실패 콜백
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_string($value) || $value === '') {
$fail(__('validation.notification_channel.invalid', ['value' => (string) $value]));
return;
}
if (in_array($value, $this->persistedChannels, true)) {
return;
}
if (! app(NotificationChannelService::class)->isChannelAvailable($value)) {
$fail(__('validation.notification_channel.unavailable', ['value' => $value]));
}
}
}
+13 -3
View File
@@ -19,11 +19,13 @@ class LocaleRequiredTranslatable implements ValidationRule
* @param int $maxLength 각 번역의 최대 길이
* @param int $minLength 각 번역의 최소 길이 (기본: 0)
* @param string|null $requiredLocale 필수 로케일 (null이면 현재 로케일 사용)
* @param bool $strictLocales 지원 목록 밖 로케일 키를 실패로 처리할지 여부
*/
public function __construct(
private int $maxLength = 255,
private int $minLength = 0,
private ?string $requiredLocale = null
private ?string $requiredLocale = null,
private bool $strictLocales = false
) {}
/**
@@ -32,7 +34,6 @@ class LocaleRequiredTranslatable implements ValidationRule
* @param string $attribute 필드명
* @param mixed $value 검증 값
* @param Closure $fail 실패 콜백
* @return void
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
@@ -67,13 +68,22 @@ class LocaleRequiredTranslatable implements ValidationRule
// 각 언어별 검증
foreach ($value as $lang => $text) {
// 지원하지 않는 언어 코드
// 지원 목록 밖 로케일 키
//
// app.translatable_locales 는 활성 언어팩 기준으로 부팅마다 덮어써지는 가변 런타임
// 값이다. 언어팩을 비활성화하면 기존 레코드에 남아 있던 그 로케일 키 때문에 수정
// 자체가 차단되므로, 기본값은 "길이 검증 대상에서 제외하고 통과"로 둔다.
// 데이터를 지우지 않으므로 언어팩을 다시 켜면 번역이 그대로 복구된다.
if (! in_array($lang, $allowedLanguages)) {
if ($this->strictLocales) {
$fail(__('validation.translatable.unsupported_language', ['lang' => $lang]));
return;
}
continue;
}
// null이거나 빈 문자열이 아닌 경우에만 검증
if ($text !== null && $text !== '') {
// 문자열이 아닌 경우
+15 -4
View File
@@ -17,10 +17,12 @@ class TranslatableField implements ValidationRule
*
* @param int $maxLength 각 번역의 최대 길이
* @param bool $required 최소 하나의 번역 필수 여부
* @param bool $strictLocales 지원 목록 밖 로케일 키를 실패로 처리할지 여부
*/
public function __construct(
private int $maxLength = 255,
private bool $required = false
private bool $required = false,
private bool $strictLocales = false
) {}
/**
@@ -40,13 +42,22 @@ class TranslatableField implements ValidationRule
// 각 언어별 검증
foreach ($value as $lang => $text) {
// 지원하지 않는 언어 코드
// 지원 목록 밖 로케일 키
//
// app.translatable_locales 는 활성 언어팩 기준으로 부팅마다 덮어써지는 가변 런타임
// 값이다. 언어팩을 비활성화하면 기존 레코드에 남아 있던 그 로케일 키 때문에 수정
// 자체가 차단되므로, 기본값은 "길이 검증 대상에서 제외하고 통과"로 둔다.
// 데이터를 지우지 않으므로 언어팩을 다시 켜면 번역이 그대로 복구된다.
if (! in_array($lang, $allowedLanguages)) {
if ($this->strictLocales) {
$fail(__('validation.translatable.unsupported_language', ['lang' => $lang]));
return;
}
continue;
}
// null이거나 빈 문자열이 아닌 경우에만 검증
if ($text !== null && $text !== '') {
// 문자열이 아닌 경우
@@ -56,8 +67,8 @@ class TranslatableField implements ValidationRule
return;
}
// 최대 길이 초과
if (strlen($text) > $this->maxLength) {
// 최대 길이 초과 (바이트가 아닌 문자 단위로 계산)
if (mb_strlen($text) > $this->maxLength) {
$fail(__('validation.translatable.max_length', [
'lang' => $lang,
'max' => $this->maxLength,
+40
View File
@@ -9,6 +9,19 @@ use App\Services\TemplateService;
class TemplateRouteResolver
{
/**
* 템플릿 routes.json 이 아닌 **코어가 직접 소유하는** admin 경로 접두사.
*
* 레이아웃 편집기는 코어 기능이라 어느 템플릿의 routes.json 에도 선언되지 않는다.
* 템플릿 라우트만 보고 판정하면 실제로 동작하는 화면이 HTTP 404 로 서빙되므로
* (본문은 SPA 셸이라 화면은 뜨지만 상태 코드만 404), 존재하는 경로로 인정한다.
*
* @var array<string>
*/
private const CORE_ADMIN_ROUTE_PREFIXES = [
'/admin/layout-editor',
];
public function __construct(
private readonly TemplateService $templateService,
private readonly TemplateManagerInterface $templateManager,
@@ -92,6 +105,11 @@ class TemplateRouteResolver
*/
public function routeExists(string $url, string $type = 'user'): bool
{
// 코어 소유 admin 경로는 템플릿 routes.json 에 없어도 존재하는 경로다.
if ($type === 'admin' && $this->isCoreAdminRoute($url)) {
return true;
}
$loaded = $this->loadActiveRoutes($type);
// 템플릿 미설치/로드 실패 시 전 경로 404 차단 — 안전측 폴백 (D-47-3).
if ($loaded === null) {
@@ -112,6 +130,28 @@ class TemplateRouteResolver
return false;
}
/**
* URL 이 코어가 직접 소유하는 admin 경로인지 판정합니다.
*
* 접두사 뒤에는 경로 구분자(`/`) 또는 문자열 끝만 허용한다 — `/admin/layout-editorX`
* 같은 유사 경로가 통과하지 않도록 한다.
*
* @param string $url 요청 URL 경로
* @return bool 코어 소유 경로면 true
*/
private function isCoreAdminRoute(string $url): bool
{
$path = '/'.trim(parse_url($url, PHP_URL_PATH) ?? $url, '/');
foreach (self::CORE_ADMIN_ROUTE_PREFIXES as $prefix) {
if ($path === $prefix || str_starts_with($path, $prefix.'/')) {
return true;
}
}
return false;
}
/**
* 활성 템플릿($type)의 병합 routes 배열을 로드합니다.
*
+10 -8
View File
@@ -7,6 +7,7 @@ use App\Contracts\Repositories\ScheduleRepositoryInterface;
use App\Enums\ScheduleResultStatus;
use App\Enums\ScheduleTriggerType;
use App\Enums\ScheduleType;
use App\Exceptions\ScheduleExecutionException;
use App\Extension\HookManager;
use App\Models\Schedule;
use App\Models\ScheduleHistory;
@@ -14,6 +15,7 @@ use App\Support\OutboundUrlValidator;
use App\Support\ScheduleCommandValidator;
use Exception;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
@@ -254,14 +256,14 @@ class ScheduleService
* @param Schedule $schedule 스케줄
* @return array 실행 결과
*
* @throws Exception 차단목록에 걸린 명령일 때
* @throws ScheduleExecutionException 차단목록에 걸린 명령일 때
*/
private function executeArtisanCommand(Schedule $schedule): array
{
// 저장 시점 검증 도입 이전 데이터나 DB 직접 수정으로 들어온 값을 방어한다
// (isUrlCallAllowed 와 동일 사유 — 실행 직전이 마지막 방어선).
if (! ScheduleCommandValidator::isArtisanCommandAllowed($schedule->command)) {
throw new Exception(__('schedule.artisan_not_allowed'));
throw ScheduleExecutionException::artisanNotAllowed();
}
Artisan::call($schedule->command, [], new BufferedOutput);
@@ -278,7 +280,7 @@ class ScheduleService
* @param Schedule $schedule 스케줄
* @return array 실행 결과
*
* @throws Exception 실행 실패 시
* @throws ScheduleExecutionException 차단목록에 걸렸거나 실행이 실패했을 때
*/
private function executeShellCommand(Schedule $schedule): array
{
@@ -287,7 +289,7 @@ class ScheduleService
$arguments = ScheduleCommandValidator::tokenizeShellCommand($schedule->command);
if ($arguments === null || ! ScheduleCommandValidator::isShellCommandAllowed($schedule->command)) {
throw new Exception(__('schedule.shell_not_allowed'));
throw ScheduleExecutionException::shellNotAllowed();
}
$timeout = $schedule->timeout ?? 60;
@@ -297,7 +299,7 @@ class ScheduleService
$result = Process::timeout($timeout)->run($arguments);
if ($result->failed()) {
throw new Exception($result->errorOutput() ?: __('schedule.shell_command_failed'), $result->exitCode());
throw ScheduleExecutionException::shellCommandFailed($result->errorOutput(), $result->exitCode());
}
return [
@@ -312,14 +314,14 @@ class ScheduleService
* @param Schedule $schedule 스케줄
* @return array 실행 결과
*
* @throws Exception 호출 실패 시
* @throws ScheduleExecutionException 내부망 URL 이거나 호출이 실패했을 때
*/
private function executeUrlCall(Schedule $schedule): array
{
// 저장된 URL 이 그대로 서버의 outbound 목적지가 되므로, 실행 직전에도 내부망 주소를 차단한다
// (저장 시점 검증 도입 이전 데이터나 DB 직접 수정으로 들어온 값 방어).
if (! $this->isUrlCallAllowed($schedule->command)) {
throw new Exception(__('schedule.url_not_public'));
throw ScheduleExecutionException::urlNotPublic();
}
$timeout = $schedule->timeout ?? 30;
@@ -327,7 +329,7 @@ class ScheduleService
$response = Http::timeout($timeout)->get($schedule->command);
if ($response->failed()) {
throw new Exception(__('schedule.http_request_failed', ['status' => $response->status()]), $response->status());
throw ScheduleExecutionException::httpRequestFailed($response->status());
}
return [
@@ -71,7 +71,7 @@ class ResourceFieldDescriber
'creator' => '생성자 정보 객체 (uuid/name/email — creator 관계 파생)',
'children' => '하위 항목 배열 (계층 트리 — children 관계 파생)',
'parent' => '상위 항목 객체 (parent 관계 파생)',
'permissions' => '연결된 권한 목록 (id/identifier/name — 역할 경유 권한 관계 파생)',
'permissions' => '권한 목록 (각 원소 id/identifier/name — permissions 관계 또는 역할 경유 파생)',
'recipient' => '수신자 사용자 객체 (uuid/name/email — recipientUser 관계 파생)',
'sender' => '발신자 사용자 객체 (uuid/name/email — senderUser 관계 파생)',
'author' => '작성자 사용자 객체 (uuid/name — author 관계 파생)',
@@ -106,10 +106,8 @@ class ResourceFieldDescriber
// 감사/관계 공통 (도메인 무관하게 역할 고정 — 쓰기 응답에서 흔히 등장)
'updated_by' => '최종 수정한 사용자 정보 (uuid/name — updated_by 관계 파생, 없으면 null)',
'creator' => '생성자 정보 객체 (uuid/name/email — creator 관계 파생)',
'user' => '대상 사용자 정보 객체 (uuid/name/email 등 — user 관계 파생)',
'roles' => '보유 역할 목록 (각 원소 id/name/permissions — roles 관계 파생)',
'permissions' => '권한 목록 (각 원소 identifier/name — permissions 관계 파생)',
'avatar' => '아바타 이미지 URL (미등록 시 null)',
// 인증/토큰 (로그인/토큰 발급 응답)
+8 -8
View File
@@ -212,7 +212,7 @@ HTTP/1.1 200
| sort_by | query | string | 아니오 | — | 정렬 기준 필드명 |
| sort_order | query | string | 아니오 | — | 정렬 방향 (asc 오름차순 / desc 내림차순) |
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.identity.message_definition.filter_index_rules`).
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.identity.message_definition.index_validation_rules`).
**요청 예시**
@@ -368,7 +368,7 @@ HTTP/1.1 200
<!-- @generated:end -->
**설명** 본인인증 알림 메시지 정의(프로바이더별·목적별·정책별 메일/SMS 템플릿 묶음) 목록을 필터·검색하여 페이지네이션 조회합니다. `auth:sanctum` + `core.admin.identity.messages.read` 관리자 권한이 필요합니다. 확장이 `core.identity.message_definition.filter_index_rules` 필터 훅으로 추가 검색 파라미터를 등록할 수 있습니다. 응답 각 항목의 `user_overrides` 로 운영자가 시드 기본값에서 수정한 필드를, `templates` 로 채널별 하위 템플릿을 함께 내려 관리자 메시지 설정 화면을 구성할 때 사용합니다.
**설명** 본인인증 알림 메시지 정의(프로바이더별·목적별·정책별 메일/SMS 템플릿 묶음) 목록을 필터·검색하여 페이지네이션 조회합니다. `auth:sanctum` + `core.admin.identity.messages.read` 관리자 권한이 필요합니다. 확장이 `core.identity.message_definition.index_validation_rules` 필터 훅으로 추가 검색 파라미터를 등록할 수 있습니다. 응답 각 항목의 `user_overrides` 로 운영자가 시드 기본값에서 수정한 필드를, `templates` 로 채널별 하위 템플릿을 함께 내려 관리자 메시지 설정 화면을 구성할 때 사용합니다.
### POST /api/admin/identity/messages/definitions
@@ -390,7 +390,7 @@ HTTP/1.1 200
| variables | body | array | 아니오 | — | 템플릿 치환 변수 메타데이터 목록 (원소 key/description, key 는 영문 식별자) |
| templates | body | array | 예 | min 1 | 채널별 하위 템플릿 배열 (최소 1개, 원소 channel/subject/body 다국어 배열) |
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.identity.message_definition.filter_store_rules`).
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.identity.message_definition.store_validation_rules`).
**요청 예시**
@@ -441,7 +441,7 @@ Content-Type: application/json
<!-- @generated:end -->
**설명** 운영자가 정책(policy) 매핑용 메시지 정의를 신규 생성합니다. `auth:sanctum` + `core.admin.identity.messages.update` 관리자 권한이 필요합니다. `IdentityMessageDefinitionService::createAdminDefinition` 이 처리하며 `scope_type='policy'` + `scope_value` 가 admin policy.key 와 매칭되는 경우만 허용됩니다(FormRequest 검증). `channels`·`templates` 를 최소 1개 이상 포함해야 하며, 확장은 `core.identity.message_definition.filter_store_rules` 필터 훅으로 검증 규칙을 확장할 수 있습니다. 특정 인증 정책에 전용 메일/SMS 문구를 붙이고자 할 때 사용하며 성공 시 201 로 응답합니다.
**설명** 운영자가 정책(policy) 매핑용 메시지 정의를 신규 생성합니다. `auth:sanctum` + `core.admin.identity.messages.update` 관리자 권한이 필요합니다. `IdentityMessageDefinitionService::createAdminDefinition` 이 처리하며 `scope_type='policy'` + `scope_value` 가 admin policy.key 와 매칭되는 경우만 허용됩니다(FormRequest 검증). `channels`·`templates` 를 최소 1개 이상 포함해야 하며, 확장은 `core.identity.message_definition.store_validation_rules` 필터 훅으로 검증 규칙을 확장할 수 있습니다. 특정 인증 정책에 전용 메일/SMS 문구를 붙이고자 할 때 사용하며 성공 시 201 로 응답합니다.
### DELETE /api/admin/identity/messages/definitions/{definition}
@@ -652,7 +652,7 @@ HTTP/1.1 200
| channels | body | array | 아니오 | min 1 | 지원 채널 목록 (최소 1개 — 이 정의가 발송할 채널 조정) |
| is_active | body | boolean | 아니오 | — | 활성 여부 (true 활성 / false 비활성) |
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.identity.message_definition.filter_update_rules`).
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.identity.message_definition.update_validation_rules`).
**요청 예시**
@@ -696,7 +696,7 @@ Content-Type: application/json
<!-- @generated:end -->
**설명** 메시지 정의의 편집 가능 속성(`name`, `description`, `channels`, `is_active`) 을 수정합니다. `auth:sanctum` + `core.admin.identity.messages.update` 관리자 권한이 필요합니다. `IdentityMessageDefinitionService::updateDefinition` 이 처리하며 수정된 필드는 `user_overrides` 에 기록되어 시더 재실행 시에도 보존됩니다. 확장은 `core.identity.message_definition.filter_update_rules` 필터 훅으로 검증 규칙을 확장할 수 있습니다. 시드 정의의 표시명이나 활성 채널을 운영 상황에 맞게 조정할 때 사용합니다.
**설명** 메시지 정의의 편집 가능 속성(`name`, `description`, `channels`, `is_active`) 을 수정합니다. `auth:sanctum` + `core.admin.identity.messages.update` 관리자 권한이 필요합니다. `IdentityMessageDefinitionService::updateDefinition` 이 처리하며 수정된 필드는 `user_overrides` 에 기록되어 시더 재실행 시에도 보존됩니다. 확장은 `core.identity.message_definition.update_validation_rules` 필터 훅으로 검증 규칙을 확장할 수 있습니다. 시드 정의의 표시명이나 활성 채널을 운영 상황에 맞게 조정할 때 사용합니다.
### POST /api/admin/identity/messages/definitions/{definition}/reset
@@ -1045,7 +1045,7 @@ Content-Type: application/json
| body | body | array | 예 | — | 본문 |
| is_active | body | boolean | 아니오 | — | 활성 여부 (true 활성 / false 비활성) |
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.identity.message_template.filter_update_rules`).
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.identity.message_template.update_validation_rules`).
**요청 예시**
@@ -1086,7 +1086,7 @@ Content-Type: application/json
<!-- @generated:end -->
**설명** 개별 채널 메시지 템플릿의 제목(`subject`)·본문(`body`)·활성 여부(`is_active`) 를 수정합니다. `auth:sanctum` + `core.admin.identity.messages.update` 관리자 권한이 필요합니다. `body` 는 필수이며 다국어 배열로 전달합니다. `IdentityMessageTemplateService::updateTemplate` 이 처리하고 확장은 `core.identity.message_template.filter_update_rules` 필터 훅으로 검증을 확장할 수 있습니다. 인증 메일/SMS 의 실제 발송 문구를 편집할 때 사용합니다.
**설명** 개별 채널 메시지 템플릿의 제목(`subject`)·본문(`body`)·활성 여부(`is_active`) 를 수정합니다. `auth:sanctum` + `core.admin.identity.messages.update` 관리자 권한이 필요합니다. `body` 는 필수이며 다국어 배열로 전달합니다. `IdentityMessageTemplateService::updateTemplate` 이 처리하고 확장은 `core.identity.message_template.update_validation_rules` 필터 훅으로 검증을 확장할 수 있습니다. 인증 메일/SMS 의 실제 발송 문구를 편집할 때 사용합니다.
### POST /api/admin/identity/messages/templates/{template}/reset
+2 -2
View File
@@ -1732,12 +1732,12 @@ HTTP/1.1 200
{
"name": "Added",
"items": [
"클래임 사유 관리 시스템 — DB 기반 환불 사유 관리 (ClaimReason 모델, Repository, Service, Controller, FormRequest, Resource)",
"클레임 사유 관리 시스템 — DB 기반 환불 사유 관리 (ClaimReason 모델, Repository, Service, Controller, FormRequest, Resource)",
"`ecommerce_claim_reasons` 테이블: type, code, name(다국어), fault_type, is_user_selectable, is_active, sort_order",
"Admin API 7개: 목록/생성/상세/수정/삭제/상태토글/활성목록 (`/admin/claim-reasons`)",
"User API 1개: 사용자 선택 가능 사유 목록 (`/user/claim-reasons`)",
"ClaimReasonFaultTypeEnum (customer/seller/carrier), ClaimReasonTypeEnum (refund)",
"쇼핑몰 환경설정 > 클래임 탭 — 환불 사유 인라인 편집 UI (배송사 관리 패턴)",
"쇼핑몰 환경설정 > 클레임 탭 — 환불 사유 인라인 편집 UI (배송사 관리 패턴)",
"테이블 뷰(PC) + 카드 뷰(모바일) 반응형 레이아웃",
"EcommerceSettingsController에 syncReasons() 연동",
"업그레이드 스크립트 (Upgrade_0_14_0) — 기존 CancelReasonTypeEnum 7개 값을 DB 시드 데이터로 마이그레이션"
+5 -5
View File
@@ -36,7 +36,7 @@
| sort_by | query | string | 아니오 | `id`, `type`, `extension_type`, `is_active`, `created_at`, `updated_at` | 정렬 기준 필드명 |
| sort_order | query | string | 아니오 | `asc`, `desc` | 정렬 방향 (asc 오름차순 / desc 내림차순) |
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.notification_definition.filter_index_rules`).
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.notification_definition.index_validation_rules`).
**요청 예시**
@@ -297,7 +297,7 @@ HTTP/1.1 200
<!-- @generated:end -->
**설명** 등록된 알림 정의 목록을 페이지네이션으로 조회합니다. 인증(`auth:sanctum`)과 `core.settings.read` 권한이 필요합니다. `search`, `extension_type`, `extension_identifier`, `channel`, `is_active` 로 필터링하고 `sort_by`/`sort_order` 로 정렬하며, 확장이 `core.notification_definition.filter_index_rules` 훅으로 필터를 추가할 수 있습니다. 관리자 알림 정의 관리 목록 화면을 렌더링할 때 사용합니다.
**설명** 등록된 알림 정의 목록을 페이지네이션으로 조회합니다. 인증(`auth:sanctum`)과 `core.settings.read` 권한이 필요합니다. `search`, `extension_type`, `extension_identifier`, `channel`, `is_active` 로 필터링하고 `sort_by`/`sort_order` 로 정렬하며, 확장이 `core.notification_definition.index_validation_rules` 훅으로 필터를 추가할 수 있습니다. 관리자 알림 정의 관리 목록 화면을 렌더링할 때 사용합니다.
### GET /api/admin/notification-definitions/{definition}
@@ -437,11 +437,11 @@ HTTP/1.1 200
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
| --- | --- | --- | --- | --- | --- |
| definition | path | string | 예 | — | 대상 definition의 식별자 |
| channels | body | array | 아니오 | min 1 | 활성 채널 목록 — 이 정의가 발송에 사용할 채널 배열 (각 원소 최대 50자, mail·database 등). 지정 시 최소 1개 필요 |
| channels | body | array | 아니오 | min 1 | 활성 채널 목록 — 이 정의가 발송에 사용할 채널 배열 (각 원소 최대 50자, mail·database 등). 지정 시 최소 1개 필요. 허용 채널은 `config('notification.default_channels')` 에 `core.notification.filter_available_channels` 훅으로 확장이 추가한 채널을 더해 런타임에 결정된다. 목록에 없는 채널은 거부되지만, 해당 정의에 이미 저장되어 있던 채널은 통과한다(채널 제공 확장을 비활성화해도 기존 레코드 수정이 막히지 않도록) |
| hooks | body | array | 아니오 | — | 트리거 훅 목록 — 이 알림을 발송시키는 훅 이름 배열 (각 원소 최대 255자, core.auth.after_register 등) |
| is_active | body | boolean | 아니오 | — | 활성 여부 (true 활성 / false 비활성) |
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.notification_definition.filter_update_rules`).
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.notification_definition.update_validation_rules`).
**요청 예시**
@@ -482,7 +482,7 @@ Content-Type: application/json
<!-- @generated:end -->
**설명** 알림 정의의 활성 채널(`channels`), 트리거 훅(`hooks`), 활성 상태(`is_active`)를 수정합니다. 인증(`auth:sanctum`)과 `core.settings.update` 권한이 필요합니다. Service 계층에서 수정 후 템플릿을 다시 로드해 반환하며, 확장이 `core.notification_definition.filter_update_rules` 훅으로 추가 파라미터를 검증에 넣을 수 있습니다. 발송 채널 구성이나 훅 연결을 변경할 때 사용합니다.
**설명** 알림 정의의 활성 채널(`channels`), 트리거 훅(`hooks`), 활성 상태(`is_active`)를 수정합니다. 인증(`auth:sanctum`)과 `core.settings.update` 권한이 필요합니다. Service 계층에서 수정 후 템플릿을 다시 로드해 반환하며, 확장이 `core.notification_definition.update_validation_rules` 훅으로 추가 파라미터를 검증에 넣을 수 있습니다. 발송 채널 구성이나 훅 연결을 변경할 때 사용합니다.
### POST /api/admin/notification-definitions/{definition}/reset
+2 -2
View File
@@ -38,7 +38,7 @@
| sort_by | query | string | 아니오 | `id`, `channel`, `notification_type`, `status`, `sent_at`, `created_at` | 정렬 기준 필드명 |
| sort_order | query | string | 아니오 | `asc`, `desc` | 정렬 방향 (asc 오름차순 / desc 내림차순) |
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.notification_log.filter_index_rules`).
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.notification_log.index_validation_rules`).
**요청 예시**
@@ -149,7 +149,7 @@ HTTP/1.1 200
<!-- @generated:end -->
**설명** 알림 발송 이력을 페이지네이션으로 조회합니다. 인증(`auth:sanctum`)과 `core.notification-logs.read` 권한이 필요합니다. 발송자/수신자 ID, `search`, `channel`, `notification_type`, `extension_type`, `status` 로 필터링하고 `sort_by`/`sort_order` 로 정렬하며, 확장이 `core.notification_log.filter_index_rules` 훅으로 필터를 추가할 수 있습니다. 요청 사용자(`request->user()`)를 Service 에 전달해 열람 범위를 결정하며, 관리자 알림 발송 이력 화면을 렌더링할 때 사용합니다.
**설명** 알림 발송 이력을 페이지네이션으로 조회합니다. 인증(`auth:sanctum`)과 `core.notification-logs.read` 권한이 필요합니다. 발송자/수신자 ID, `search`, `channel`, `notification_type`, `extension_type`, `status` 로 필터링하고 `sort_by`/`sort_order` 로 정렬하며, 확장이 `core.notification_log.index_validation_rules` 훅으로 필터를 추가할 수 있습니다. 요청 사용자(`request->user()`)를 Service 에 전달해 열람 범위를 결정하며, 관리자 알림 발송 이력 화면을 렌더링할 때 사용합니다.
### POST /api/admin/notification-logs/bulk-delete
+4 -4
View File
@@ -28,7 +28,7 @@
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
| --- | --- | --- | --- | --- | --- |
| definition_id | body | integer | 예 | — | definition 식별자 |
| subject | body | array | 예 | — | 제목 |
| subject | body | array | 아니오 | — | 다국어 제목. 컬럼이 nullable 이므로 미전송·`null` 이 허용된다(제목 개념이 없는 채널 대응). 전송하는 경우 로케일별 문자열 배열이어야 하며 각 값은 500자 이하 |
| body | body | array | 예 | — | 본문 |
| locale | body | string | 아니오 | max 10 | 로케일 코드 (표시 언어/지역) |
@@ -85,13 +85,13 @@ Content-Type: application/json
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
| --- | --- | --- | --- | --- | --- |
| template | path | string | 예 | — | 대상 template의 식별자 |
| subject | body | array | 예 | — | 제목 |
| subject | body | array | 아니오 | — | 다국어 제목. 컬럼이 nullable 이므로 미전송·`null` 이 허용된다(제목 개념이 없는 채널 대응). 전송하는 경우 로케일별 문자열 배열이어야 하며 각 값은 500자 이하 |
| body | body | array | 예 | — | 본문 |
| click_url | body | string | 아니오 | max 500 | 알림 클릭 시 이동할 대상 URL (미설정 시 이동 없음) |
| recipients | body | array | 아니오 | — | 수신자 규칙 목록. 각 원소는 type(trigger_user: 이벤트 유발 사용자, related_user: 연관 사용자, role: 역할 대상, specific_users: 지정 사용자), value(대상 식별값), relation(연관 사용자 관계명), exclude_trigger_user(유발 사용자 제외 여부)로 구성 |
| is_active | body | boolean | 아니오 | — | 활성 여부 (true 활성 / false 비활성) |
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.notification_template.filter_update_rules`).
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.notification_template.update_validation_rules`).
**요청 예시**
@@ -136,7 +136,7 @@ Content-Type: application/json
<!-- @generated:end -->
**설명** 단일 채널 알림 템플릿의 다국어 제목(`subject`)·본문(`body`)과 클릭 URL, 수신자(`recipients`), 활성 상태를 수정합니다. 인증(`auth:sanctum`)과 `core.settings.update` 권한이 필요합니다. `template` 경로 파라미터로 대상을 지정하며, 확장이 `core.notification_template.filter_update_rules` 훅으로 추가 파라미터를 검증에 넣을 수 있습니다. 관리자가 특정 채널의 알림 문구를 편집해 저장할 때 사용합니다.
**설명** 단일 채널 알림 템플릿의 다국어 제목(`subject`)·본문(`body`)과 클릭 URL, 수신자(`recipients`), 활성 상태를 수정합니다. 인증(`auth:sanctum`)과 `core.settings.update` 권한이 필요합니다. `template` 경로 파라미터로 대상을 지정하며, 확장이 `core.notification_template.update_validation_rules` 훅으로 추가 파라미터를 검증에 넣을 수 있습니다. 관리자가 특정 채널의 알림 문구를 편집해 저장할 때 사용합니다.
### POST /api/admin/notification-templates/{template}/reset
+4 -4
View File
@@ -32,7 +32,7 @@
| page | query | integer | 아니오 | min 1 | 조회할 페이지 번호 (1부터 시작) |
| sort_order | query | string | 아니오 | `asc`, `desc` | 정렬 방향 (asc 오름차순 / desc 내림차순) |
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.notification.filter_index_rules`).
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.notification.index_validation_rules`).
**요청 예시**
@@ -215,7 +215,7 @@ HTTP/1.1 200
| --- | --- | --- | --- | --- | --- |
| ids | body | array | 예 | min 1, max 100 | 대상 리소스 식별자 배열 (대량 작업 대상) |
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.notification.filter_batch_read_rules`).
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.notification.batch_read_validation_rules`).
**요청 예시**
@@ -416,7 +416,7 @@ Authorization: Bearer {YOUR_TOKEN}
| page | query | integer | 아니오 | min 1 | 조회할 페이지 번호 (1부터 시작) |
| sort_order | query | string | 아니오 | `asc`, `desc` | 정렬 방향 (asc 오름차순 / desc 내림차순) |
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.notification.filter_index_rules`).
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.notification.index_validation_rules`).
**요청 예시**
@@ -596,7 +596,7 @@ HTTP/1.1 200
| --- | --- | --- | --- | --- | --- |
| ids | body | array | 예 | min 1, max 100 | 대상 리소스 식별자 배열 (대량 작업 대상) |
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.notification.filter_batch_read_rules`).
> 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`core.notification.batch_read_validation_rules`).
**요청 예시**
+14
View File
@@ -518,6 +518,20 @@ class TestMailRequest extends FormRequest
| `$this->input()` 사용 | 런타임 입력값으로 분기 (request body에서 직접 읽음) |
| 메서드 추출 | 규칙이 3개 이상 분기되면 `getXxxRules()` 메서드로 분리 |
| 기본값 제공 | `$this->input('mailer', 'smtp')` — 미전송 시 기본 드라이버 적용 |
| Store/Update 정책 일치 | 같은 리소스의 Store 와 Update 는 동일 필드에 동일한 조건부 규칙을 적용한다. 한쪽에만 `exclude_if` 등이 있으면 다른 쪽에서만 저장이 막힌다 |
| `sometimes` 는 부분 수정 지원 수단이 아니다 | 관리자 폼은 조회 응답 전체를 그대로 PUT 하므로 요청에는 항상 모든 키가 존재한다. 토글 OFF 상태의 종속 필드는 `sometimes` 가 아니라 `exclude_if` 로 검증에서 배제한다 |
`exclude_if` 는 규칙 배열의 **첫 번째**에 둔다. 배제는 규칙 루프 도중 발동하며 이미 추가된 실패 메시지는 회수되지 않으므로, 앞선 규칙이 먼저 실패하면 배제되어도 오류가 남는다.
```php
// ❌ DON'T: 요청에 키가 항상 있으므로 sometimes 가 발동하지 않아 빈 값이 차단된다
'allowed_extensions' => ['sometimes', 'array', 'min:1'],
// ✅ DO: 토글이 꺼져 있으면 필드 자체를 검증에서 제외한다
'allowed_extensions' => ['exclude_if:use_file_upload,false', 'sometimes', 'required', 'array', 'min:1'],
```
자동 차단: `formrequest-store-update-conditional-symmetry`(error), `formrequest-sometimes-array-min-inert`(warning).
---
+40
View File
@@ -24,6 +24,7 @@
6. [관리 화면 표시](#6-관리-화면-표시)
7. [템플릿 예시](#7-템플릿-예시)
8. [코어 버전 제약 정책](#8-코어-버전-제약-정책)
9. [다국어 파일 변경과 CHANGELOG](#9-다국어-파일-변경과-changelog)
---
@@ -259,6 +260,45 @@ setState(selectedX) → apiCall(changelog)
---
## 9. 다국어 파일 변경과 CHANGELOG
호스트(코어/모듈/플러그인/템플릿)의 다국어 파일을 변경하면 그 호스트의 CHANGELOG 에도 사용자 관점 항목을 기재한다.
다국어 변경은 대부분 사용자가 화면에서 읽는 문장의 변경이다.
| 변경 유형 | 사용자에게 보이는 결과 |
| --- | --- |
| 누락 키 추가 | 원문 키(`sirsoft-ecommerce::messages.settings.fetch_success`)가 그대로 보이던 화면이 정상 문구로 바뀐다 |
| 문구 정정 | 잘못된 안내(예: 실제 입력 한도와 다른 글자 수)가 고쳐진다 |
| 오탈자 정정 | 화면 용어가 바뀐다 |
이들 변경은 diff 상 `lang/` 아래에만 남아 CHANGELOG 를 쓰는 시점에 눈에 띄지 않는다. 실제로 이슈 #78 에서 코어·이커머스·게시판·kginicis 4개 호스트의 문구 수정이 기록 없이 누락됐고, 그중에는 로그인/역할 권한 거절 응답에 원문 키가 노출되던 사용자 관점 결함도 있었다.
### 기재 예시
```markdown
### Fixed
- 일부 안내가 실제 문장 대신 내부 식별자로 표시되던 문제를 수정했습니다. 로그인하지 않았거나 역할 권한이 없어 요청이 거절될 때가 해당됩니다.
```
### 면제
사용자에게 노출되지 않는 문구(내부 커맨드 출력, 테스트 픽스처 등)는 변경 파일 상단에 사유와 함께 면제 주석을 둔다.
```php
// changelog:allow 개발용 Artisan 커맨드 출력 문구 — 사용자 화면 비노출
```
### 자동 검증
- `host-lang-change-requires-changelog` (warn) — 변경셋에 호스트 lang 파일이 있는데 그 호스트 CHANGELOG 가 없으면 경고
- `lang-duplicate-array-key` (error) — 같은 레벨 중복 키 차단. 뒤 선언이 앞을 덮어 앞 블록 전용 키가 런타임에 소실되고 원문 키가 노출되는 것을 막는다. 정정은 항상 **두 블록 병합** (한쪽만 삭제하면 그쪽 키가 사라진다)
언어팩 패키지(`lang-packs/_bundled/**`)는 별도 룰(`lang-pack-*`)이 담당하므로 본 절의 대상이 아니다.
---
## 관련 파일
- `app/Extension/Helpers/ChangelogParser.php` — Changelog 파서
@@ -4,6 +4,20 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.7] - 2026-07-22
### Added
- 확장 식별자·메뉴 역할·활동 로그 검색 조건의 안내 문구 일본어 번역 추가 — 잘못된 값을 입력했을 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
- 모듈·플러그인 파일 업로드 시 허용되지 않는 파일 형식 안내 문구 일본어 번역 추가.
- 플러그인을 GitHub 주소 또는 ZIP 파일로 설치할 때의 입력 검증 안내 문구 일본어 번역 추가 — 주소 형식이 잘못됐거나 ZIP 이 아닌 파일을 올렸을 때, 파일 용량을 초과했을 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
## [1.0.6] - 2026-07-22
### Added
- 알림 채널 선택 항목의 검증 안내 문구 일본어 번역 추가 (`validation.notification_channel.*`) — 사용할 수 없는 알림 채널을 선택했을 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
## [1.0.5] - 2026-07-16
### Added
@@ -10,6 +10,10 @@ return [
'ids_required' => '削除するアクティビティログを選択してください。',
'ids_min' => '削除するアクティビティログを1つ以上選択してください。',
'id_not_found' => '選択されたアクティビティログが見つかりません。',
'log_type_invalid' => '選択したログ種別が正しくありません。',
'date_range_invalid' => '終了日は開始日と同じか、それ以降である必要があります。',
'per_page_min' => '1ページあたりの表示件数は1件以上である必要があります。',
'per_page_max' => '1ページあたりの表示件数は100件を超えることはできません。',
],
'type' => [
'admin' => '管理者',
@@ -104,6 +104,13 @@ return [
'install_failed' => 'プラグイン インストールに失敗しました: :error',
],
'validation' => [
'github_url_required' => 'GitHub URLは必須です。',
'github_url_invalid' => '有効なURL形式ではありません。',
'github_url_format' => 'GitHub リポジトリURL形式である必要があります。(例: https://github.com/owner/repo)',
'file_required' => 'インストールするファイルを選択してください。',
'file_invalid' => '有効なファイルではありません。',
'file_must_be_zip' => 'ZIP ファイルのみアップロードできます。',
'file_max_size' => 'ファイルサイズは :sizeMB を超えることができません。',
'plugin_name_required' => 'プラグイン名は必須です。',
'plugin_name_string' => 'プラグイン名は文字列である必要があります。',
'plugin_name_max' => 'プラグイン名は最大 :max 文字までです。',
@@ -520,6 +520,10 @@ return [
'data_source_must_have_id' => 'data_sources[:index]に必須フィールド "id" が見つかりません。',
'data_source_id_must_be_string' => 'data_sources[:index].id は文字列である必要があります。',
],
'notification_channel' => [
'invalid' => '通知チャネル識別子が正しくありません。',
'unavailable' => "利用できない通知チャネルです: ':value'",
],
'translatable' => [
'must_be_array' => '多言語フィールドは配列である必要があります。',
'unsupported_language' => 'サポートされていない言語コード: \':lang\'',
@@ -544,6 +548,11 @@ return [
],
],
'menu' => [
'roles' => [
'array' => 'ロール一覧は配列である必要があります。',
'integer' => 'ロールIDは数値である必要があります。',
'exists' => '選択したロールが見つかりません。',
],
'name' => [
'required' => 'メニュー名を入力してください。',
],
@@ -656,7 +665,18 @@ return [
'outside_base_directory' => '基本ディレクトリ外のパスは許可されていません。',
'file_type_not_allowed' => '許可されていないファイルタイプです。拡張子: :extension (許可: :allowed)',
],
'extension_identifier' => [
'max' => '拡張識別子は最大255文字まで入力できます。',
'must_be_string' => '拡張識別子は文字列である必要があります。',
'min_parts' => '拡張識別子は vendor-name 形式である必要があります(例: sirsoft-board)。',
'empty_part' => '拡張識別子に空の部分があります。ハイフンを連続させたり両端に置くことはできません。',
'invalid_characters' => '拡張識別子は英小文字・数字・アンダースコア(_)のみ使用できます。',
'empty_word' => '拡張識別子でアンダースコアを連続させたり両端に置くことはできません。',
'word_starts_with_digit' => '拡張識別子の各単語は数字で始めることはできません。',
],
'module_path' => [
'file_type_not_allowed' => '許可されていないファイルタイプです。拡張子: :extension(許可: :allowed)',
'must_be_string' => 'パスは文字列である必要があります。',
'traversal_detected' => 'パストトラバーサルが検出されました: :pattern',
'absolute_path_not_allowed' => '絶対パスは許可されません。',
@@ -664,6 +684,7 @@ return [
'outside_base_directory' => '基準ディレクトリ外へのアクセスは許可されません。',
],
'plugin_path' => [
'file_type_not_allowed' => '許可されていないファイルタイプです。拡張子: :extension(許可: :allowed)',
'must_be_string' => 'パスは文字列である必要があります。',
'traversal_detected' => 'パストトラバーサルが検出されました: :pattern',
'absolute_path_not_allowed' => '絶対パスは許可されません。',
@@ -973,6 +994,14 @@ return [
'definition_already_exists' => '同一の(provider、scope_type、scope_value)定義が既に存在します。',
],
'attributes' => [
'layout' => 'レイアウト',
'module' => 'モジュール',
'token' => 'トークン',
'mailgun_domain' => 'Mailgun ドメイン',
'mailgun_secret' => 'Mailgun シークレット',
'ses_key' => 'SES アクセスキー',
'ses_secret' => 'SES シークレットキー',
'ses_region' => 'SES リージョン',
'ids' => 'ユーザーID一覧',
'user_id' => 'ユーザーID',
'status' => 'ステータス',
@@ -12,7 +12,7 @@
"en": "G7 core Japanese language pack (bundled)",
"ja": "G7 コア 日本語 言語パック(バンドル)"
},
"version": "1.0.5",
"version": "1.0.7",
"license": "MIT",
"scope": "core",
"target_identifier": null,
@@ -4,6 +4,26 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.4] - 2026-07-22
### Added
- 게시판 대시보드 조회 안내 문구 일본어 번역 추가.
## [1.0.3] - 2026-07-22
### Added
- 게시판 환경설정의 조회·저장·일괄적용·캐시 초기화 안내 문구 일본어 번역 추가 (`messages.settings.*`) — 저장에 실패했을 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
- 댓글 비밀번호 확인, 댓글 목록 조회, 게시판 목록·폼 조회 실패, 비밀글 사용 불가 안내 문구의 일본어 번역 추가 — 지금까지 일본어 로케일에서 안내 문구 대신 내부 식별자가 그대로 보이던 화면들이 정상 표시됩니다.
- 게시판 설정 일괄 적용 화면의 입력 항목명과 검증 안내 문구, 신고 상태 변경 불가 안내, 권한 모드 선택 안내의 일본어 번역 추가.
## [1.0.2] - 2026-07-22
### Fixed
- 게시판 유형 "기본형"의 일본어 표기가 화면마다 달랐던 문제를 수정했습니다. 게시판 유형 목록과 목록 필터에서 서로 다른 한자로 표시되던 것을 하나로 통일했습니다.
## [1.0.1] - 2026-07-08
### Added
@@ -59,6 +59,7 @@ return [
'board_type_show' => '掲示板タイプ詳細閲覧 (:type_name)',
],
'fields' => [
'name' => '掲示板名',
'is_active' => '有効化',
'per_page' => 'ページあたりの投稿数',
'per_page_mobile' => 'モバイルページあたりの投稿数',
@@ -6,6 +6,7 @@ return [
'already_exists' => '既に存在する掲示板スラッグです。',
'has_posts' => '投稿がある掲示板は削除できません。',
'fetch_success' => '掲示板情報を閲覧しました。',
'fetch_failed' => '掲示板リストの取得に失敗しました。',
'create_success' => '掲示板が作成されました。',
'update_success' => '掲示板が編集されました。',
'delete_success' => '掲示板が削除されました。',
@@ -13,6 +14,7 @@ return [
'config_retrieved' => '掲示板設定を閲覧しました。',
'types_retrieved' => '掲示板タイプリストを閲覧しました。',
'form_data_retrieved' => '掲示板フォームデータを閲覧しました。',
'form_data_failed' => '掲示板フォームデータの取得に失敗しました。',
'copy_data_retrieved' => '掲示板コピーデータを閲覧しました。',
'create_failed' => '掲示板の作成に失敗しました。',
'update_failed' => '掲示板の編集に失敗しました。',
@@ -67,6 +69,7 @@ return [
'guest_write_not_allowed' => 'この掲示板は非会員による投稿が許可されていません。',
'file_upload_not_allowed' => 'この掲示板はファイルアップロードが許可されていません。',
'guest_upload_not_allowed' => 'この掲示板は非会員によるファイルアップロードが許可されていません。',
'secret_post_not_allowed' => 'この掲示板では秘密投稿を使用できません。',
'modify_permission_denied' => '投稿の編集権限がありません。',
'delete_permission_denied' => '投稿の削除権限がありません。',
'error_404' => '存在しない投稿です。',
@@ -93,9 +96,18 @@ return [
'restore_success' => 'コメントが復元されました。',
'restore_failed' => 'コメント復元に失敗しました。',
'blinded_comment_content' => '運営方針に基づいて非表示にされたコメントです。',
'deleted_comment_content' => '削除されたコメントです。',
'not_guest_comment' => '非会員のコメントではありません。',
'invalid_password' => 'パスワードが一致しません。',
'password_verified' => 'パスワードが確認されました。',
'verify_password_failed' => 'パスワードの確認に失敗しました。',
'post_blinded' => 'ブロック処理された投稿にはコメントを作成できません。',
'post_deleted' => '削除された投稿にはコメントを作成できません。',
],
'comments' => [
'comments_disabled' => 'この掲示板はコメント機能が無効化されています。',
'index_success' => 'コメントリストを閲覧しました。',
'index_failed' => 'コメントリストの取得に失敗しました。',
],
'attachment' => [
'uploaded' => 'ファイルがアップロードされました。',
@@ -231,6 +243,18 @@ return [
'stats_success' => 'アクティビティ統計を閲覧しました。',
'stats_failed' => 'アクティビティ統計の閲覧に失敗しました。',
],
'settings' => [
'fetch_success' => '設定を取得しました。',
'fetch_failed' => '設定の取得に失敗しました。',
'save_success' => '設定を保存しました。',
'save_failed' => '設定の保存に失敗しました。',
'save_error' => '設定の保存中にエラーが発生しました。',
'bulk_apply_success' => ':count件の掲示板に設定を一括適用しました。',
'bulk_apply_aborted' => '一括適用中にエラーが発生したため、すべての変更を元に戻しました。',
'bulk_apply_failed' => '設定の一括適用に失敗しました。',
'clear_cache_success' => '設定キャッシュを初期化しました。',
'clear_cache_error' => '設定キャッシュの初期化中にエラーが発生しました。',
],
'invalid_slug_format' => 'スラッグの形式が正しくありません。小文字で始まり、小文字、数字、ハイフンのみ使用できます。(スラッグ: :slug)',
'invalid_slug_length' => 'スラッグの長さが正しくありません。1~50文字以内で入力してください。(スラッグ: :slug)',
'table_already_exists' => 'テーブルは既に存在します。(テーブル: :table)',
@@ -239,6 +263,7 @@ return [
'table_drop_failed' => 'テーブルの削除に失敗しました。(スラッグ: :slug、エラー: :error)',
'common' => [
'guest' => '非会員',
'system' => 'システム',
'status' => [
'published' => '公開中',
'blinded' => 'ブラインド',
@@ -260,4 +285,7 @@ return [
'inquiry' => [
'default_title' => '商品お問い合わせ',
],
'dashboard' => [
'fetch_success' => 'ダッシュボードデータを閲覧しました。',
],
];
@@ -2,6 +2,13 @@
return [
'fields_invalid' => '選択した :value フィールドは許可されていません。',
'bulk_apply' => [
'fields.required' => '一括適用する項目を1つ以上選択してください。',
'fields.min' => '一括適用する項目を1つ以上選択してください。',
'apply_all.required' => '全掲示板に適用するかどうかを指定してください。',
'board_ids.required_if' => '全体適用でない場合は対象掲示板を選択してください。',
'board_ids.*.exists' => '存在しない掲示板です。',
],
'slug' => [
'required' => '掲示板スラッグは必須です。',
'format' => '掲示板スラッグは英小文字で始まり、英小文字、数字、ハイフン(-)のみ使用できます。',
@@ -106,6 +113,10 @@ return [
'min' => '権限に最低1つ以上のロールを選択してください。',
'exists' => '存在しないロールです。',
],
'mode' => [
'required' => '権限モードを選択してください。',
'in' => '無効な権限モードです。',
],
],
'max_reply_depth' => [
'min' => '返信の最大階層は最小 :min 以上である必要があります。',
@@ -135,6 +146,7 @@ return [
'hours_minutes' => ':hours 時間 :minutes 分',
],
'board' => [
'not_found' => '掲示板が見つかりません。',
'name' => [
'required' => '掲示板名は必須です。',
'string' => '掲示板名は文字列である必要があります。',
@@ -315,6 +327,28 @@ return [
],
'board' => [
'add_to_menu' => '管理者メニューに表示',
'blocked_keywords' => '禁止ワード',
],
'bulk_apply' => [
'fields' => '適用項目',
'fields.*' => '適用項目',
'apply_all' => '全掲示板に適用',
'board_ids' => '対象掲示板',
'board_ids.*' => '対象掲示板',
'override_values' => '上書き値',
'override_values.per_page' => 'ページあたりの投稿数',
'override_values.per_page_mobile' => 'モバイルページあたりの投稿数',
'override_values.max_reply_depth' => '最大返信深さ',
'override_values.max_comment_depth' => '最大コメント深さ',
'override_values.min_title_length' => '最小タイトル長',
'override_values.max_title_length' => '最大タイトル長',
'override_values.min_content_length' => '最小本文長',
'override_values.max_content_length' => '最大本文長',
'override_values.min_comment_length' => '最小コメント長',
'override_values.max_comment_length' => '最大コメント長',
'override_values.max_file_size' => '最大ファイルサイズ',
'override_values.max_file_count' => '最大ファイル個数',
'override_values.new_display_hours' => '新規表示時間',
],
],
'blind' => [
@@ -465,6 +499,7 @@ return [
],
],
'report' => [
'invalid_status_transition' => '現在の通報ステータスからそのステータスへは変更できません。',
'status' => [
'required' => '通報ステータスは必須です。',
'in' => '無効な通報ステータスです。',
@@ -997,7 +997,7 @@
"relative_example": "例) 3分前 · 2時間前 · 5日前 · 3個月前 · 2年前"
},
"type": {
"basic": "基本型",
"basic": "基本形",
"gallery": "ギャラリー型",
"card": "カード型"
},
@@ -11,7 +11,7 @@
"empty_title": "掲示板がありません",
"empty_description": "登録された掲示板がありません。",
"filter_all_types": "すべてのタイプ",
"filter_type_basic": "基本型",
"filter_type_basic": "基本形",
"filter_type_gallery": "ギャラリー型",
"filter_type_card": "カード型",
"status": "ステータス",
@@ -12,7 +12,7 @@
"en": "G7 module (sirsoft-board) Japanese language pack (bundled)",
"ja": "G7 モジュール (sirsoft-board) 日本語 言語パック(バンドル)"
},
"version": "1.0.1",
"version": "1.0.4",
"license": "MIT",
"scope": "module",
"target_identifier": "sirsoft-board",
@@ -4,12 +4,28 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.6] - 2026-07-22
### Added
- 쇼핑몰 환경설정·카테고리 이미지·결제 설정 안내 문구 일본어 번역 추가 — 저장/조회 결과 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
- 검색 프리셋·브랜드·배송사·배송정책 목록 안내 문구 일본어 번역 추가.
### Fixed
- 주문 관리자 메모 글자 수 안내가 실제 입력 한도(2000자)와 다른 "1000자"로 표시되던 문제를 수정했습니다.
## [1.0.5] - 2026-07-19
### Removed
- 더 이상 사용하지 않는 장바구니 옵션 조합 검증 문구의 일본어 번역을 정리했습니다.
### Added
- 리뷰 작성·답글·일괄처리, 장바구니 선택 항목, 주문서 쿠폰 적용, 클레임 사유 관리, 배송비 검색 조건의 안내 문구 일본어 번역을 추가했습니다. 지금까지 일본어 로케일에서 안내 대신 내부 식별자가 그대로 보이던 화면들이 정상 표시됩니다.
- 택배사·상품 공통정보·주문 취소 관련 오류 안내와 상품 문의 답변 상태, 기본 배송 그룹 표시명의 일본어 번역을 추가했습니다.
## [1.0.4] - 2026-07-16
### Added
@@ -181,6 +181,7 @@ return [
'user_shipping_country_change' => '会員配送国変更',
],
'fields' => [
'website' => 'ウェブサイト',
'is_active' => '有効化否か',
'is_default' => 'デフォルト',
'sort_order' => '並べ替え順序',
@@ -0,0 +1,16 @@
<?php
/**
* 이커머스 모듈 관리자 라벨
*
* 관리자 API 응답에서 사용하는 표시용 라벨
*/
return [
// 상품 문의
'inquiries' => [
'status' => [
'answered' => '回答済み',
'pending' => '未回答',
],
],
];
@@ -52,6 +52,9 @@ return [
'claim_reason_in_use' => '注文キャンセルで使用中の理由は削除できません。(使用回数: :count件)',
'shipping_type_not_found' => '配送タイプが見つかりません。',
'shipping_type_in_use' => '注文で使用中の配送タイプ(:name)は削除できません。(使用回数: :count件)',
'shipping_carrier_not_found' => '配送業者が見つかりません。',
'shipping_carrier_in_use' => '注文で使用中の配送業者は削除できません。(使用回数: :count件)',
'order_cancel_not_allowed' => '決済待ち状態の注文のみキャンセルできます。',
'unauthorized' => 'この注文へのアクセス権限がありません。',
'purchase_not_allowed' => '購入権限のない商品です。',
'order_cancel_failed' => '注文キャンセル処理に失敗しました。',
@@ -70,6 +73,7 @@ return [
'min_purchase_qty_not_met' => '最小購入数量は:limit個です。(リクエスト::requested個)',
'max_purchase_qty_exceeded' => '最大購入数量は:limit個です。(リクエスト::requested個)',
'product_notice_template_not_found' => '商品情報提供告示テンプレートが見つかりません。',
'product_common_info_not_found' => '商品共通情報が見つかりません。',
'product_image_limit_exceeded' => '画像は最大 :max 枚までアップロードできます。',
'additional_option_invalid' => '選択された追加オプションが有効ではありません。',
'additional_option_required' => '必須の追加オプション(:name)を選択してください。',
@@ -10,6 +10,28 @@ return [
'updated' => 'カテゴリが編集されました。',
'deleted' => 'カテゴリが削除されました。',
'has_products' => '(関連商品 :count個)',
'fetch_success' => 'カテゴリ一覧を閲覧しました。',
'fetch_failed' => 'カテゴリ一覧の取得に失敗しました。',
'status_changed' => 'カテゴリの状態が変更されました。',
'order_updated' => 'カテゴリの順序が変更されました。',
],
'category_images' => [
'uploaded' => 'カテゴリ画像がアップロードされました。',
'not_found' => 'カテゴリ画像が見つかりません。',
'deleted' => 'カテゴリ画像が削除されました。',
'reordered' => 'カテゴリ画像の順序が変更されました。',
],
'settings' => [
'fetch_success' => '設定を閲覧しました。',
'fetch_failed' => '設定の取得に失敗しました。',
'save_success' => '設定が保存されました。',
'save_failed' => '設定の保存に失敗しました。',
'save_error' => '設定の保存中にエラーが発生しました。',
'update_success' => '設定が更新されました。',
'update_failed' => '設定の更新に失敗しました。',
'update_error' => '設定の更新中にエラーが発生しました。',
'cache_clear_success' => '設定キャッシュが削除されました。',
'cache_clear_error' => '設定キャッシュの削除中にエラーが発生しました。',
],
'products' => [
'not_found' => '商品が見つかりません。',
@@ -43,6 +65,7 @@ return [
'deleted' => '検索プリセットが削除されました。',
'name_exists' => '同じ名前のプリセットが既に存在します。',
'not_owner' => '該当プリセットを編集する権限がありません。',
'fetch_success' => '検索プリセット一覧を閲覧しました。',
],
'product_image' => [
'not_found' => '商品画像が見つかりません。',
@@ -61,6 +84,7 @@ return [
'created' => 'ブランドが登録されました。',
'updated' => 'ブランドが編集されました。',
'deleted' => 'ブランドが削除されました。',
'status_changed' => 'ブランドの状態が変更されました。',
'has_products' => '(関連商品 :count個)',
],
'options' => [
@@ -71,6 +95,7 @@ return [
'bulk_update_success' => ':count個のオプションが編集されました。',
'bulk_update_failed' => 'オプション一括アップデートに失敗しました。',
'has_order_history' => '注文履歴のあるオプションは削除できません。',
'bulk_updated' => ':count個のオプションが編集されました。',
],
'logs' => [
'product_created' => '商品登録',
@@ -135,6 +160,8 @@ return [
'bulk_status_updated' => ':count件の注文のステータスが変更されました。',
'bulk_shipping_updated' => ':count件の注文の配送情報が編集されました。',
'bulk_update_failed' => '一括処理に失敗しました。',
'update_failed' => '注文の編集に失敗しました。',
'delete_failed' => '注文の削除に失敗しました。',
'status_changed' => '注文ステータスが:status(に)変更されました。',
'shipping_registered' => '配送情報が登録されました。',
'export_success' => 'Excelファイルが作成されました。',
@@ -350,6 +377,7 @@ return [
'updated' => '配送業者が編集されました。',
'deleted' => '配送業者が削除されました。',
'toggled' => '配送業者ステータスが変更されました。',
'status_changed' => '配送業者ステータスが変更されました。',
'delete_failed_in_use' => '注文で使用中の配送業者は削除できません。無効化をお勧めします。',
'type_domestic' => '国内',
'type_international' => '国際',
@@ -401,6 +429,7 @@ return [
'toggled' => '配送ポリシー使用有無が変更されました。',
'copied' => '配送ポリシーがコピーされました。',
'bulk_deleted' => ':count個の配送ポリシーが削除されました。',
'active_list_retrieved' => '使用中の配送ポリシー一覧を閲覧しました。',
'bulk_toggled' => ':count個の配送ポリシーの使用有無が変更されました。',
'set_default_success' => 'デフォルト配送ポリシーが設定されました。',
'fee_summary' => [
@@ -0,0 +1,11 @@
<?php
/**
* 이커머스 모듈 주문 라벨
*
* 주문 화면/응답에서 사용하는 표시용 라벨
*/
return [
// 배송정책이 지정되지 않은 배송 그룹의 기본 표시명
'default_shipping' => '基本配送',
];
@@ -155,6 +155,7 @@ return [
'array' => '配送国家は配列形式である必要があります。',
'string' => '配送国家は文字列である必要があります。',
'in' => '正しい配送国家を選択してください。',
'max' => '配送国コードは最大:max文字まで入力可能です。',
],
'category_id' => [
'integer' => 'カテゴリーIDは数字である必要があります。',
@@ -611,7 +612,7 @@ return [
'requires_status' => '送り状番号を入力する場合は、配送ステータスも一緒に変更してください。',
],
'admin_memo' => [
'max' => '管理者メモは最大1000文字まで入力可能です。',
'max' => '管理者メモは最大2000文字まで入力可能です。',
],
'recipient_name' => [
'required' => '受取人名を入力してください。',
@@ -704,6 +705,17 @@ return [
'integer' => '最大金額は数字である必要があります。',
'min' => '最大金額は0以上である必要があります。',
],
'min_shipping_amount' => [
'integer' => '最小送料は数字である必要があります。',
'min' => '最小送料は0以上である必要があります。',
],
'max_shipping_amount' => [
'integer' => '最大送料は数字である必要があります。',
'min' => '最大送料は0以上である必要があります。',
],
'shipping_policy_id' => [
'integer' => '配送ポリシーIDは数字である必要があります。',
],
'country_codes' => [
'array' => '国別コードは配列形式である必要があります。',
'string' => '国別コードは文字列である必要があります。',
@@ -845,6 +857,12 @@ return [
'item_not_found' => 'カート商品が見つかりません。',
'items_required' => 'カートに追加する商品を選択してください。',
'items_min' => '最低1つ以上の商品を選択してください。',
'selected_ids_array' => '選択されたカート商品IDは配列形式である必要があります。',
'selected_ids_integer' => '選択されたカート商品IDは数字である必要があります。',
'selected_ids_min' => '選択されたカート商品IDは1以上である必要があります。',
'cart_key_required' => '非会員カートキーが必要です。',
'invalid_cart_key' => '無効なカートキー形式です。',
'login_required' => 'ログインが必要です。',
],
'wishlist' => [
'product_id_required' => '商品を選択してください。',
@@ -869,6 +887,14 @@ return [
'region_max' => '地域は最大100文字まで入力可能です。',
'city_max' => '都市は最大100文字まで入力可能です。',
'address_max' => '住所は最大255文字まで入力可能です。',
'item_coupons_array' => '商品別クーポンは配列形式である必要があります。',
'item_coupons_max' => '商品ごとにクーポンは最大:max個まで適用できます。',
'item_coupon_integer' => '商品クーポンIDは数字である必要があります。',
'item_coupon_not_found' => '存在しない商品クーポンです。',
'order_coupon_integer' => '注文クーポンIDは数字である必要があります。',
'order_coupon_not_found' => '存在しない注文クーポンです。',
'shipping_coupon_integer' => '送料クーポンIDは数字である必要があります。',
'shipping_coupon_not_found' => '存在しない送料クーポンです。',
],
'shipping_policy' => [
'ids_required' => '変更する配送ポリシーを選択してください。',
@@ -997,15 +1023,56 @@ return [
],
'rating' => [
'in' => '正しい評価を選択してください。',
'required' => '評価を選択してください。',
'integer' => '評価は数字である必要があります。',
'min' => '評価は:min点以上である必要があります。',
'max' => '評価は:max点以下である必要があります。',
],
'reply_status' => [
'in' => '正しい返信ステータスを選択してください。',
],
'product_id' => [
'required' => '商品を選択してください。',
'exists' => '存在しない商品です。',
],
'order_option_id' => [
'required' => 'レビューを作成する注文商品を選択してください。',
'exists' => '存在しない注文商品です。',
],
'content' => [
'required' => 'レビュー内容を入力してください。',
'min' => 'レビュー内容は最低:min文字以上入力してください。',
'max' => 'レビュー内容は最大:max文字まで入力可能です。',
],
'content_mode' => [
'in' => '正しいレビュー内容形式を選択してください。',
],
'reply_content' => [
'required' => '返信内容を入力してください。',
'min' => '返信内容は最低:min文字以上入力してください。',
'max' => '返信内容は最大:max文字まで入力可能です。',
],
'reply_content_mode' => [
'in' => '正しい返信内容形式を選択してください。',
],
'ids' => [
'required' => '処理するレビューを選択してください。',
'array' => 'レビューIDは配列形式である必要があります。',
'min' => '最低1つ以上のレビューを選択してください。',
'integer' => 'レビューIDは数字である必要があります。',
'exists' => '存在しないレビューです。',
],
'action' => [
'required' => '処理方式を選択してください。',
'in' => '正しい処理方式を選択してください。',
],
'has_photo' => [
'boolean' => 'フォトレビュー フィルターは true または false である必要があります。',
],
'status' => [
'in' => '正しいレビュー ステータスを選択してください。',
'required' => 'レビューステータスを選択してください。',
'required_if' => 'ステータス変更を選択した場合はレビューステータスも選択してください。',
],
'start_date' => [
'date' => '開始日は日付形式である必要があります。',
@@ -1246,6 +1313,14 @@ return [
'country_settings.*.api_config.response_type' => 'レスポンス形式',
'country_settings.*.api_config.response_path' => 'レスポンス配送料金パス',
'language_currency.currencies.*.base_unit' => '基準単位',
'claim_reason_type' => 'クレーム区分',
'claim_reason_code' => 'クレーム理由コード',
'claim_reason_name' => 'クレーム理由名',
'claim_reason_fault_type' => '責任区分',
'claim_reason_is_user_selectable' => 'ユーザー選択可否',
'claim_reason_is_active' => '使用可否',
'claim_reason_sort_order' => '並び順',
'claim_reason_search' => '検索語',
],
'custom' => [
'basic_info' => [
@@ -1506,9 +1581,17 @@ return [
'boolean' => 'キャンセル時の在庫復旧の可否は真偽値である必要があります。',
],
],
'claim' => [
'refund_reasons' => [
'duplicate_code' => '重複した返金理由コードがあります。',
'name_required' => '返金理由名は必須項目です。',
],
],
'shipping' => [
'default_country' => [
'must_exist_in_countries' => '既定の配送国は配送可能国の一覧に存在している必要があります。',
'string' => '既定の配送国は文字列である必要があります。',
'max' => '既定の配送国は最大10文字まで入力可能です。',
],
'available_countries' => [
'array' => '配送可能国は配列形式である必要があります。',
@@ -12,7 +12,7 @@
"en": "G7 module (sirsoft-ecommerce) Japanese language pack (bundled)",
"ja": "G7 モジュール (sirsoft-ecommerce) 日本語 言語パック(バンドル)"
},
"version": "1.0.5",
"version": "1.0.6",
"license": "MIT",
"scope": "module",
"target_identifier": "sirsoft-ecommerce",
@@ -4,6 +4,12 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.2] - 2026-07-22
### Added
- 페이지 활동 로그의 "제목"·"내용" 변경 항목 라벨 일본어 번역을 추가했습니다. 일본어 로케일에서 항목명이 비어 보이던 문제가 해소됩니다.
## [1.0.1] - 2026-07-08
### Added
@@ -26,6 +26,8 @@ return [
'page_attachment_reorder' => 'ページ添付ファイルの並べ替え (ページ: :title)',
],
'fields' => [
'title' => 'タイトル',
'content' => '内容',
'slug' => 'スラッグ',
'content_mode' => 'コンテンツモード',
'published' => '公開状態',
@@ -12,7 +12,7 @@
"en": "G7 module (sirsoft-page) Japanese language pack (bundled)",
"ja": "G7 モジュール (sirsoft-page) 日本語 言語パック(バンドル)"
},
"version": "1.0.1",
"version": "1.0.2",
"license": "MIT",
"scope": "module",
"target_identifier": "sirsoft-page",
@@ -4,6 +4,18 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.3] - 2026-07-22
### Added
- 해외결제(CBT) 편의점·가상계좌 입금 시뮬레이션 안내 문구 일본어 번역 추가.
## [1.0.2] - 2026-07-22
### Added
- 해외결제(CBT) 연결 진단 결과 안내의 일본어 번역을 추가했습니다. 일본어 로케일에서 안내 대신 내부 식별자가 보이던 문제가 해소됩니다.
## [1.0.1] - 2026-07-16
### Added
@@ -6,11 +6,17 @@ return [
'cash_receipt_missing_issue_number' => '識別番号を入力してください。',
'cash_receipt_already_issued' => 'すでに現金領収書が発行された決済です。',
'cash_receipt_issue_failed' => '現金領収書の発行に失敗しました。',
'order_not_found' => '注文が見つかりません。',
'cbt_failed' => '海外決済の処理に失敗しました。',
],
'refund' => [
'missing_tid' => 'トランザクション ID(TID)がないため、返金を進めることができません。',
'default_reason' => '購入者返金要求',
],
'cbt_connectivity' => [
'checked' => '接続診断が完了しました。',
'check_failed' => '接続診断に失敗しました。',
],
'cbt_reconciliation' => [
'not_retryable' => '再試行可能な CBT 返金待機件ではありません。',
'retry_success' => 'CBT 返金再試行が完了しました。',
@@ -33,4 +39,14 @@ return [
'japan_contact_opening_hours_required' => 'ライブ日本決済画面に表示するお問い合わせ営業時間が必要です。',
'replace_sample_value' => 'ライブモードではサンプル値の代わりに実際の契約情報を入力してください。',
],
'cbt_cvs' => [
'simulate_success' => '入金シミュレーションが完了しました。',
'simulate_failed' => '入金シミュレーションに失敗しました。',
'expire_success' => '仮想口座の入金期限が満了処理されました。',
'recheck_success' => '入金状態を再確認しました。',
'not_test_mode' => 'テストモードでのみ利用できます。',
'not_waiting_deposit' => '入金待ちの決済のみ処理できます。',
'not_expirable' => '満了処理できない決済です。',
'not_cvs' => 'コンビニ・仮想口座決済ではありません。',
],
];
@@ -12,7 +12,7 @@
"en": "G7 plugin (sirsoft-pay_kginicis) Japanese language pack (bundled)",
"ja": "G7 プラグイン (sirsoft-pay_kginicis) 日本語 言語パック(バンドル)"
},
"version": "1.0.1",
"version": "1.0.3",
"license": "MIT",
"scope": "plugin",
"target_identifier": "sirsoft-pay_kginicis",
+4
View File
@@ -13,6 +13,10 @@ return [
'ids_required' => 'Please select activity logs to delete.',
'ids_min' => 'Please select at least one activity log to delete.',
'id_not_found' => 'The selected activity log was not found.',
'log_type_invalid' => 'The selected log type is invalid.',
'date_range_invalid' => 'The end date must be the same as or after the start date.',
'per_page_min' => 'Items per page must be at least 1.',
'per_page_max' => 'Items per page must not exceed 100.',
],
// Log type labels
+11 -12
View File
@@ -126,17 +126,6 @@ return [
'install_failed' => 'Plugin installation failed: :error',
],
// Validation messages (FormRequest)
'validation' => [
'github_url_required' => 'GitHub URL is required.',
'github_url_invalid' => 'Invalid URL format.',
'github_url_format' => 'Must be a GitHub repository URL. (e.g., https://github.com/owner/repo)',
'file_required' => 'Please select a file to install.',
'file_invalid' => 'Invalid file.',
'file_must_be_zip' => 'Only ZIP files can be uploaded.',
'file_max_size' => 'File size must not exceed :sizeMB.',
],
// Artisan command messages
'commands' => [
'list' => [
@@ -249,8 +238,18 @@ return [
'updated' => 'Plugin settings updated successfully.',
],
// Validation messages
// Validation messages (FormRequest)
'validation' => [
// GitHub / file upload installation
'github_url_required' => 'GitHub URL is required.',
'github_url_invalid' => 'Invalid URL format.',
'github_url_format' => 'Must be a GitHub repository URL. (e.g., https://github.com/owner/repo)',
'file_required' => 'Please select a file to install.',
'file_invalid' => 'Invalid file.',
'file_must_be_zip' => 'Only ZIP files can be uploaded.',
'file_max_size' => 'File size must not exceed :sizeMB.',
// Plugin name / listing
'plugin_name_required' => 'Plugin name is required.',
'plugin_name_string' => 'Plugin name must be a string.',
'plugin_name_max' => 'Plugin name must not exceed :max characters.',
+32 -17
View File
@@ -566,6 +566,12 @@ return [
'data_source_id_must_be_string' => 'data_sources[:index].id must be a string.',
],
// Notification channel validation messages
'notification_channel' => [
'invalid' => 'The notification channel identifier is invalid.',
'unavailable' => "Unavailable notification channel: ':value'",
],
// Translatable field validation messages
'translatable' => [
'must_be_array' => 'Translatable field must be an array.',
@@ -577,25 +583,13 @@ return [
'current_locale_required' => 'The :locale language value is required.',
],
// Template validation messages
'template' => [
'type' => [
'in' => 'The type parameter must be either user or admin.',
],
'description' => [
'string' => 'The template description must be a string.',
'max' => 'The template description may not be greater than :max characters.',
],
'metadata' => [
'array' => 'metadata must be an array.',
],
'status' => [
'in' => 'status must be either active or inactive.',
],
],
// Menu validation messages
'menu' => [
'roles' => [
'array' => 'The roles must be an array.',
'integer' => 'Each role ID must be an integer.',
'exists' => 'The selected role could not be found.',
],
'name' => [
'required' => 'Please enter a menu name.',
],
@@ -736,7 +730,19 @@ return [
],
// Module path validation messages
// Extension identifier validation messages (ValidExtensionIdentifier Rule)
'extension_identifier' => [
'max' => 'The extension identifier must not exceed 255 characters.',
'must_be_string' => 'The extension identifier must be a string.',
'min_parts' => 'The extension identifier must be in vendor-name format (e.g., sirsoft-board).',
'empty_part' => 'The extension identifier has an empty part. Hyphens cannot be consecutive or at the edges.',
'invalid_characters' => 'The extension identifier may only contain lowercase letters, numbers, and underscores (_).',
'empty_word' => 'The extension identifier has consecutive or edge underscores.',
'word_starts_with_digit' => 'Each word in the extension identifier must not start with a digit.',
],
'module_path' => [
'file_type_not_allowed' => 'File type not allowed. Extension: :extension (Allowed: :allowed)',
'must_be_string' => 'The path must be a string.',
'traversal_detected' => 'Path traversal detected: :pattern',
'absolute_path_not_allowed' => 'Absolute paths are not allowed.',
@@ -746,6 +752,7 @@ return [
// Plugin path validation messages
'plugin_path' => [
'file_type_not_allowed' => 'File type not allowed. Extension: :extension (Allowed: :allowed)',
'must_be_string' => 'The path must be a string.',
'traversal_detected' => 'Path traversal detected: :pattern',
'absolute_path_not_allowed' => 'Absolute paths are not allowed.',
@@ -1177,5 +1184,13 @@ return [
// Changelog fields
'from_version' => 'start version',
'to_version' => 'end version',
'layout' => 'layout',
'module' => 'module',
'token' => 'token',
'mailgun_domain' => 'Mailgun domain',
'mailgun_secret' => 'Mailgun secret',
'ses_key' => 'SES access key',
'ses_secret' => 'SES secret key',
'ses_region' => 'SES region',
],
];
+4
View File
@@ -13,6 +13,10 @@ return [
'ids_required' => '삭제할 활동 로그를 선택해주세요.',
'ids_min' => '삭제할 활동 로그를 하나 이상 선택해주세요.',
'id_not_found' => '선택한 활동 로그를 찾을 수 없습니다.',
'log_type_invalid' => '선택한 로그 유형이 올바르지 않습니다.',
'date_range_invalid' => '종료일은 시작일과 같거나 이후여야 합니다.',
'per_page_min' => '페이지당 표시 개수는 1개 이상이어야 합니다.',
'per_page_max' => '페이지당 표시 개수는 100개를 초과할 수 없습니다.',
],
// 로그 유형 라벨
+11 -12
View File
@@ -126,17 +126,6 @@ return [
'install_failed' => '플러그인 설치에 실패했습니다: :error',
],
// validation 메시지 (FormRequest)
'validation' => [
'github_url_required' => 'GitHub URL은 필수입니다.',
'github_url_invalid' => '유효한 URL 형식이 아닙니다.',
'github_url_format' => 'GitHub 저장소 URL 형식이어야 합니다. (예: https://github.com/owner/repo)',
'file_required' => '설치할 파일을 선택해 주세요.',
'file_invalid' => '유효한 파일이 아닙니다.',
'file_must_be_zip' => 'ZIP 파일만 업로드할 수 있습니다.',
'file_max_size' => '파일 크기는 :sizeMB를 초과할 수 없습니다.',
],
// Artisan 커맨드 메시지
'commands' => [
'list' => [
@@ -249,8 +238,18 @@ return [
'updated' => '플러그인 설정이 성공적으로 업데이트되었습니다.',
],
// 검증 메시지
// 검증 메시지 (FormRequest)
'validation' => [
// GitHub / 파일 업로드 설치
'github_url_required' => 'GitHub URL은 필수입니다.',
'github_url_invalid' => '유효한 URL 형식이 아닙니다.',
'github_url_format' => 'GitHub 저장소 URL 형식이어야 합니다. (예: https://github.com/owner/repo)',
'file_required' => '설치할 파일을 선택해 주세요.',
'file_invalid' => '유효한 파일이 아닙니다.',
'file_must_be_zip' => 'ZIP 파일만 업로드할 수 있습니다.',
'file_max_size' => '파일 크기는 :sizeMB를 초과할 수 없습니다.',
// 플러그인 이름 / 목록 조회
'plugin_name_required' => '플러그인 이름은 필수입니다.',
'plugin_name_string' => '플러그인 이름은 문자열이어야 합니다.',
'plugin_name_max' => '플러그인 이름은 최대 :max자까지 입력할 수 있습니다.',
+32 -15
View File
@@ -565,6 +565,12 @@ return [
'data_source_id_must_be_string' => 'data_sources[:index].id는 문자열이어야 합니다.',
],
// 알림 채널 검증 메시지
'notification_channel' => [
'invalid' => '알림 채널 식별자가 올바르지 않습니다.',
'unavailable' => "사용할 수 없는 알림 채널입니다: ':value'",
],
// 다국어 필드 검증 메시지
'translatable' => [
'must_be_array' => '다국어 필드는 배열이어야 합니다.',
@@ -577,24 +583,14 @@ return [
],
// 템플릿 검증 메시지
'template' => [
'type' => [
'in' => 'type 파라미터는 user 또는 admin만 가능합니다.',
],
'description' => [
'string' => '템플릿 설명은 문자열이어야 합니다.',
'max' => '템플릿 설명은 :max자를 초과할 수 없습니다.',
],
'metadata' => [
'array' => 'metadata는 배열이어야 합니다.',
],
'status' => [
'in' => 'status는 active 또는 inactive여야 합니다.',
],
],
// 메뉴 검증 메시지
'menu' => [
'roles' => [
'array' => '역할 목록은 배열이어야 합니다.',
'integer' => '역할 ID는 숫자여야 합니다.',
'exists' => '선택한 역할을 찾을 수 없습니다.',
],
'name' => [
'required' => '메뉴 이름을 입력해주세요.',
],
@@ -734,8 +730,20 @@ return [
'file_type_not_allowed' => '허용되지 않은 파일 타입입니다. 확장자: :extension (허용: :allowed)',
],
// 확장 식별자 검증 메시지 (ValidExtensionIdentifier Rule)
'extension_identifier' => [
'max' => '확장 식별자는 최대 255자까지 입력 가능합니다.',
'must_be_string' => '확장 식별자는 문자열이어야 합니다.',
'min_parts' => '확장 식별자는 vendor-name 형식이어야 합니다 (예: sirsoft-board).',
'empty_part' => '확장 식별자에 빈 부분이 있습니다. 하이픈이 연속되거나 양끝에 올 수 없습니다.',
'invalid_characters' => '확장 식별자는 영문 소문자, 숫자, 언더스코어(_)만 사용할 수 있습니다.',
'empty_word' => '확장 식별자에서 언더스코어가 연속되거나 양끝에 올 수 없습니다.',
'word_starts_with_digit' => '확장 식별자의 각 단어는 숫자로 시작할 수 없습니다.',
],
// 모듈 경로 검증 메시지
'module_path' => [
'file_type_not_allowed' => '허용되지 않은 파일 타입입니다. 확장자: :extension (허용: :allowed)',
'must_be_string' => '경로는 문자열이어야 합니다.',
'traversal_detected' => '경로 트래버설이 감지되었습니다: :pattern',
'absolute_path_not_allowed' => '절대 경로는 허용되지 않습니다.',
@@ -745,6 +753,7 @@ return [
// 플러그인 경로 검증 메시지
'plugin_path' => [
'file_type_not_allowed' => '허용되지 않은 파일 타입입니다. 확장자: :extension (허용: :allowed)',
'must_be_string' => '경로는 문자열이어야 합니다.',
'traversal_detected' => '경로 트래버설이 감지되었습니다: :pattern',
'absolute_path_not_allowed' => '절대 경로는 허용되지 않습니다.',
@@ -1168,5 +1177,13 @@ return [
'websocket_host' => '웹소켓 호스트',
'websocket_port' => '웹소켓 포트',
'websocket_scheme' => '웹소켓 프로토콜',
'layout' => '레이아웃',
'module' => '모듈',
'token' => '토큰',
'mailgun_domain' => 'Mailgun 도메인',
'mailgun_secret' => 'Mailgun 시크릿',
'ses_key' => 'SES 액세스 키',
'ses_secret' => 'SES 시크릿 키',
'ses_region' => 'SES 리전',
],
];
@@ -16,6 +16,20 @@
- 사이트맵을 만들 때 게시판과 게시글을 한 번에 모두 메모리에 올리지 않고 나눠서 읽도록 바꿨습니다. 게시글이 많은 사이트에서 사이트맵 생성 중 메모리가 부족해 실패하던 문제가 줄어듭니다.
- 개선된 사이트맵 생성 기능을 사용하기 위해 이 모듈은 이제 코어 7.0.6 이상이 필요합니다.
### Fixed
- 첨부파일을 사용하지 않는 게시판을 아무것도 바꾸지 않고 저장해도 "허용 파일 확장자를 최소 1개 이상 입력하세요" 오류가 나며 저장이 막히던 문제를 수정했습니다. 첨부를 쓰지 않는 게시판에서는 허용 확장자를 비워 두어도 정상적으로 저장됩니다. 게시판 환경설정의 기본값도 동일하게 동작합니다. (#78 @jiwonpapa 님께서 제보해주셨습니다.)
- 새 글 표시 기간을 "0시간"(새 글 표시 안 함)으로 저장할 수 없던 문제를 수정했습니다. 0은 원래 새 글 배지를 끄는 값으로 동작하도록 되어 있었으나 저장 단계에서 막혀 있었습니다.
- 게시판 환경설정에서 제목·댓글 최소 길이를 0(제한 없음)으로 저장할 수 없던 문제를 수정했습니다. 내용 최소 길이는 0이 허용되어 같은 화면에서 항목마다 동작이 달랐습니다.
- 허용 확장자를 지정하지 않은 게시판에서 어떤 파일도 첨부할 수 없던 문제를 수정했습니다. 파일 선택 단계에서 막히던 것과 서버에서 거부되던 것을 함께 고쳐, 확장자를 지정하지 않은 게시판은 기본 확장자 목록을 사용합니다. 첨부 안내에 확장자가 비어 표시되던 것도 함께 해소됩니다. 확장자를 지정한 게시판은 지금처럼 목록에 없는 파일을 계속 거부합니다.
- 게시판 알림 설정에서 저장에 실패해도 아무 안내 없이 넘어가던 화면을 수정했습니다. 실패하면 그 이유가 화면에 표시됩니다.
- 일부 안내 문구가 실제 문장 대신 내부 식별자(`sirsoft-board::messages....` 같은 문자열)로 표시되던 문제를 수정했습니다. 첨부파일이 있는 게시판을 찾지 못했을 때, 댓글 목록을 불러오지 못했을 때, 비회원 댓글의 비밀번호를 확인할 때, 삭제·블라인드된 글에 댓글을 달려 할 때, 권한이 없어 거절될 때가 해당됩니다.
- 비밀글을 쓸 수 없는 게시판에서 비밀글로 저장하려 할 때, 답글 대상 게시판을 찾지 못했을 때, 신고 상태를 바꿀 수 없는 상태로 변경하려 할 때 나오던 안내가 정상 문장으로 표시됩니다.
- 댓글 수정, 신고 접수·처리, 블라인드·복원, 게시판 설정 일괄 적용 화면에서 오류 안내에 항목 이름이 영문 필드명으로 나오던 것을 한글 항목명으로 바로잡았습니다.
- 게시판 이름을 바꿨을 때 활동 로그의 변경 항목 이름이 비어 보이던 문제를 수정했습니다.
- 게시판 환경설정 조회·저장·일괄 적용과 관리자 대시보드 조회 화면에서 결과 안내가 실제 문장 대신 내부 식별자로 표시되던 문제를 수정했습니다.
- 환경설정을 여러 게시판에 일괄 적용할 때, 개별 저장 화면과 달리 글자 수·표시 시간 등의 값 범위 제한이 적용되지 않던 문제를 수정했습니다. 이제 일괄 적용도 개별 저장과 동일한 범위를 벗어난 값을 거부합니다.
## [1.0.2] - 2026-07-14
### Fixed
@@ -143,6 +143,10 @@ return [
'category_max' => 50,
// 신규 게시글 NEW 배지 표시 기간 (시간 단위, 0 = 표시 안 함)
'new_display_hours_min' => 0,
'new_display_hours_max' => 720,
// 답글 깊이 제한
'max_reply_depth_min' => 1,
'max_reply_depth_max' => 10,
@@ -216,7 +216,7 @@ HTTP/1.1 200
| use_reply | body | boolean | 예 | — | 게시글 답변(원글에 대한 답글) 기능 사용 여부 |
| use_report | body | boolean | 예 | — | 게시글/댓글 신고 기능 사용 여부 |
| comment_order | body | string | 예 | `ASC`, `DESC` | 댓글 정렬 순서 (ASC 오름차순 / DESC 내림차순) |
| new_display_hours | body | integer | 아니오 | min 1, max 720 | 신규(NEW) 표시를 유지할 기간 (시간 단위, 최대 720시간=30일) |
| new_display_hours | body | integer | 아니오 | min 0, max 720 | 신규(NEW) 표시를 유지할 기간 (시간 단위, 최대 720시간=30일). `0` 은 NEW 배지를 표시하지 않음 |
| min_title_length | body | integer | 아니오 | min 0, max 200 | 게시글 제목 최소 글자 수 |
| max_title_length | body | integer | 아니오 | min 1, max 1000 | 게시글 제목 최대 글자 수 |
| min_content_length | body | integer | 아니오 | min 0, max 10000 | 게시글 본문 최소 글자 수 |
@@ -226,7 +226,7 @@ HTTP/1.1 200
| use_file_upload | body | boolean | 예 | — | 파일 첨부 기능 사용 여부 (true일 때 allowed_extensions 최소 1개 필수) |
| max_file_size | body | integer | 아니오 | min 1, max 200 | 첨부파일 1개당 최대 크기 (MB 단위) |
| max_file_count | body | integer | 아니오 | min 1, max 20 | 게시글 1건당 첨부할 수 있는 최대 파일 개수 |
| allowed_extensions | body | array | 예 | min 1 | 업로드 허용 확장자 목록 (예: jpg, png, pdf — 첨부 사용 시 최소 1개 필수) |
| allowed_extensions | body | array | 조건부 | min 1 | 업로드 허용 확장자 목록 (예: jpg, png, pdf). `use_file_upload=true` 일 때만 필수이며 최소 1개가 있어야 한다. `use_file_upload=false` 이면 검증에서 제외되어 빈 배열·`null` 도 허용된다 |
| board_manager_ids | body | array | 예 | min 1 | board manager 식별자 배열 |
| board_step_ids | body | array | 아니오 | — | board step 식별자 배열 |
| permissions | body | array | 아니오 | — | 게시판별 세부 권한 매트릭스 (권한 키별 mode/roles — 미지정 시 Service가 Manager/Step 역할을 주입) |
@@ -1222,7 +1222,7 @@ HTTP/1.1 200
| use_reply | body | boolean | 예 | — | 게시글 답변(원글에 대한 답글) 기능 사용 여부 |
| use_report | body | boolean | 예 | — | 게시글/댓글 신고 기능 사용 여부 |
| comment_order | body | string | 예 | `ASC`, `DESC` | 댓글 정렬 순서 (ASC 오름차순 / DESC 내림차순) |
| new_display_hours | body | integer | 아니오 | min 1, max 720 | 신규(NEW) 표시를 유지할 기간 (시간 단위, 최대 720시간=30일) |
| new_display_hours | body | integer | 아니오 | min 0, max 720 | 신규(NEW) 표시를 유지할 기간 (시간 단위, 최대 720시간=30일). `0` 은 NEW 배지를 표시하지 않음 |
| min_title_length | body | integer | 아니오 | min 0, max 200 | 게시글 제목 최소 글자 수 |
| max_title_length | body | integer | 아니오 | min 1, max 1000 | 게시글 제목 최대 글자 수 |
| min_content_length | body | integer | 아니오 | min 0, max 10000 | 게시글 본문 최소 글자 수 |
@@ -1232,7 +1232,7 @@ HTTP/1.1 200
| use_file_upload | body | boolean | 예 | — | 파일 첨부 기능 사용 여부 (true일 때 allowed_extensions 최소 1개 필수) |
| max_file_size | body | integer | 아니오 | min 1, max 200 | 첨부파일 1개당 최대 크기 (MB 단위) |
| max_file_count | body | integer | 아니오 | min 1, max 20 | 게시글 1건당 첨부할 수 있는 최대 파일 개수 |
| allowed_extensions | body | array | 아니오 | min 1 | 업로드 허용 확장자 목록 (예: jpg, png, pdf — 첨부 사용 시 최소 1개 필수) |
| allowed_extensions | body | array | 조건부 | min 1 | 업로드 허용 확장자 목록 (예: jpg, png, pdf). `use_file_upload=true` 일 때만 필수이며 최소 1개가 있어야 한다. `use_file_upload=false` 이면 검증에서 제외되어 빈 배열·`null` 도 허용된다 |
| board_manager_ids | body | array | 예 | min 1 | board manager 식별자 배열 |
| board_step_ids | body | array | 아니오 | — | board step 식별자 배열 |
| permissions | body | array | 예 | — | 게시판별 세부 권한 매트릭스 (권한 키별 mode/roles — 각 권한을 all 또는 특정 역할에 부여) |
@@ -361,7 +361,7 @@ HTTP/1.1 200
| --- | --- | --- | --- | --- | --- |
| _tab | body | string | 아니오 | `basic_defaults`, `report_policy`, `spam_security`, `general`, `seo`, `notifications`, `notification_definitions` | 현재 편집 중인 탭을 나타내는 메타 값. 탭 단위 부분 저장의 컨텍스트를 식별하는 용도이며 설정값으로는 저장되지 않습니다. |
| notifications | body | array | 아니오 | — | 알림 채널 설정. `channels` 배열의 각 항목에 채널 식별자(id), 활성화 여부(is_active), 정렬 순서(sort_order)를 담아 저장합니다. |
| basic_defaults | body | array | 아니오 | — | 기본 설정 카테고리 값. 게시판 타입·페이지당 글 수·정렬·댓글/답글·길이 제한·파일 업로드·기본 권한 등 basic_defaults 하위 키를 저장합니다. |
| basic_defaults | body | array | 아니오 | — | 기본 설정 카테고리 값. 게시판 타입·페이지당 글 수·정렬·댓글/답글·길이 제한·파일 업로드·기본 권한 등 basic_defaults 하위 키를 저장합니다. `basic_defaults.allowed_extensions` 는 `basic_defaults.use_file_upload` 가 `true` 일 때만 최소 1개가 필수이며, `false`/`null` 이면 검증에서 제외되어 빈 배열도 허용됩니다. `min_title_length`·`min_comment_length`·`new_display_hours` 의 하한은 `config('sirsoft-board.limits')` 선언을 따르며 `0` 을 허용합니다. |
| report_policy | body | array | 아니오 | — | 신고 정책 카테고리 값. 자동 숨김 임계치/대상, 일일 신고 한도, 거부 누적 제한, 관리자·작성자 신고 알림 설정을 저장합니다. |
| report_permissions | body | array | 아니오 | — | 신고 관리 권한 역할. `view_roles`(조회 역할)와 `manage_roles`(관리 역할)의 역할 식별자 배열이며, 포함 시 설정 저장과 별개로 DB 권한 역할이 동기화됩니다. |
| display | body | array | 아니오 | — | 표시 설정 카테고리 값. 날짜 표시 형식(date_display_format: standard/relative) 등을 저장합니다. |
@@ -312,4 +312,46 @@ describe('업로드 에러 시 isSaving 복구 (onUploadError)', () => {
const uploader = fileUploaders[0];
expect(uploader.props.uploadParams.post_id).toBeUndefined();
});
// ==========================================
// accept 표현식 — 빈 확장자 배열 truthiness (#78 R3 회귀)
//
// API 는 allowed_extensions 를 "항상 배열"로 직렬화한다(BoardResource 가 NULL → []).
// 그런데 레이아웃이 `ext ? ... : fallback` 으로 판정하면 []가 JS 에서 truthy 라
// accept="." 가 되어 useFileUploader 확장자 게이트가 전 파일을 거부한다.
// 서버는 정상인데 사용자는 파일 선택 단계에서 막히므로 요청조차 발생하지 않는다.
// → 길이 판정(`?.length` )이어야 한다.
// ==========================================
/**
* accept 표현식이 빈 배열을 fallback 으로 흘리는지 평가합니다.
*/
function evaluateAccept(expression: string, allowedExtensions: unknown): string {
const body = expression.replace(/^\{\{/, '').replace(/\}\}$/, '');
const form_meta = { data: { board: { allowed_extensions: allowedExtensions } } };
// eslint-disable-next-line no-new-func
return new Function('form_meta', `return (${body});`)(form_meta);
}
const FALLBACK = '.jpg,.jpeg,.png,.gif,.pdf,.zip';
it.each([
['관리자 첨부파일', () => findComponentsByName(adminAttachments, 'FileUploader')[0]],
['사용자 폼', () => findComponentsByName(userPostForm, 'FileUploader')[0]],
])('%s accept 는 빈 확장자 배열에서 기본 확장자로 폴백해야 합니다 (#78 R3)', (_label, getUploader) => {
const accept = (getUploader() as any).props.accept as string;
expect(evaluateAccept(accept, [])).toBe(FALLBACK);
expect(evaluateAccept(accept, null)).toBe(FALLBACK);
expect(evaluateAccept(accept, undefined)).toBe(FALLBACK);
});
it.each([
['관리자 첨부파일', () => findComponentsByName(adminAttachments, 'FileUploader')[0]],
['사용자 폼', () => findComponentsByName(userPostForm, 'FileUploader')[0]],
])('%s accept 는 지정된 확장자를 그대로 반영해야 합니다', (_label, getUploader) => {
const accept = (getUploader() as any).props.accept as string;
expect(evaluateAccept(accept, ['jpg', 'png'])).toBe('.jpg,.png');
});
});
@@ -321,9 +321,11 @@ describe('admin_board_settings.json - 메인 레이아웃', () => {
const content = mainLayout.slots.content[0];
const stickyHeader = findById(content, 'sticky_header');
expect(stickyHeader).toBeDefined();
// sirsoft-admin_basic 의 .sticky-tab-nav-responsive 자산 사용 (responsive padding 화면 전용 — #408)
// 자산 정의: sticky top-0 z-40 -mx-{4,6,8} px-{4,6,8} border-b bg-gray-50 dark:bg-gray-900
expect(stickyHeader.props.className).toContain('sticky-tab-nav-responsive');
// sirsoft-admin_basic 의 .sticky-section-header 자산 사용.
// 자식(header_content)이 자체 padding 을 가지므로 좌우 -mx/px override 가 없는 변형을 쓴다
// (main.css 의 .sticky-section-header 주석이 "게시판 설정" 을 대상으로 명시).
// -mx/px override 가 있는 .sticky-tab-nav-responsive 를 쓰면 자식 padding 과 겹쳐 어긋난다.
expect(stickyHeader.props.className).toContain('sticky-section-header');
// 하위 탭 네비게이션이 basic_defaults 탭에서만 표시 (TabNavigationScroll - 스크롤 방식)
const subTabNav = findById(content, 'sub_tab_navigation');
@@ -212,7 +212,7 @@ describe('admin_dashboard_community.json - community 슬롯 주입', () => {
// 따라서 실제 응답은 { success, message, data: [...] } 이고, 바인딩은 data.data 가 아닌 data 다.
const list = findById(communityExt as any, 'latest_posts_list');
expect(list.iteration).toBeUndefined();
const item = findById(communityExt as any, 'latest_post_item');
const item = findById(communityExt as any, 'latest_post_item_{{i}}');
expect(item.iteration).toBeDefined();
expect(item.iteration.source).toBe('board_recent_posts?.data');
expect(item.iteration.item_var).toBe('post');
@@ -222,7 +222,7 @@ describe('admin_dashboard_community.json - community 슬롯 주입', () => {
it('미처리 신고 항목(report_item)에 iteration 이 정의된다', () => {
const list = findById(communityExt as any, 'report_management_list');
expect(list.iteration).toBeUndefined();
const item = findById(communityExt as any, 'report_item');
const item = findById(communityExt as any, 'report_item_{{i}}');
expect(item.iteration).toBeDefined();
expect(item.iteration.source).toBe('board_pending_reports?.data?.items');
expect(item.iteration.item_var).toBe('report');
@@ -246,7 +246,7 @@ describe('admin_dashboard_community.json - community 슬롯 주입', () => {
});
it('최신 게시글 항목은 클릭 시 게시글 상세로 navigate 한다', () => {
const item = findById(communityExt as any, 'latest_post_item');
const item = findById(communityExt as any, 'latest_post_item_{{i}}');
expect(Array.isArray(item.actions)).toBe(true);
const click = item.actions.find((a: any) => a.type === 'click' && a.handler === 'navigate');
expect(click).toBeDefined();
@@ -254,7 +254,7 @@ describe('admin_dashboard_community.json - community 슬롯 주입', () => {
});
it('신고 항목은 클릭 시 신고 상세로 navigate 한다', () => {
const item = findById(communityExt as any, 'report_item');
const item = findById(communityExt as any, 'report_item_{{i}}');
expect(Array.isArray(item.actions)).toBe(true);
const click = item.actions.find((a: any) => a.type === 'click' && a.handler === 'navigate');
expect(click).toBeDefined();
@@ -266,7 +266,7 @@ describe('admin_dashboard_community.json - community 슬롯 주입', () => {
// 배지는 신고 대상(게시글/댓글)의 상태가 아니라 신고 건 자체의 처리상태(status)를 보여준다.
// 색상은 신고현황 페이지(admin_board_reports_index 의 status 컬럼) 와 동일한 매핑을 사용한다.
// 시각 검증 피드백: "대상 상태가 아닌 처리상태가 보여져야 한다" + "처리상태는 게시판 신고현황과 동일한 색상으로".
const statusBadge = findById(communityExt as any, 'report_status_badge');
const statusBadge = findById(communityExt as any, 'report_status_badge_{{i}}');
expect(statusBadge).not.toBeNull();
expect(statusBadge.if).toBe('{{!!report?.status_label}}');
expect(statusBadge.text).toBe('{{report?.status_label ?? \'\'}}');
@@ -286,7 +286,7 @@ describe('admin_dashboard_community.json - community 슬롯 주입', () => {
});
it('신고 카드의 타입 배지(report_target_type_badge)는 댓글/게시글 색상이 다르다', () => {
const typeBadge = findById(communityExt as any, 'report_target_type_badge');
const typeBadge = findById(communityExt as any, 'report_target_type_badge_{{i}}');
expect(typeBadge.props.className).toContain("comment");
expect(typeBadge.props.className).toContain('bg-purple-900');
expect(typeBadge.props.className).toContain('bg-blue-900');
@@ -110,7 +110,7 @@
"name": "Icon",
"props": {
"name": "lock",
"className": "w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0"
"className": "text-xl text-amber-600 dark:text-amber-400 flex-shrink-0"
}
},
{
@@ -210,7 +210,7 @@
"name": "Icon",
"props": {
"name": "triangle-exclamation",
"className": "w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5"
"className": "text-xl text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5"
}
},
{
@@ -427,7 +427,7 @@
},
{
"handler": "openWindow",
"condition": "{{!route?.slug}}",
"if": "{{!route?.slug}}",
"params": {
"path": "{{'/admin/boards/' + result.data.slug + '/edit'}}",
"target": "_self"
@@ -30,7 +30,7 @@
"name": "Icon",
"props": {
"name": "paperclip",
"className": "w-5 h-5 text-gray-700 dark:text-gray-300"
"className": "text-xl text-gray-700 dark:text-gray-300"
}
},
{
@@ -55,7 +55,7 @@
"collection": "post_attachments",
"maxFiles": "{{form_meta?.data?.board?.max_file_count ?? 5}}",
"maxSize": "{{form_meta?.data?.board?.max_file_size ?? 10}}",
"accept": "{{form_meta?.data?.board?.allowed_extensions ? '.' + form_meta.data.board.allowed_extensions.join(',.') : '.jpg,.jpeg,.png,.gif,.pdf,.zip'}}",
"accept": "{{form_meta?.data?.board?.allowed_extensions?.length ? '.' + form_meta.data.board.allowed_extensions.join(',.') : '.jpg,.jpeg,.png,.gif,.pdf,.zip'}}",
"initialFiles": "{{form_meta?.data?.attachments ?? []}}",
"autoUpload": false,
"uploadTriggerEvent": "upload:board_attachments",
@@ -840,6 +840,15 @@
"handler": "openModal",
"target": "modal_board_notification_template_preview"
}
],
"onError": [
{
"handler": "toast",
"params": {
"type": "error",
"message": "{{error.message}}"
}
}
]
}
]
@@ -410,7 +410,7 @@
"text": "$t:sirsoft-board.admin.settings.notification_definitions.no_template_for_channel"
},
{
"id": "board_recipients_badge",
"id": "board_recipients_badge_{{defIdx}}",
"type": "basic",
"name": "Div",
"if": "{{((def.templates ?? []).find(t => t.channel === (_local.activeNotificationChannel ?? query.channel ?? 'mail'))?.recipients ?? []).length > 0}}",
@@ -533,6 +533,15 @@
"type": "success"
}
}
],
"onError": [
{
"handler": "toast",
"params": {
"type": "error",
"message": "{{error.message}}"
}
}
]
}
]
@@ -0,0 +1,70 @@
<?php
namespace Modules\Sirsoft\Board\Casts;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
use Modules\Sirsoft\Board\Http\Requests\Concerns\ResolvesAllowedExtensions;
/**
* 허용 확장자 컬럼 캐스팅
*
* "확장자 미지정" 을 표현하는 값이 빈 배열([])과 NULL 두 가지로 공존하면
* 소비 지점마다 `?? 기본값` 이 [] 를 통과시켜 `mimes:` 빈 규칙을 만드는 결함이 반복됩니다.
* 저장 시점에 센티널을 NULL 하나로 붕괴시켜 이 버그 클래스를 구조적으로 제거합니다.
*
* 조회 시맨틱은 기존 'array' 캐스트와 동일합니다 (NULL 은 NULL 그대로 반환).
*
* @implements CastsAttributes<array<int, string>|null, array<int, string>|null>
*/
class AsAllowedExtensions implements CastsAttributes
{
use ResolvesAllowedExtensions;
/**
* DB 값을 배열로 변환합니다.
*
* @param Model $model 모델 인스턴스
* @param string $key 속성명
* @param mixed $value DB 원본 값
* @param array<string, mixed> $attributes 전체 속성
* @return array<int, string>|null 확장자 목록 (미지정 시 null)
*/
public function get(Model $model, string $key, mixed $value, array $attributes): ?array
{
if ($value === null || $value === '') {
return null;
}
if (is_array($value)) {
return $value;
}
$decoded = json_decode((string) $value, true);
return is_array($decoded) ? $decoded : null;
}
/**
* 저장 값을 정규화하여 JSON 문자열로 변환합니다.
*
* 빈 값은 NULL 로 저장하여 "미지정" 표현을 단일화합니다.
*
* @param Model $model 모델 인스턴스
* @param string $key 속성명
* @param mixed $value 설정 값
* @param array<string, mixed> $attributes 전체 속성
* @return string|null JSON 문자열 (미지정 시 null)
*/
public function set(Model $model, string $key, mixed $value, array $attributes): ?string
{
if (is_string($value)) {
$decoded = json_decode($value, true);
$value = is_array($decoded) ? $decoded : explode(',', $value);
}
$normalized = self::normalizeAllowedExtensions($value);
return $normalized === [] ? null : json_encode($normalized, JSON_UNESCAPED_UNICODE);
}
}
@@ -40,6 +40,7 @@ class AttachmentController extends AdminBaseController
*
* @param UploadAttachmentRequest $request 업로드 요청
* @param string $slug 게시판 슬러그
* @return JsonResponse 업로드된 첨부파일 정보 응답
*/
public function upload(UploadAttachmentRequest $request, string $slug): JsonResponse
{
@@ -81,7 +82,7 @@ class AttachmentController extends AdminBaseController
201
);
} catch (BoardNotFoundException $e) {
return $this->error('sirsoft-board::messages.board.not_found', 404);
return $this->error('sirsoft-board::messages.boards.not_found', 404);
} catch (\Exception $e) {
return $this->error('sirsoft-board::messages.attachment.upload_failed', 500, $e->getMessage());
}
@@ -92,6 +93,7 @@ class AttachmentController extends AdminBaseController
*
* @param string $slug 게시판 슬러그
* @param int $id 첨부파일 ID
* @return JsonResponse 삭제 결과 응답
*/
public function destroy(string $slug, int $id): JsonResponse
{
@@ -115,7 +117,7 @@ class AttachmentController extends AdminBaseController
return $this->success('sirsoft-board::messages.attachment.delete_success');
} catch (BoardNotFoundException $e) {
return $this->error('sirsoft-board::messages.board.not_found', 404);
return $this->error('sirsoft-board::messages.boards.not_found', 404);
} catch (\Exception $e) {
return $this->error('sirsoft-board::messages.attachment.delete_failed', 500, $e->getMessage());
}
@@ -126,6 +128,7 @@ class AttachmentController extends AdminBaseController
*
* @param ReorderAttachmentsRequest $request 순서 변경 요청
* @param string $slug 게시판 슬러그
* @return JsonResponse 순서 변경 결과 응답
*/
public function reorder(ReorderAttachmentsRequest $request, string $slug): JsonResponse
{
@@ -145,7 +148,7 @@ class AttachmentController extends AdminBaseController
return $this->success('sirsoft-board::messages.attachment.reorder_success');
} catch (BoardNotFoundException $e) {
return $this->error('sirsoft-board::messages.board.not_found', 404);
return $this->error('sirsoft-board::messages.boards.not_found', 404);
} catch (\Exception $e) {
return $this->error('sirsoft-board::messages.attachment.reorder_failed', 500, $e->getMessage());
}
@@ -156,6 +159,7 @@ class AttachmentController extends AdminBaseController
*
* @param string $slug 게시판 슬러그
* @param string $hash 첨부파일 해시
* @return StreamedResponse|JsonResponse 파일 스트림 또는 오류 응답
*/
public function download(string $slug, string $hash): StreamedResponse|JsonResponse
{
@@ -181,7 +185,7 @@ class AttachmentController extends AdminBaseController
} catch (AccessDeniedHttpException $e) {
return $this->error('auth.scope_denied', 403);
} catch (BoardNotFoundException $e) {
return $this->error('sirsoft-board::messages.board.not_found', 404);
return $this->error('sirsoft-board::messages.boards.not_found', 404);
} catch (\Exception $e) {
return $this->error('sirsoft-board::messages.attachment.download_failed', 500, $e->getMessage());
}
@@ -5,13 +5,13 @@ namespace Modules\Sirsoft\Board\Http\Controllers\Admin;
use App\Http\Controllers\Api\Base\AdminBaseController;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Modules\Sirsoft\Board\Http\Requests\BlindCommentRequest;
use Modules\Sirsoft\Board\Http\Requests\RestoreCommentRequest;
use Modules\Sirsoft\Board\Http\Requests\StoreCommentRequest;
use Modules\Sirsoft\Board\Http\Requests\UpdateCommentRequest;
use Modules\Sirsoft\Board\Http\Resources\CommentResource;
use Modules\Sirsoft\Board\Models\Board;
use Modules\Sirsoft\Board\Services\BoardService;
use Modules\Sirsoft\Board\Services\CommentService;
use Modules\Sirsoft\Board\Traits\ChecksBoardPermission;
@@ -28,9 +28,11 @@ class CommentController extends AdminBaseController
* CommentController 생성자
*
* @param CommentService $commentService 댓글 서비스
* @param BoardService $boardService 게시판 서비스
*/
public function __construct(
private CommentService $commentService
private CommentService $commentService,
private BoardService $boardService
) {
parent::__construct();
}
@@ -51,7 +53,7 @@ class CommentController extends AdminBaseController
// 비회원 댓글은 admin.manage 권한 필요
if ($commentUserId === null) {
return $this->forbidden('sirsoft-board::messages.permissions.access_denied');
return $this->forbidden('sirsoft-board::messages.permission.denied');
}
// admin.write 권한이 있고 본인 댓글이면 수정/삭제 가능
@@ -59,7 +61,7 @@ class CommentController extends AdminBaseController
return null;
}
return $this->forbidden('sirsoft-board::messages.permissions.access_denied');
return $this->forbidden('sirsoft-board::messages.permission.denied');
}
/**
@@ -84,7 +86,11 @@ class CommentController extends AdminBaseController
public function store(StoreCommentRequest $request, string $slug, int $postId): JsonResponse
{
try {
$board = Board::where('slug', $slug)->firstOrFail();
$board = $this->boardService->getBoardBySlug($slug, checkScope: false);
if (! $board) {
return $this->notFound('sirsoft-board::messages.boards.not_found');
}
if (! $board->use_comment) {
return $this->error('sirsoft-board::messages.comments.comments_disabled', 403);
@@ -129,7 +135,11 @@ class CommentController extends AdminBaseController
return $response;
}
$board = Board::where('slug', $slug)->firstOrFail();
$board = $this->boardService->getBoardBySlug($slug, checkScope: false);
if (! $board) {
return $this->notFound('sirsoft-board::messages.boards.not_found');
}
if (! $board->use_comment) {
return $this->error('sirsoft-board::messages.comments.comments_disabled', 403);
@@ -168,7 +178,11 @@ class CommentController extends AdminBaseController
return $response;
}
$board = Board::where('slug', $slug)->firstOrFail();
$board = $this->boardService->getBoardBySlug($slug, checkScope: false);
if (! $board) {
return $this->notFound('sirsoft-board::messages.boards.not_found');
}
if (! $board->use_comment) {
return $this->error('sirsoft-board::messages.comments.comments_disabled', 403);
@@ -196,7 +210,11 @@ class CommentController extends AdminBaseController
public function blind(BlindCommentRequest $request, string $slug, int $postId, int $id): JsonResponse
{
try {
$board = Board::where('slug', $slug)->firstOrFail();
$board = $this->boardService->getBoardBySlug($slug, checkScope: false);
if (! $board) {
return $this->notFound('sirsoft-board::messages.boards.not_found');
}
if (! $board->use_comment) {
return $this->error('sirsoft-board::messages.comments.comments_disabled', 403);
@@ -221,22 +239,26 @@ class CommentController extends AdminBaseController
/**
* 블라인드된 댓글을 복원합니다.
*
* @param Request $request HTTP 요청 객체
* @param RestoreCommentRequest $request 댓글 복원 요청
* @param string $slug 게시판 slug
* @param int $postId 게시글 ID
* @param int $id 댓글 ID
* @return JsonResponse 댓글 복원 결과 응답
*/
public function restore(Request $request, string $slug, int $postId, int $id): JsonResponse
public function restore(RestoreCommentRequest $request, string $slug, int $postId, int $id): JsonResponse
{
try {
$board = Board::where('slug', $slug)->firstOrFail();
$board = $this->boardService->getBoardBySlug($slug, checkScope: false);
if (! $board) {
return $this->notFound('sirsoft-board::messages.boards.not_found');
}
if (! $board->use_comment) {
return $this->error('sirsoft-board::messages.comments.comments_disabled', 403);
}
$reason = $request->input('reason');
$reason = $request->validated()['reason'] ?? null;
$comment = $this->commentService->restoreComment($slug, $id, $reason);
return $this->successWithResource(
@@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Modules\Sirsoft\Board\Enums\PostStatus;
use Modules\Sirsoft\Board\Exceptions\BoardNotFoundException;
use Modules\Sirsoft\Board\Exceptions\PostNotFoundException;
use Modules\Sirsoft\Board\Http\Requests\BlindPostRequest;
@@ -53,6 +54,7 @@ class PostController extends AdminBaseController
* @param string $slug 게시판 슬러그
* @return JsonResponse 게시글 목록 응답
*/
// audit:allow controller-base-request-injection reason: GET 목록 조회. all()로 필터·페이징 파라미터만 읽음 (검증 불필요)
public function index(Request $request, string $slug): JsonResponse
{
// 권한은 라우트 미들웨어에서 체크됨 (sirsoft-board.{slug}.admin.posts.read)
@@ -387,6 +389,7 @@ class PostController extends AdminBaseController
* @param string $slug 게시판 슬러그
* @return JsonResponse 폼 입력 데이터 응답
*/
// audit:allow controller-base-request-injection reason: GET 수정 폼 데이터 조회. 경로 파라미터(slug) + 쿼리만 read-only 참조 (검증 불필요)
public function getFormData(Request $request, string $slug): JsonResponse
{
// 권한은 라우트 미들웨어에서 체크됨 (sirsoft-board.{slug}.admin.posts.write)
@@ -474,6 +477,7 @@ class PostController extends AdminBaseController
* @param string $slug 게시판 슬러그
* @return JsonResponse 폼 메타 데이터 응답
*/
// audit:allow controller-base-request-injection reason: GET 작성/수정 폼 메타 조회. 경로 파라미터(slug) + post_id 쿼리만 read-only 참조 (검증 불필요)
public function getFormMeta(Request $request, string $slug): JsonResponse
{
// 권한은 라우트 미들웨어에서 체크됨 (sirsoft-board.{slug}.admin.posts.write)
@@ -487,7 +491,7 @@ class PostController extends AdminBaseController
// 관리자 라우트에서는 항상 사용자 권한 정보 포함
$request->merge(['include_user_abilities' => true]);
$boardResource = new \Modules\Sirsoft\Board\Http\Resources\BoardResource($board);
$boardResource = new BoardResource($board);
$boardData = $boardResource->toArray($request);
// 게시글 폼에서는 게시판 이름을 로컬라이즈된 문자열로 반환
@@ -523,10 +527,10 @@ class PostController extends AdminBaseController
$parentPost = $this->postService->getPost($slug, $parentId);
// 블라인드 또는 삭제된 게시글에는 답글 작성 불가
if ($parentPost->status === \Modules\Sirsoft\Board\Enums\PostStatus::Blinded) {
if ($parentPost->status === PostStatus::Blinded) {
return $this->error('sirsoft-board::validation.post.parent_id.blinded', 403);
}
if ($parentPost->status === \Modules\Sirsoft\Board\Enums\PostStatus::Deleted) {
if ($parentPost->status === PostStatus::Deleted) {
return $this->error('sirsoft-board::validation.post.parent_id.deleted', 403);
}
@@ -577,7 +581,7 @@ class PostController extends AdminBaseController
// 비회원 게시글은 admin.manage 권한 필요 (위에서 통과 안 됨)
if ($postUserId === null) {
return $this->forbidden('sirsoft-board::messages.permissions.access_denied');
return $this->forbidden('sirsoft-board::messages.permission.denied');
}
// admin.posts.write 권한이 있고 본인 글이면 수정/삭제 가능
@@ -585,6 +589,6 @@ class PostController extends AdminBaseController
return null;
}
return $this->forbidden('sirsoft-board::messages.permissions.access_denied');
return $this->forbidden('sirsoft-board::messages.permission.denied');
}
}
@@ -13,6 +13,7 @@ use Modules\Sirsoft\Board\Services\AttachmentService;
use Modules\Sirsoft\Board\Services\BoardService;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
/**
* 사용자용 첨부파일 컨트롤러
@@ -66,7 +67,7 @@ class AttachmentController extends PublicBaseController
}
return $response;
} catch (\Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException $e) {
} catch (AccessDeniedHttpException $e) {
return $this->error('auth.scope_denied', 403);
} catch (BoardNotFoundException $e) {
return $this->notFound(__('sirsoft-board::messages.boards.not_found'));
@@ -116,7 +117,7 @@ class AttachmentController extends PublicBaseController
$fileInfo['mime_type'],
(int) g7_core_settings('cache.layout_ttl', 86400)
);
} catch (\Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException $e) {
} catch (AccessDeniedHttpException $e) {
// 삭제글 첨부 등 권한 차단은 403 으로 응답
return $this->error('auth.scope_denied', 403);
} catch (BoardNotFoundException $e) {
@@ -175,7 +176,7 @@ class AttachmentController extends PublicBaseController
201
);
} catch (BoardNotFoundException $e) {
return $this->notFound(__('sirsoft-board::messages.board.not_found'));
return $this->notFound(__('sirsoft-board::messages.boards.not_found'));
} catch (\Exception $e) {
return $this->error(__('sirsoft-board::messages.attachment.upload_failed'), 500, $e->getMessage());
}
@@ -206,7 +207,7 @@ class AttachmentController extends PublicBaseController
return $this->success(__('sirsoft-board::messages.attachment.reorder_success'));
} catch (BoardNotFoundException $e) {
return $this->notFound(__('sirsoft-board::messages.board.not_found'));
return $this->notFound(__('sirsoft-board::messages.boards.not_found'));
} catch (\Exception $e) {
return $this->error(__('sirsoft-board::messages.attachment.reorder_failed'), 500, $e->getMessage());
}
@@ -246,7 +247,7 @@ class AttachmentController extends PublicBaseController
return $this->success(__('sirsoft-board::messages.attachment.delete_success'));
} catch (BoardNotFoundException $e) {
return $this->notFound(__('sirsoft-board::messages.board.not_found'));
return $this->notFound(__('sirsoft-board::messages.boards.not_found'));
} catch (\Exception $e) {
return $this->error(__('sirsoft-board::messages.attachment.delete_failed'), 500, $e->getMessage());
}
@@ -294,7 +294,7 @@ class PostController extends PublicBaseController
// 파일 업로드 권한 확인
if (! $this->checkBoardPermission($slug, 'attachments.upload', PermissionType::User)) {
return $this->forbidden('sirsoft-board::messages.permissions.access_denied');
return $this->forbidden('sirsoft-board::messages.permission.attachments_upload_denied');
}
}
@@ -584,7 +584,7 @@ class PostController extends PublicBaseController
// 회원 게시글이고 본인이 아닌 경우 권한 에러
if ($post->user_id && Auth::id() !== $post->user_id) {
if (! $this->hasBoardManagePermission($slug)) {
return $this->error('sirsoft-board::messages.posts.no_permission', 403);
return $this->error('sirsoft-board::messages.posts.modify_permission_denied', 403);
}
}
@@ -675,7 +675,7 @@ class PostController extends PublicBaseController
// 회원 게시글이고 본인이 아니거나, 관리자도 아닌 경우 권한 에러
if ($post->user_id && Auth::id() !== $post->user_id) {
if (! $this->hasBoardManagePermission($slug)) {
return $this->error('sirsoft-board::messages.posts.no_permission', 403);
return $this->error('sirsoft-board::messages.posts.modify_permission_denied', 403);
}
}
@@ -787,7 +787,6 @@ class PostController extends PublicBaseController
*
* @param Post $parentPost 부모 게시글 모델
* @param int $parentId 부모 게시글 ID (예외 메시지용)
* @return void
*
* @throws PostNotFoundException 부모글이 블라인드/삭제 상태인 경우
*/
@@ -4,6 +4,7 @@ namespace Modules\Sirsoft\Board\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Modules\Sirsoft\Board\Http\Requests\Concerns\ReadsBoardLimits;
use Modules\Sirsoft\Board\Models\Board;
/**
@@ -14,6 +15,8 @@ use Modules\Sirsoft\Board\Models\Board;
*/
class BulkApplySettingsRequest extends FormRequest
{
use ReadsBoardLimits;
/**
* boards 테이블 컬럼에 매핑되는 일괄 적용 가능 필드 목록
*/
@@ -78,7 +81,7 @@ class BulkApplySettingsRequest extends FormRequest
{
$allowedFields = array_merge(self::BOARD_COLUMN_FIELDS, self::PERMISSION_FIELDS);
return [
return array_merge([
'fields' => ['required', 'array', 'min:1'],
'fields.*' => ['string', function (string $attribute, mixed $value, \Closure $fail) use ($allowedFields) {
// 개별 권한 키(예: admin.manage, user.write)는 점(.)을 포함한 문자열로 허용
@@ -93,6 +96,37 @@ class BulkApplySettingsRequest extends FormRequest
'board_ids' => ['required_if:apply_all,false', 'array'],
'board_ids.*' => ['integer', Rule::exists(Board::class, 'id')],
'override_values' => ['sometimes', 'array'],
], $this->overrideValueRules());
}
/**
* override_values 의 컬럼별 범위 검증 규칙.
*
* override_values 는 boards 테이블 컬럼에 직접 기록되므로(BoardService::bulkApplySettings),
* Store/Update 와 동일한 제한값(ReadsBoardLimits SSoT)을 강제해야 한다. 이 규칙이 없으면
* 일괄 적용 경로만 범위 검증을 우회해 new_display_hours=99999 같은 값이 저장된다.
* 각 키는 선택적(존재할 때만 검증)이며, 범위 밖 값은 422 로 거부된다.
*
* @return array<string, mixed> override_values.* 검증 규칙
*/
private function overrideValueRules(): array
{
$limits = $this->boardLimits();
return [
'override_values.per_page' => ['nullable', 'integer', "min:{$limits['per_page_min']}", "max:{$limits['per_page_max']}"],
'override_values.per_page_mobile' => ['nullable', 'integer', "min:{$limits['per_page_min']}", "max:{$limits['per_page_max']}"],
'override_values.max_reply_depth' => ['nullable', 'integer', "min:{$limits['max_reply_depth_min']}", "max:{$limits['max_reply_depth_max']}"],
'override_values.max_comment_depth' => ['nullable', 'integer', "min:{$limits['max_comment_depth_min']}", "max:{$limits['max_comment_depth_max']}"],
'override_values.min_title_length' => ['nullable', 'integer', "min:{$limits['min_title_length_min']}", "max:{$limits['min_title_length_max']}"],
'override_values.max_title_length' => ['nullable', 'integer', "min:{$limits['max_title_length_min']}", "max:{$limits['max_title_length_max']}"],
'override_values.min_content_length' => ['nullable', 'integer', "min:{$limits['min_content_length_min']}", "max:{$limits['min_content_length_max']}"],
'override_values.max_content_length' => ['nullable', 'integer', "min:{$limits['max_content_length_min']}", "max:{$limits['max_content_length_max']}"],
'override_values.min_comment_length' => ['nullable', 'integer', "min:{$limits['min_comment_length_min']}", "max:{$limits['min_comment_length_max']}"],
'override_values.max_comment_length' => ['nullable', 'integer', "min:{$limits['max_comment_length_min']}", "max:{$limits['max_comment_length_max']}"],
'override_values.max_file_size' => ['nullable', 'integer', "min:{$limits['max_file_size_min']}", "max:{$limits['max_file_size_max']}"],
'override_values.max_file_count' => ['nullable', 'integer', "min:{$limits['max_file_count_min']}", "max:{$limits['max_file_count_max']}"],
'override_values.new_display_hours' => ['nullable', 'integer', "min:{$limits['new_display_hours_min']}", "max:{$limits['new_display_hours_max']}"],
];
}
@@ -103,7 +137,7 @@ class BulkApplySettingsRequest extends FormRequest
*/
public function attributes(): array
{
$attributes = __('sirsoft-board::attributes.bulk_apply');
$attributes = __('sirsoft-board::validation.attributes.bulk_apply');
return is_array($attributes) ? $attributes : [];
}
@@ -62,7 +62,7 @@ class StatusCountsRequest extends FormRequest
public function attributes(): array
{
return [
'ids' => __('sirsoft-board::attributes.report.ids'),
'ids' => __('sirsoft-board::validation.attributes.report.ids'),
];
}
}
@@ -5,6 +5,7 @@ namespace Modules\Sirsoft\Board\Http\Requests\Admin;
use App\Models\Role;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Modules\Sirsoft\Board\Http\Requests\Concerns\ReadsBoardLimits;
/**
* 게시판 환경설정 저장 요청 검증
@@ -14,6 +15,8 @@ use Illuminate\Validation\Rule;
*/
class StoreBoardSettingsRequest extends FormRequest
{
use ReadsBoardLimits;
/**
* 사용자가 이 요청을 수행할 권한이 있는지 확인
*
@@ -110,13 +113,38 @@ class StoreBoardSettingsRequest extends FormRequest
*/
public function rules(): array
{
$limits = config('sirsoft-board.limits', []);
$perPageMin = $limits['per_page_min'] ?? 5;
$perPageMax = $limits['per_page_max'] ?? 100;
$maxReplyDepthMin = $limits['max_reply_depth_min'] ?? 1;
$maxReplyDepthMax = $limits['max_reply_depth_max'] ?? 10;
$maxCommentDepthMin = $limits['max_comment_depth_min'] ?? 0;
$maxCommentDepthMax = $limits['max_comment_depth_max'] ?? 10;
// config 기준 제한값 (폴백 기본치는 ReadsBoardLimits 트레이트가 단일 관리)
$limits = $this->boardLimits();
$perPageMin = $limits['per_page_min'];
$perPageMax = $limits['per_page_max'];
$maxReplyDepthMin = $limits['max_reply_depth_min'];
$maxReplyDepthMax = $limits['max_reply_depth_max'];
$maxCommentDepthMin = $limits['max_comment_depth_min'];
$maxCommentDepthMax = $limits['max_comment_depth_max'];
// 길이 제한 (하한값은 config 선언을 따른다 — min_title/min_comment 는 0 허용)
$minTitleLengthMin = $limits['min_title_length_min'];
$minTitleLengthMax = $limits['min_title_length_max'];
$maxTitleLengthMin = $limits['max_title_length_min'];
$maxTitleLengthMax = $limits['max_title_length_max'];
$minContentLengthMin = $limits['min_content_length_min'];
$minContentLengthMax = $limits['min_content_length_max'];
$maxContentLengthMin = $limits['max_content_length_min'];
$maxContentLengthMax = $limits['max_content_length_max'];
$minCommentLengthMin = $limits['min_comment_length_min'];
$minCommentLengthMax = $limits['min_comment_length_max'];
$maxCommentLengthMin = $limits['max_comment_length_min'];
$maxCommentLengthMax = $limits['max_comment_length_max'];
// 파일 업로드 제한
$maxFileSizeMin = $limits['max_file_size_min'];
$maxFileSizeMax = $limits['max_file_size_max'];
$maxFileCountMin = $limits['max_file_count_min'];
$maxFileCountMax = $limits['max_file_count_max'];
// NEW 배지 표시 기간 (0 = 표시 안 함)
$newDisplayHoursMin = $limits['new_display_hours_min'];
$newDisplayHoursMax = $limits['new_display_hours_max'];
return [
// 현재 탭 정보 (메타 데이터)
@@ -148,24 +176,32 @@ class StoreBoardSettingsRequest extends FormRequest
'basic_defaults.comment_order' => ['nullable', 'string', 'in:ASC,DESC'],
'basic_defaults.show_view_count' => ['nullable', 'boolean'],
'basic_defaults.use_report' => ['nullable', 'boolean'],
'basic_defaults.min_title_length' => ['nullable', 'integer', 'min:1', 'max:200'],
'basic_defaults.max_title_length' => ['nullable', 'integer', 'min:1', 'max:1000'],
'basic_defaults.min_content_length' => ['nullable', 'integer', 'min:0', 'max:10000'],
'basic_defaults.max_content_length' => ['nullable', 'integer', 'min:1', 'max:100000'],
'basic_defaults.min_comment_length' => ['nullable', 'integer', 'min:1', 'max:1000'],
'basic_defaults.max_comment_length' => ['nullable', 'integer', 'min:1', 'max:10000'],
'basic_defaults.min_title_length' => ['nullable', 'integer', "min:{$minTitleLengthMin}", "max:{$minTitleLengthMax}"],
'basic_defaults.max_title_length' => ['nullable', 'integer', "min:{$maxTitleLengthMin}", "max:{$maxTitleLengthMax}"],
'basic_defaults.min_content_length' => ['nullable', 'integer', "min:{$minContentLengthMin}", "max:{$minContentLengthMax}"],
'basic_defaults.max_content_length' => ['nullable', 'integer', "min:{$maxContentLengthMin}", "max:{$maxContentLengthMax}"],
'basic_defaults.min_comment_length' => ['nullable', 'integer', "min:{$minCommentLengthMin}", "max:{$minCommentLengthMax}"],
'basic_defaults.max_comment_length' => ['nullable', 'integer', "min:{$maxCommentLengthMin}", "max:{$maxCommentLengthMax}"],
'basic_defaults.use_file_upload' => ['nullable', 'boolean'],
'basic_defaults.max_file_size' => ['nullable', 'integer', 'min:1', 'max:200'],
'basic_defaults.max_file_count' => ['nullable', 'integer', 'min:1', 'max:20'],
'basic_defaults.max_file_size' => ['nullable', 'integer', "min:{$maxFileSizeMin}", "max:{$maxFileSizeMax}"],
'basic_defaults.max_file_count' => ['nullable', 'integer', "min:{$maxFileCountMin}", "max:{$maxFileCountMax}"],
'basic_defaults.blocked_keywords' => ['nullable', 'array'],
'basic_defaults.blocked_keywords.*' => ['string', 'max:100'],
// 허용 확장자는 최소 1개 필수 (빈 배열 차단 — 빈 값 저장 시 전 파일 거부되던 버그 방지).
// 부분 탭 저장을 위해 sometimes 유지, nullable 제거.
'basic_defaults.allowed_extensions' => ['sometimes', 'array', 'min:1'],
// 허용 확장자: 첨부 사용 기본값일 때만 최소 1개 필수 (빈 값 저장 시 전 파일 거부되던 버그 방지).
// 첨부 미사용 기본값은 exclude_if 로 검증 자체에서 배제한다 — 환경설정 화면도 탭 데이터를
// 통째로 PUT 하므로 요청에는 항상 이 키가 존재하고, sometimes 는 보호가 되지 않는다.
// null 조건이 별도로 필요한 이유: prepareForValidation() 의 boolean 캐스팅이 isset() 가드를
// 쓰는데 isset(null) 은 false 라 null 이 false 로 캐스팅되지 않고 그대로 통과한다.
// exclude_if 는 배열 앞쪽이어야 한다. 앞선 규칙이 먼저 실패하면 배제되어도 메시지가 남는다.
'basic_defaults.allowed_extensions' => [
'exclude_if:basic_defaults.use_file_upload,false',
'exclude_if:basic_defaults.use_file_upload,null',
'sometimes', 'required', 'array', 'min:1',
],
'basic_defaults.allowed_extensions.*' => ['string', 'max:20'],
'basic_defaults.notify_admin_on_post' => ['nullable', 'boolean'],
'basic_defaults.notify_author' => ['nullable', 'boolean'],
'basic_defaults.new_display_hours' => ['nullable', 'integer', 'min:1', 'max:720'],
'basic_defaults.new_display_hours' => ['nullable', 'integer', "min:{$newDisplayHoursMin}", "max:{$newDisplayHoursMax}"],
// default_board_permissions는 flat key 구조 (예: {"posts.read": ["admin","user"], "manager": ["admin"]})
// Laravel dot notation으로 하위 키를 개별 검증하면 flat key가 중첩 배열로 파싱되어 데이터 유실됨
// → 배열 자체만 검증 (하위 키 개별 검증 금지)
@@ -248,10 +284,14 @@ class StoreBoardSettingsRequest extends FormRequest
*/
public function messages(): array
{
// validation.settings 는 선택적 오버라이드 그룹이다 — 정의되지 않으면 Laravel 기본
// 검증 메시지 + validation.attributes.settings 의 속성명 조합이 그대로 쓰인다.
// (미정의여도 원문 키가 노출되지 않도록 is_array 가드로 걸러낸다)
$messages = __('sirsoft-board::validation.settings');
$base = is_array($messages) ? $messages : [];
return array_merge($base, [
'basic_defaults.allowed_extensions.required' => __('sirsoft-board::validation.allowed_extensions.min'),
'basic_defaults.allowed_extensions.min' => __('sirsoft-board::validation.allowed_extensions.min'),
'report_permissions.view_roles.required_with' => __('sirsoft-board::validation.report_permissions.view_roles.required_with'),
'report_permissions.view_roles.min' => __('sirsoft-board::validation.report_permissions.view_roles.min'),
@@ -52,7 +52,7 @@ class BlindCommentRequest extends FormRequest
public function attributes(): array
{
return [
'reason' => __('sirsoft-board::attributes.blind.reason'),
'reason' => __('sirsoft-board::validation.attributes.blind.reason'),
];
}
}
@@ -52,7 +52,7 @@ class BlindPostRequest extends FormRequest
public function attributes(): array
{
return [
'reason' => __('sirsoft-board::attributes.blind.reason'),
'reason' => __('sirsoft-board::validation.attributes.blind.reason'),
];
}
}
@@ -14,6 +14,10 @@ class BulkUpdateStatusRequest extends FormRequest
{
/**
* 사용자가 이 요청을 수행할 권한이 있는지 확인
*
* 권한 체크는 라우트의 permission 미들웨어에서 수행됩니다.
*
* @return bool 항상 true
*/
public function authorize(): bool
{
@@ -62,9 +66,9 @@ class BulkUpdateStatusRequest extends FormRequest
public function attributes(): array
{
return [
'ids' => __('sirsoft-board::attributes.report.ids'),
'status' => __('sirsoft-board::attributes.report.status'),
'process_note' => __('sirsoft-board::attributes.report.process_note'),
'ids' => __('sirsoft-board::validation.attributes.report.ids'),
'status' => __('sirsoft-board::validation.attributes.report.status'),
'process_note' => __('sirsoft-board::validation.attributes.report.process_note'),
];
}
}
@@ -0,0 +1,81 @@
<?php
namespace Modules\Sirsoft\Board\Http\Requests\Concerns;
/**
* 게시판 제한값(config('sirsoft-board.limits')) 조회 트레이트
*
* 게시판 관련 FormRequest 들이 각자 `$limits['x'] ?? 기본값` 블록을 복제하면서
* 파일마다 폴백 값이 어긋나는 드리프트가 발생했습니다. 폴백 기본값을 이 트레이트
* 한 곳에 모아 config 가 제한값의 단일 출처(SSoT)가 되도록 합니다.
*/
trait ReadsBoardLimits
{
/**
* config 미정의 시 사용할 제한값 기본치
*
* config/board.php 의 limits 섹션과 동일한 값을 유지해야 합니다.
*/
private const BOARD_LIMIT_DEFAULTS = [
'per_page_min' => 5,
'per_page_max' => 100,
'min_title_length_min' => 0,
'min_title_length_max' => 200,
'max_title_length_min' => 1,
'max_title_length_max' => 1000,
'min_content_length_min' => 0,
'min_content_length_max' => 10000,
'max_content_length_min' => 1,
'max_content_length_max' => 100000,
'min_comment_length_min' => 0,
'min_comment_length_max' => 1000,
'max_comment_length_min' => 1,
'max_comment_length_max' => 10000,
'max_file_size_min' => 1,
'max_file_size_max' => 200,
'max_file_count_min' => 1,
'max_file_count_max' => 20,
'category_max' => 50,
'max_reply_depth_min' => 1,
'max_reply_depth_max' => 10,
'max_comment_depth_min' => 0,
'max_comment_depth_max' => 10,
'new_display_hours_min' => 0,
'new_display_hours_max' => 720,
];
/**
* 게시판 제한값 전체를 반환합니다.
*
* config 값이 기본치를 덮어씁니다.
*
* @return array<string, int> 제한값 맵
*/
protected function boardLimits(): array
{
$configured = config('sirsoft-board.limits', []);
return array_merge(
self::BOARD_LIMIT_DEFAULTS,
is_array($configured) ? $configured : []
);
}
/**
* 특정 제한값을 반환합니다.
*
* @param string $key 제한값 키 (예: per_page_min)
* @return int 제한값
*/
protected function boardLimit(string $key): int
{
return (int) ($this->boardLimits()[$key] ?? 0);
}
}
@@ -0,0 +1,74 @@
<?php
namespace Modules\Sirsoft\Board\Http\Requests\Concerns;
/**
* 허용 확장자 목록 해석 트레이트
*
* `$board->allowed_extensions ?? $기본값` 패턴은 null 만 잡고 빈 배열([])을 통과시킵니다.
* 그 결과 `implode(',', [])` 가 `mimes:` 빈 규칙을 만들어 해당 게시판의 모든 파일 업로드가
* 거부됩니다. 빈 배열과 null 은 "확장자 미지정" 이라는 같은 의미이므로 동일하게 취급합니다.
*
* 저장 시점에도 빈 배열을 null 로 정규화하지만(Board 모델 mutator), 이미 저장된 레거시 행을
* 소급 치유하지는 못하므로 소비 시점 방어를 함께 둡니다.
*/
trait ResolvesAllowedExtensions
{
/**
* 모듈 설정도 비어 있을 때 사용할 최종 확장자 목록
*/
private const FALLBACK_ALLOWED_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'pdf', 'zip'];
/**
* 게시판의 허용 확장자를 해석합니다.
*
* 게시판 값이 비어 있으면 모듈 환경설정 기본값 → 최종 기본값 순으로 폴백합니다.
*
* @param mixed $boardExtensions 게시판의 allowed_extensions 값
* @param mixed $defaultExtensions 모듈 환경설정 기본값 (없으면 null)
* @return array<int, string> 비어있지 않은 확장자 목록
*/
protected function resolveAllowedExtensions(mixed $boardExtensions, mixed $defaultExtensions = null): array
{
foreach ([$boardExtensions, $defaultExtensions] as $candidate) {
$normalized = self::normalizeAllowedExtensions($candidate);
if ($normalized !== []) {
return $normalized;
}
}
return self::FALLBACK_ALLOWED_EXTENSIONS;
}
/**
* 확장자 목록을 정규화합니다.
*
* 배열이 아니거나 빈 문자열/비문자열 항목은 제거하고, 소문자 + 중복 제거된
* 순차 배열로 반환합니다.
*
* @param mixed $extensions 원본 값
* @return array<int, string> 정규화된 확장자 목록 (없으면 빈 배열)
*/
public static function normalizeAllowedExtensions(mixed $extensions): array
{
if (! is_array($extensions)) {
return [];
}
$normalized = [];
foreach ($extensions as $extension) {
if (! is_string($extension)) {
continue;
}
$extension = mb_strtolower(trim($extension, " \t\n\r\0\x0B."));
if ($extension !== '') {
$normalized[] = $extension;
}
}
return array_values(array_unique($normalized));
}
}
@@ -0,0 +1,63 @@
<?php
namespace Modules\Sirsoft\Board\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* 댓글 복원 요청 폼 검증
*
* 블라인드 처리된 댓글을 복원할 때 사용됩니다. 블라인드(BlindCommentRequest) 와 대칭으로
* 복원 사유를 검증합니다.
*/
class RestoreCommentRequest extends FormRequest
{
/**
* 사용자가 이 요청을 수행할 권한이 있는지 확인
*
* 권한 체크는 라우트의 permission 미들웨어에서 수행됩니다.
*
* @return bool 항상 true
*/
public function authorize(): bool
{
return true;
}
/**
* 요청에 적용할 검증 규칙
*
* @return array<string, mixed> 검증 규칙 배열
*/
public function rules(): array
{
return [
'reason' => ['nullable', 'string', 'max:1000'],
];
}
/**
* 검증 오류 메시지 커스터마이징
*
* @return array<string, string> 오류 메시지 배열
*/
public function messages(): array
{
return [
'reason.string' => __('sirsoft-board::validation.restore.reason.string'),
'reason.max' => __('sirsoft-board::validation.restore.reason.max'),
];
}
/**
* 검증할 필드의 이름을 커스터마이징
*
* @return array<string, string> 필드명 → 표시명 매핑
*/
public function attributes(): array
{
return [
'reason' => __('sirsoft-board::validation.attributes.restore.reason'),
];
}
}
@@ -41,8 +41,8 @@ class RestorePostRequest extends FormRequest
public function messages(): array
{
return [
'reason.string' => __('sirsoft-board::validation.restore_reason_string'),
'reason.max' => __('sirsoft-board::validation.restore_reason_max'),
'reason.string' => __('sirsoft-board::validation.restore.reason.string'),
'reason.max' => __('sirsoft-board::validation.restore.reason.max'),
];
}
}
@@ -10,12 +10,14 @@ use App\Rules\TranslatableField;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
use Modules\Sirsoft\Board\Http\Requests\Concerns\ReadsBoardLimits;
use Modules\Sirsoft\Board\Http\Requests\Concerns\ValidatesLengthRange;
use Modules\Sirsoft\Board\Rules\BoardTypeValidationRule;
use Modules\Sirsoft\Board\Rules\SlugUniqueRule;
class StoreBoardRequest extends FormRequest
{
use ReadsBoardLimits;
use ValidatesLengthRange;
/**
@@ -104,37 +106,41 @@ class StoreBoardRequest extends FormRequest
*/
public function rules(): array
{
// config에서 제한값 가져오기
$limits = config('sirsoft-board.limits', []);
$perPageMin = $limits['per_page_min'] ?? 5;
$perPageMax = $limits['per_page_max'] ?? 100;
$maxFileSizeMax = $limits['max_file_size_max'] ?? 200; // MB
$maxFileCountMax = $limits['max_file_count_max'] ?? 20;
$categoryMax = $limits['category_max'] ?? 50;
// config 기준 제한값 (폴백 기본치는 ReadsBoardLimits 트레이트가 단일 관리)
$limits = $this->boardLimits();
$perPageMin = $limits['per_page_min'];
$perPageMax = $limits['per_page_max'];
$maxFileSizeMax = $limits['max_file_size_max']; // MB
$maxFileCountMax = $limits['max_file_count_max'];
$categoryMax = $limits['category_max'];
// 제목 길이 제한
$minTitleLengthMin = $limits['min_title_length_min'] ?? 0;
$minTitleLengthMax = $limits['min_title_length_max'] ?? 200;
$maxTitleLengthMin = $limits['max_title_length_min'] ?? 1;
$maxTitleLengthMax = $limits['max_title_length_max'] ?? 200;
$minTitleLengthMin = $limits['min_title_length_min'];
$minTitleLengthMax = $limits['min_title_length_max'];
$maxTitleLengthMin = $limits['max_title_length_min'];
$maxTitleLengthMax = $limits['max_title_length_max'];
// 내용 길이 제한
$minContentLengthMin = $limits['min_content_length_min'] ?? 0;
$minContentLengthMax = $limits['min_content_length_max'] ?? 10000;
$maxContentLengthMin = $limits['max_content_length_min'] ?? 1;
$maxContentLengthMax = $limits['max_content_length_max'] ?? 50000;
$minContentLengthMin = $limits['min_content_length_min'];
$minContentLengthMax = $limits['min_content_length_max'];
$maxContentLengthMin = $limits['max_content_length_min'];
$maxContentLengthMax = $limits['max_content_length_max'];
// 댓글 길이 제한
$minCommentLengthMin = $limits['min_comment_length_min'] ?? 0;
$minCommentLengthMax = $limits['min_comment_length_max'] ?? 1000;
$maxCommentLengthMin = $limits['max_comment_length_min'] ?? 1;
$maxCommentLengthMax = $limits['max_comment_length_max'] ?? 1000;
$minCommentLengthMin = $limits['min_comment_length_min'];
$minCommentLengthMax = $limits['min_comment_length_max'];
$maxCommentLengthMin = $limits['max_comment_length_min'];
$maxCommentLengthMax = $limits['max_comment_length_max'];
// 답글/대댓글 깊이 제한
$maxReplyDepthMin = $limits['max_reply_depth_min'] ?? 1;
$maxReplyDepthMax = $limits['max_reply_depth_max'] ?? 10;
$maxCommentDepthMin = $limits['max_comment_depth_min'] ?? 0;
$maxCommentDepthMax = $limits['max_comment_depth_max'] ?? 10;
$maxReplyDepthMin = $limits['max_reply_depth_min'];
$maxReplyDepthMax = $limits['max_reply_depth_max'];
$maxCommentDepthMin = $limits['max_comment_depth_min'];
$maxCommentDepthMax = $limits['max_comment_depth_max'];
// NEW 배지 표시 기간 (0 = 표시 안 함)
$newDisplayHoursMin = $limits['new_display_hours_min'];
$newDisplayHoursMax = $limits['new_display_hours_max'];
$rules = [
// 기본 정보 (name, description은 다국어 필드 - 기본 언어만 필수)
@@ -164,7 +170,7 @@ class StoreBoardRequest extends FormRequest
'use_reply' => ['required', 'boolean'],
'use_report' => ['required', 'boolean'],
'comment_order' => ['required', 'in:ASC,DESC'],
'new_display_hours' => ['nullable', 'integer', 'min:1', 'max:720'],
'new_display_hours' => ['nullable', 'integer', "min:{$newDisplayHoursMin}", "max:{$newDisplayHoursMax}"],
// 제목 길이 제한
'min_title_length' => ['nullable', 'integer', "min:{$minTitleLengthMin}", "max:{$minTitleLengthMax}"],
@@ -236,7 +242,7 @@ class StoreBoardRequest extends FormRequest
public function attributes(): array
{
$attributes = [
'blocked_keywords' => __('sirsoft-board::admin.form.fields.blocked_keywords.label'),
'blocked_keywords' => __('sirsoft-board::validation.attributes.board.blocked_keywords'),
'add_to_menu' => __('sirsoft-board::validation.attributes.board.add_to_menu'),
];
@@ -6,6 +6,7 @@ use App\Extension\HookManager;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Modules\Sirsoft\Board\Enums\PostStatus;
use Modules\Sirsoft\Board\Http\Requests\Concerns\ResolvesAllowedExtensions;
use Modules\Sirsoft\Board\Models\Board;
use Modules\Sirsoft\Board\Models\Post;
use Modules\Sirsoft\Board\Rules\BlockedKeywordsRule;
@@ -17,8 +18,12 @@ use Modules\Sirsoft\Board\Rules\ParentPostValidationRule;
*/
class StorePostRequest extends FormRequest
{
use ResolvesAllowedExtensions;
/**
* 사용자가 이 요청을 수행할 권한이 있는지 확인
*
* @return bool 항상 true (권한은 미들웨어에서 검증)
*/
public function authorize(): bool
{
@@ -96,7 +101,12 @@ class StorePostRequest extends FormRequest
$maxFiles = $board->max_file_count ?? $defaults['max_file_count'] ?? 5;
$maxSizeMB = $board->max_file_size ?? $defaults['max_file_size'] ?? 10;
$maxSizeKB = $maxSizeMB * 1024;
$allowedExtensions = $board->allowed_extensions ?? $defaults['allowed_extensions'] ?? ['jpg', 'jpeg', 'png', 'gif', 'pdf', 'zip'];
// 게시판/기본값이 모두 비어 있으면(null 또는 []) 최종 기본 확장자로 폴백한다.
// 빈 배열을 그대로 쓰면 'mimes:' 빈 규칙이 되어 전 파일이 거부된다.
$allowedExtensions = $this->resolveAllowedExtensions(
$board->allowed_extensions,
$defaults['allowed_extensions'] ?? null
);
$mimes = implode(',', $allowedExtensions);
$rules['files'] = ['nullable', 'array', 'max:'.$maxFiles];
@@ -5,6 +5,7 @@ namespace Modules\Sirsoft\Board\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
use Modules\Sirsoft\Board\Enums\ReportReasonType;
use Modules\Sirsoft\Board\Repositories\Contracts\ReportRepositoryInterface;
use Modules\Sirsoft\Board\Rules\CooldownRule;
@@ -19,6 +20,10 @@ class StoreReportRequest extends FormRequest
{
/**
* 사용자가 이 요청을 수행할 권한이 있는지 확인
*
* 권한 체크는 라우트의 permission 미들웨어에서 수행됩니다.
*
* @return bool 항상 true
*/
public function authorize(): bool
{
@@ -43,9 +48,9 @@ class StoreReportRequest extends FormRequest
*
* 신고 남발 방지: 일일 횟수 제한, 반려 누적 제한, 연속 신고 쿨타임
*
* @param \Illuminate\Validation\Validator $validator 검증기 인스턴스
* @param Validator $validator 검증기 인스턴스
*/
public function withValidator(\Illuminate\Validation\Validator $validator): void
public function withValidator(Validator $validator): void
{
if (! Auth::check()) {
return;
@@ -104,8 +109,8 @@ class StoreReportRequest extends FormRequest
public function attributes(): array
{
return [
'reason_type' => __('sirsoft-board::attributes.report.reason_type'),
'reason_detail' => __('sirsoft-board::attributes.report.reason_detail'),
'reason_type' => __('sirsoft-board::validation.attributes.report.reason_type'),
'reason_detail' => __('sirsoft-board::validation.attributes.report.reason_detail'),
];
}
}
@@ -10,12 +10,14 @@ use App\Rules\TranslatableField;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
use Modules\Sirsoft\Board\Http\Requests\Concerns\ReadsBoardLimits;
use Modules\Sirsoft\Board\Http\Requests\Concerns\ValidatesLengthRange;
use Modules\Sirsoft\Board\Rules\BoardTypeValidationRule;
use Modules\Sirsoft\Board\Rules\PermissionRolesRequiredRule;
class UpdateBoardRequest extends FormRequest
{
use ReadsBoardLimits;
use ValidatesLengthRange;
/**
@@ -77,43 +79,51 @@ class UpdateBoardRequest extends FormRequest
* 요청에 적용할 검증 규칙
*
* slug는 수정 불가하므로 제외됩니다.
* 부분 업데이트를 지원하기 위해 sometimes 규칙을 적용합니다.
*
* `sometimes` 는 "요청에 그 키가 없을 때만" 검증을 건너뛴다. 관리자 폼은 조회 응답
* 전체를 그대로 PUT 하므로 키는 항상 존재하며, 따라서 sometimes 만으로는 조건부
* 배제가 되지 않는다. 다른 필드 값에 따라 검증을 배제해야 하는 항목은 `exclude_if`
* 를 규칙 배열 첫 번째에 두어야 한다 (allowed_extensions 참조).
*
* @return array<string, mixed> 검증 규칙 배열
*/
public function rules(): array
{
// config에서 제한값 가져오기
$limits = config('sirsoft-board.limits', []);
$perPageMin = $limits['per_page_min'] ?? 5;
$perPageMax = $limits['per_page_max'] ?? 100;
$maxFileSizeMax = $limits['max_file_size_max'] ?? 200; // MB
$maxFileCountMax = $limits['max_file_count_max'] ?? 20;
$categoryMax = $limits['category_max'] ?? 50;
// config 기준 제한값 (폴백 기본치는 ReadsBoardLimits 트레이트가 단일 관리)
$limits = $this->boardLimits();
$perPageMin = $limits['per_page_min'];
$perPageMax = $limits['per_page_max'];
$maxFileSizeMax = $limits['max_file_size_max']; // MB
$maxFileCountMax = $limits['max_file_count_max'];
$categoryMax = $limits['category_max'];
// 제목 길이 제한
$minTitleLengthMin = $limits['min_title_length_min'] ?? 0;
$minTitleLengthMax = $limits['min_title_length_max'] ?? 200;
$maxTitleLengthMin = $limits['max_title_length_min'] ?? 1;
$maxTitleLengthMax = $limits['max_title_length_max'] ?? 200;
$minTitleLengthMin = $limits['min_title_length_min'];
$minTitleLengthMax = $limits['min_title_length_max'];
$maxTitleLengthMin = $limits['max_title_length_min'];
$maxTitleLengthMax = $limits['max_title_length_max'];
// 내용 길이 제한
$minContentLengthMin = $limits['min_content_length_min'] ?? 0;
$minContentLengthMax = $limits['min_content_length_max'] ?? 10000;
$maxContentLengthMin = $limits['max_content_length_min'] ?? 1;
$maxContentLengthMax = $limits['max_content_length_max'] ?? 50000;
$minContentLengthMin = $limits['min_content_length_min'];
$minContentLengthMax = $limits['min_content_length_max'];
$maxContentLengthMin = $limits['max_content_length_min'];
$maxContentLengthMax = $limits['max_content_length_max'];
// 댓글 길이 제한
$minCommentLengthMin = $limits['min_comment_length_min'] ?? 0;
$minCommentLengthMax = $limits['min_comment_length_max'] ?? 1000;
$maxCommentLengthMin = $limits['max_comment_length_min'] ?? 1;
$maxCommentLengthMax = $limits['max_comment_length_max'] ?? 1000;
$minCommentLengthMin = $limits['min_comment_length_min'];
$minCommentLengthMax = $limits['min_comment_length_max'];
$maxCommentLengthMin = $limits['max_comment_length_min'];
$maxCommentLengthMax = $limits['max_comment_length_max'];
// 답글/대댓글 깊이 제한
$maxReplyDepthMin = $limits['max_reply_depth_min'] ?? 1;
$maxReplyDepthMax = $limits['max_reply_depth_max'] ?? 10;
$maxCommentDepthMin = $limits['max_comment_depth_min'] ?? 0;
$maxCommentDepthMax = $limits['max_comment_depth_max'] ?? 10;
$maxReplyDepthMin = $limits['max_reply_depth_min'];
$maxReplyDepthMax = $limits['max_reply_depth_max'];
$maxCommentDepthMin = $limits['max_comment_depth_min'];
$maxCommentDepthMax = $limits['max_comment_depth_max'];
// NEW 배지 표시 기간 (0 = 표시 안 함)
$newDisplayHoursMin = $limits['new_display_hours_min'];
$newDisplayHoursMax = $limits['new_display_hours_max'];
$rules = [
// 기본 정보 (slug 제외, name/description은 다국어 필드)
@@ -142,7 +152,7 @@ class UpdateBoardRequest extends FormRequest
'use_reply' => ['sometimes', 'required', 'boolean'],
'use_report' => ['sometimes', 'required', 'boolean'],
'comment_order' => ['sometimes', 'required', 'in:ASC,DESC'],
'new_display_hours' => ['sometimes', 'nullable', 'integer', 'min:1', 'max:720'],
'new_display_hours' => ['sometimes', 'nullable', 'integer', "min:{$newDisplayHoursMin}", "max:{$newDisplayHoursMax}"],
// 제목 길이 제한
'min_title_length' => ['sometimes', 'nullable', 'integer', "min:{$minTitleLengthMin}", "max:{$minTitleLengthMax}"],
@@ -160,12 +170,18 @@ class UpdateBoardRequest extends FormRequest
'use_file_upload' => ['sometimes', 'required', 'boolean'],
'max_file_size' => ['sometimes', 'nullable', 'integer', 'min:1', "max:{$maxFileSizeMax}"],
'max_file_count' => ['sometimes', 'nullable', 'integer', 'min:1', "max:{$maxFileCountMax}"],
// 허용 확장자: 전송되면 최소 1개 필수 (빈 배열 저장 시 전 파일 거부되던 버그 방지).
// 부분 수정을 위해 sometimes 유지, nullable 제거.
'allowed_extensions' => ['sometimes', 'array', 'min:1'],
// 허용 확장자: 첨부 사용 게시판만 최소 1개 필수 (빈 값 저장 시 전 파일 거부되던 버그 방지).
// 첨부 미사용 게시판은 exclude_if 로 검증 자체에서 배제한다 — 관리자 폼이 조회 응답
// 전체를 그대로 PUT 하므로 요청에는 항상 이 키가 존재하고, sometimes 는 보호가 되지 않는다.
// exclude_if 는 배열 첫 번째여야 한다. 앞선 규칙이 먼저 실패하면 배제되어도 메시지가 남는다.
'allowed_extensions' => ['exclude_if:use_file_upload,false', 'sometimes', 'required', 'array', 'min:1'],
'allowed_extensions.*' => ['string', 'max:10'],
// 관리자 설정 (관리자는 최소 1명 필수, 스텝은 선택적)
// audit:allow formrequest-sometimes-array-min-inert reason: 관리자 0명은 유효한 상태가 아니다.
// board_manager_ids 는 역할 멤버십에서 파생되므로(BoardResource::getBoardRoleData) 관리자 계정이
// 삭제되면 빈 배열이 될 수 있는데, 이때는 저장을 막고 관리자를 다시 지정하도록 안내하는 것이 맞다.
// allowed_extensions 처럼 "비어 있음"이 정상 상태인 필드와 다르다.
'board_manager_ids' => ['sometimes', 'required', 'array', 'min:1'],
'board_manager_ids.*' => ['uuid', Rule::exists(User::class, 'uuid')],
'board_step_ids' => ['sometimes', 'nullable', 'array'],
@@ -218,7 +234,7 @@ class UpdateBoardRequest extends FormRequest
public function attributes(): array
{
$attributes = [
'blocked_keywords' => __('sirsoft-board::admin.form.fields.blocked_keywords.label'),
'blocked_keywords' => __('sirsoft-board::validation.attributes.board.blocked_keywords'),
'add_to_menu' => __('sirsoft-board::validation.attributes.board.add_to_menu'),
];
@@ -349,6 +365,7 @@ class UpdateBoardRequest extends FormRequest
'max_file_size.max' => __('sirsoft-board::validation.max_file_size.max'),
'max_file_count.min' => __('sirsoft-board::validation.max_file_count.min'),
'max_file_count.max' => __('sirsoft-board::validation.max_file_count.max'),
'allowed_extensions.required' => __('sirsoft-board::validation.allowed_extensions.min'),
'allowed_extensions.min' => __('sirsoft-board::validation.allowed_extensions.min'),
// 관리자 설정 검증 메시지
@@ -86,10 +86,10 @@ class UpdateCommentRequest extends FormRequest
public function attributes(): array
{
return [
'content' => __('sirsoft-board::attributes.comment.content'),
'is_secret' => __('sirsoft-board::attributes.comment.is_secret'),
'status' => __('sirsoft-board::attributes.comment.status'),
'password' => __('sirsoft-board::attributes.comment.password'),
'content' => __('sirsoft-board::validation.attributes.comment.content'),
'is_secret' => __('sirsoft-board::validation.attributes.comment.is_secret'),
'status' => __('sirsoft-board::validation.attributes.comment.status'),
'password' => __('sirsoft-board::validation.attributes.comment.password'),
];
}
}
@@ -76,8 +76,8 @@ class UpdateStatusRequest extends FormRequest
public function attributes(): array
{
return [
'status' => __('sirsoft-board::attributes.report.status'),
'process_note' => __('sirsoft-board::attributes.report.process_note'),
'status' => __('sirsoft-board::validation.attributes.report.status'),
'process_note' => __('sirsoft-board::validation.attributes.report.process_note'),
];
}
}
@@ -4,6 +4,7 @@ namespace Modules\Sirsoft\Board\Http\Requests;
use App\Extension\HookManager;
use Illuminate\Foundation\Http\FormRequest;
use Modules\Sirsoft\Board\Http\Requests\Concerns\ResolvesAllowedExtensions;
use Modules\Sirsoft\Board\Models\Board;
/**
@@ -11,8 +12,12 @@ use Modules\Sirsoft\Board\Models\Board;
*/
class UploadAttachmentRequest extends FormRequest
{
use ResolvesAllowedExtensions;
/**
* 요청 권한 확인
*
* @return bool 항상 true (권한은 미들웨어에서 검증)
*/
public function authorize(): bool
{
@@ -43,7 +48,14 @@ class UploadAttachmentRequest extends FormRequest
$maxSizeMB = $board->max_file_size ?? 10;
$maxSizeKB = $maxSizeMB * 1024;
$allowedExtensions = $board->allowed_extensions ?? ['jpg', 'jpeg', 'png', 'gif', 'pdf', 'zip'];
// 게시판 값이 비어 있으면(null 또는 []) 모듈 기본값으로 폴백한다.
// 빈 배열을 그대로 쓰면 'mimes:' 빈 규칙이 되어 전 파일이 거부된다.
$defaults = g7_module_settings('sirsoft-board', 'basic_defaults', []);
$allowedExtensions = $this->resolveAllowedExtensions(
$board->allowed_extensions,
$defaults['allowed_extensions'] ?? null
);
$mimes = implode(',', $allowedExtensions);
$rules = [
@@ -8,6 +8,7 @@ use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rule;
use Modules\Sirsoft\Board\Enums\PostStatus;
use Modules\Sirsoft\Board\Http\Requests\Concerns\ResolvesAllowedExtensions;
use Modules\Sirsoft\Board\Models\Board;
use Modules\Sirsoft\Board\Models\Post;
use Modules\Sirsoft\Board\Rules\BlockedKeywordsRule;
@@ -24,8 +25,12 @@ use Modules\Sirsoft\Board\Rules\ParentPostValidationRule;
*/
class StorePostRequest extends FormRequest
{
use ResolvesAllowedExtensions;
/**
* 사용자가 이 요청을 수행할 권한이 있는지 확인
*
* @return bool 항상 true (권한은 미들웨어에서 검증)
*/
public function authorize(): bool
{
@@ -116,7 +121,12 @@ class StorePostRequest extends FormRequest
$maxFiles = $board->max_file_count ?? $defaults['max_file_count'] ?? 5;
$maxSizeMB = $board->max_file_size ?? $defaults['max_file_size'] ?? 10;
$maxSizeKB = $maxSizeMB * 1024;
$allowedExtensions = $board->allowed_extensions ?? $defaults['allowed_extensions'] ?? ['jpg', 'jpeg', 'png', 'gif', 'pdf', 'zip'];
// 게시판/기본값이 모두 비어 있으면(null 또는 []) 최종 기본 확장자로 폴백한다.
// 빈 배열을 그대로 쓰면 'mimes:' 빈 규칙이 되어 전 파일이 거부된다.
$allowedExtensions = $this->resolveAllowedExtensions(
$board->allowed_extensions,
$defaults['allowed_extensions'] ?? null
);
$mimes = implode(',', $allowedExtensions);
$rules['files'] = ['nullable', 'array', 'max:'.$maxFiles];
@@ -183,7 +193,9 @@ class StorePostRequest extends FormRequest
/**
* 검증 통과 후 비밀번호를 해싱합니다.
*
* @return array<string, mixed>
* @param string|null $key 조회할 키 (null 이면 전체)
* @param mixed $default 키가 없을 때 반환할 기본값
* @return array<string, mixed> 해싱이 적용된 검증 통과 데이터
*/
public function validated($key = null, $default = null): mixed
{
@@ -45,7 +45,7 @@ class CommentResource extends BaseApiResource
// 댓글 속성
'is_secret' => $this->is_secret,
'status' => $this->status?->value ?? 'published',
'status_label' => $this->status?->label() ?? __('sirsoft-board::messages.post.status.published'),
'status_label' => $this->status?->label() ?? __('sirsoft-board::enums.post_status.published'),
'depth' => $this->depth,
'replies_count' => $this->replies_count ?? 0,
@@ -3,12 +3,14 @@
namespace Modules\Sirsoft\Board\Models;
use App\Casts\AsUnicodeJson;
use App\Models\Permission;
use App\Models\User;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Modules\Sirsoft\Board\Casts\AsAllowedExtensions;
use Modules\Sirsoft\Board\Database\Factories\BoardFactory;
use Modules\Sirsoft\Board\Enums\BoardOrderBy;
use Modules\Sirsoft\Board\Enums\OrderDirection;
@@ -23,11 +25,11 @@ class Board extends Model
'is_active' => ['label_key' => 'sirsoft-board::activity_log.fields.is_active', 'type' => 'boolean'],
'per_page' => ['label_key' => 'sirsoft-board::activity_log.fields.per_page', 'type' => 'number'],
'per_page_mobile' => ['label_key' => 'sirsoft-board::activity_log.fields.per_page_mobile', 'type' => 'number'],
'order_by' => ['label_key' => 'sirsoft-board::activity_log.fields.order_by', 'type' => 'enum', 'enum' => \Modules\Sirsoft\Board\Enums\BoardOrderBy::class],
'order_direction' => ['label_key' => 'sirsoft-board::activity_log.fields.order_direction', 'type' => 'enum', 'enum' => \Modules\Sirsoft\Board\Enums\OrderDirection::class],
'order_by' => ['label_key' => 'sirsoft-board::activity_log.fields.order_by', 'type' => 'enum', 'enum' => BoardOrderBy::class],
'order_direction' => ['label_key' => 'sirsoft-board::activity_log.fields.order_direction', 'type' => 'enum', 'enum' => OrderDirection::class],
'type' => ['label_key' => 'sirsoft-board::activity_log.fields.type', 'type' => 'text'],
'show_view_count' => ['label_key' => 'sirsoft-board::activity_log.fields.show_view_count', 'type' => 'boolean'],
'secret_mode' => ['label_key' => 'sirsoft-board::activity_log.fields.secret_mode', 'type' => 'enum', 'enum' => \Modules\Sirsoft\Board\Enums\SecretMode::class],
'secret_mode' => ['label_key' => 'sirsoft-board::activity_log.fields.secret_mode', 'type' => 'enum', 'enum' => SecretMode::class],
'use_comment' => ['label_key' => 'sirsoft-board::activity_log.fields.use_comment', 'type' => 'boolean'],
'use_reply' => ['label_key' => 'sirsoft-board::activity_log.fields.use_reply', 'type' => 'boolean'],
'max_reply_depth' => ['label_key' => 'sirsoft-board::activity_log.fields.max_reply_depth', 'type' => 'number'],
@@ -170,7 +172,8 @@ class Board extends Model
'name' => AsUnicodeJson::class,
'description' => AsUnicodeJson::class,
'categories' => 'array',
'allowed_extensions' => 'array',
// 빈 배열을 NULL 로 정규화해 "확장자 미지정" 표현을 하나로 유지합니다.
'allowed_extensions' => AsAllowedExtensions::class,
'blocked_keywords' => 'array',
'is_active' => 'boolean',
'show_view_count' => 'boolean',
@@ -193,6 +196,8 @@ class Board extends Model
/**
* 생성자와의 관계를 정의합니다.
*
* @return BelongsTo 생성자 관계
*/
public function creator(): BelongsTo
{
@@ -201,6 +206,8 @@ class Board extends Model
/**
* 수정자와의 관계를 정의합니다.
*
* @return BelongsTo 수정자 관계
*/
public function updater(): BelongsTo
{
@@ -209,6 +216,8 @@ class Board extends Model
/**
* 게시글 목록과의 관계를 정의합니다.
*
* @return HasMany 게시글 관계
*/
public function posts(): HasMany
{
@@ -217,6 +226,8 @@ class Board extends Model
/**
* 댓글 목록과의 관계를 정의합니다.
*
* @return HasMany 댓글 관계
*/
public function comments(): HasMany
{
@@ -225,6 +236,8 @@ class Board extends Model
/**
* 첨부파일 목록과의 관계를 정의합니다.
*
* @return HasMany 첨부파일 관계
*/
public function attachments(): HasMany
{
@@ -233,6 +246,8 @@ class Board extends Model
/**
* 신고 목록과의 관계를 정의합니다.
*
* @return HasMany 신고 관계
*/
public function reports(): HasMany
{
@@ -241,6 +256,9 @@ class Board extends Model
/**
* 지정된 로케일의 게시판명 반환
*
* @param string|null $locale 로케일 (null 이면 현재 로케일)
* @return string 해당 로케일의 게시판명
*/
public function getLocalizedName(?string $locale = null): string
{
@@ -325,7 +343,7 @@ class Board extends Model
$identifier = "sirsoft-board.{$this->slug}.{$key}";
// 권한 조회
$permission = \App\Models\Permission::where('identifier', $identifier)->first();
$permission = Permission::where('identifier', $identifier)->first();
if (! $permission) {
// 권한이 없으면 null (전체 허용)
@@ -21,7 +21,6 @@ trait ChecksBoardPermission
* @param string $slug 게시판 슬러그
* @param string $action 권한 액션 (예: 'admin.posts.read')
* @param PermissionType $type 권한 타입
* @return bool
*/
protected function checkBoardPermission(string $slug, string $action, PermissionType $type = PermissionType::Admin): bool
{
@@ -35,7 +34,6 @@ trait ChecksBoardPermission
* @param array $actions 권한 액션 배열
* @param PermissionType $type 권한 타입
* @param bool $requireAll 모든 권한 필요 여부
* @return bool
*/
protected function checkBoardPermissions(string $slug, array $actions, PermissionType $type = PermissionType::Admin, bool $requireAll = false): bool
{
@@ -50,7 +48,6 @@ trait ChecksBoardPermission
* @param string $resource 리소스명 (예: 'boards')
* @param string $action 권한 액션 (예: 'create')
* @param PermissionType $type 권한 타입
* @return bool
*/
protected function checkModulePermission(string $resource, string $action, PermissionType $type = PermissionType::Admin): bool
{
@@ -63,12 +60,11 @@ trait ChecksBoardPermission
* @param string $slug 게시판 슬러그
* @param string $action 권한 액션
* @param PermissionType $type 권한 타입
* @return JsonResponse|null
*/
protected function authorizeOrFail(string $slug, string $action, PermissionType $type = PermissionType::Admin): ?JsonResponse
{
if (! $this->checkBoardPermission($slug, $action, $type)) {
return $this->forbidden('sirsoft-board::messages.permissions.access_denied');
return $this->forbidden('sirsoft-board::messages.permission.denied');
}
return null;
@@ -81,12 +77,11 @@ trait ChecksBoardPermission
* @param array $actions 권한 액션 배열
* @param PermissionType $type 권한 타입
* @param bool $requireAll 모든 권한 필요 여부
* @return JsonResponse|null
*/
protected function authorizeAnyOrFail(string $slug, array $actions, PermissionType $type = PermissionType::Admin, bool $requireAll = false): ?JsonResponse
{
if (! $this->checkBoardPermissions($slug, $actions, $type, $requireAll)) {
return $this->forbidden('sirsoft-board::messages.permissions.access_denied');
return $this->forbidden('sirsoft-board::messages.permission.denied');
}
return null;
@@ -98,12 +93,11 @@ trait ChecksBoardPermission
* @param string $resource 리소스명
* @param string $action 권한 액션
* @param PermissionType $type 권한 타입
* @return JsonResponse|null
*/
protected function authorizeModuleOrFail(string $resource, string $action, PermissionType $type = PermissionType::Admin): ?JsonResponse
{
if (! $this->checkModulePermission($resource, $action, $type)) {
return $this->forbidden('sirsoft-board::messages.permissions.access_denied');
return $this->forbidden('sirsoft-board::messages.permission.denied');
}
return null;
@@ -115,7 +109,6 @@ trait ChecksBoardPermission
* Admin/User 페이지에 따라 자동으로 권한 타입을 결정합니다.
*
* @param string $identifier 권한 식별자 (예: 'sirsoft-board.notice.posts.read')
* @return bool
*/
protected function checkPermissionByIdentifier(string $identifier): bool
{
@@ -129,8 +122,6 @@ trait ChecksBoardPermission
* 권한 캐시를 초기화합니다.
*
* 테스트 환경에서 동일 Request 인스턴스가 재사용되는 경우 호출합니다.
*
* @return void
*/
public static function clearPermissionCache(): void
{
@@ -146,13 +137,12 @@ trait ChecksBoardPermission
* @param string $permission 권한 식별자 (파이프로 복수 권한 구분)
* @param PermissionType $type 권한 타입
* @param bool $requireAll 모든 권한 필요 여부
* @return bool
*/
protected function checkPermissionViaMiddleware(string $permission, PermissionType $type, bool $requireAll = true): bool
{
$request = request();
$userId = Auth::id() ?? 'guest';
$cacheKey = $userId . '|' . $permission . '|' . $type->value . '|' . ($requireAll ? '1' : '0');
$cacheKey = $userId.'|'.$permission.'|'.$type->value.'|'.($requireAll ? '1' : '0');
// 요청 인스턴스에 권한 캐시를 저장하여 요청 단위로 자동 격리
$cache = $request->attributes->get('_board_permission_cache', []);
@@ -77,6 +77,7 @@ return [
// ChangeDetector field labels
'fields' => [
// Board
'name' => 'Board Name',
'is_active' => 'Active',
'per_page' => 'Posts Per Page',
'per_page_mobile' => 'Posts Per Page (Mobile)',

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