diff --git a/.claude/skills/command/SKILL.md b/.claude/skills/command/SKILL.md new file mode 100644 index 00000000000..19bb338c69d --- /dev/null +++ b/.claude/skills/command/SKILL.md @@ -0,0 +1,199 @@ +--- +name: command +description: EC-CUBE 4.4 のコンソールコマンド(Symfony Console・#[AsCommand])を実装するときの規約。「コマンドを作って」「バッチを実装して」「cronで動かす処理を作って」「コンソールコマンドを追加して」などと言われたとき、または src/Eccube/Command・プラグインの Command 配下を作成・編集するときに使用する。 +--- + +# Command 規約 — コンソールコマンド/バッチ(EC-CUBE 4.4) + +**対象**: `src/Eccube/Command/**/*.php`, `app/Customize/Command/**/*.php`, `app/Plugin/*/Command/**/*.php` +**前提**: Symfony 7.4 / PHP 8.2+ + +> 目的: コンソールコマンド(バッチ・cron 用途含む)を「入出力と起動の薄い層」に保ち、 +> 業務ロジックは Service/Repository へ寄せる。Skill `controller` / `service` と同じ責務分離をコマンドにも適用する。 +> コマンドは「もう 1 つの入口」であって、ロジックの置き場所ではない。 + +## 基本ルール + +- **`Symfony\Component\Console\Command\Command` を継承**し、クラスに **`#[AsCommand(name: ..., description: ...)]` 属性**を付ける。 + - 属性は `Symfony\Component\Console\Attribute\AsCommand`。 + - コマンド名は **`eccube:` を接頭辞**にしたコロン区切り(実例: `eccube:delete-carts` / `eccube:fixtures:generate` / `eccube:generate:proxies` / `eccube:plugin:enable`)。 +- **手動登録は不要**。`app/config/eccube/services.yaml` の `_defaults` で `autoconfigure: true` が効いており、 + `Eccube\` / `Customize\` / `Plugin\` 配下のクラスは `#[AsCommand]` を付けるだけで `console.command` として自動登録される。 + サービス定義に手書きでタグを足さない。 +- **依存はコンストラクタインジェクション**で受ける(`private readonly`/既存実装は `protected` も混在)。 + リポジトリ・サービス・`EntityManagerInterface`・`EccubeConfig` 等を注入する。 + - 注: コマンドの場合 `parent::__construct()` の呼び出しが必須(後述)。トレイト経由で依存を渡したいときだけ + `#[Required]` セッター注入を使う(`PluginCommandTrait` が `setPluginService()` 等で採用)。 +- **`configure()` で引数・オプションを宣言**する。`addArgument()` / `addOption()`、必要に応じて `setHelp()`。 +- **`execute(InputInterface $input, OutputInterface $output): int` に処理を書き、`int` を返す**。 + 正常終了は `0`、異常終了は非 0(`1` 等)。 + `Command::SUCCESS` / `Command::FAILURE` 定数も使えるが、**EC-CUBE コアは一貫して `return 0;` のリテラルを使っている**ので踏襲する。 +- **出力は `SymfonyStyle`** を使う(`$io->success()` / `$io->error()` / `$io->comment()` / `$io->title()` 等)。 + 低レベルに `$output->writeln()` を使う実装もあるが、ユーザ向けメッセージは `SymfonyStyle` に寄せる。 +- **業務ロジックはコマンドに直書きしない**。Service/Repository/PurchaseFlow へ委譲し、コマンドは + 「引数の取得 → 委譲 → 結果の出力 → 終了コード」に徹する(Skill `service` 参照)。 + +## 実装パターン + +### 基本形(引数+DI+委譲) + +`DeleteCartsCommand` を基にした骨格。コンストラクタで依存を受け、`configure()` で引数を宣言し、 +`execute()` は委譲と出力に徹する。 + +```php +addArgument('date', InputArgument::REQUIRED, 'Process records before the specified date'); + } + + #[\Override] + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $date = $input->getArgument('date'); + + // 業務処理は Service へ委譲する(コマンドにロジックを書かない) + $count = $this->exampleService->purgeBefore(new \DateTime($date)); + + $io->success(sprintf('Purged %d records.', $count)); + + return 0; + } +} +``` + +### オプション(`addOption`)とデフォルト値 + +`GenerateDummyDataCommand` の実例。`InputOption::VALUE_REQUIRED`(値あり・デフォルト指定可)と +`InputOption::VALUE_NONE`(フラグ)を使い分ける。 + +```php +protected function configure(): void +{ + $this + ->addOption('with-locale', null, InputOption::VALUE_REQUIRED, 'Set to the locale.', 'ja_JP') + ->addOption('without-image', null, InputOption::VALUE_NONE, 'Do not generate images.') + ->addOption('products', null, InputOption::VALUE_REQUIRED, 'Number of Products.', 100); +} + +protected function execute(InputInterface $input, OutputInterface $output): int +{ + $locale = $input->getOption('with-locale'); + $notImage = $input->getOption('without-image'); + // ... + return 0; +} +``` + +### バッチ(大量データ)でのトランザクションと flush + +大量レコードを扱うバッチは、**一定件数ごとにまとめて `flush()`** する(ループ内で毎回 `flush()` しない)。 +`GenerateDummyDataCommand` は `$batchSize = 100` でまとめて flush している。 +明示的なトランザクション境界が必要なら `DeleteCartsCommand` のように `beginTransaction()`/`commit()`/`rollback()` で囲む。 + +```php +$batchSize = 100; +foreach ($records as $i => $record) { + $this->exampleService->process($record); + if ((($i + 1) % $batchSize) === 0) { + $this->entityManager->flush(); + } +} +$this->entityManager->flush(); // 端数を flush +``` + +```php +// 明示トランザクション(DeleteCartsCommand の定石): 失敗時は rollback して非 0 を返す +try { + $this->entityManager->beginTransaction(); + // ... 処理 ... + $this->entityManager->flush(); + $this->entityManager->commit(); +} catch (\Exception) { + $io->error('Failed. Rollbacked.'); + $this->entityManager->rollback(); + + return 1; +} +``` + +### プラグイン/Customize のコマンド + +`#[AsCommand]` を付けて `app/Plugin/{Code}/Command/` または `app/Customize/Command/` に置くだけで、 +`Plugin\` / `Customize\` 名前空間も `autoconfigure: true` の対象なので自動登録される。 +共通処理をトレイトに切り出すなら `PluginCommandTrait` のように `#[Required]` セッター注入で依存を受ける。 + +## cron / 定期実行 + +- **EC-CUBE 4.4 のコアには独自のスケジューラ/cron 機構は無い**(`composer.json` に `symfony/scheduler` も含まれない。 + `#[AsCronTask]` 等の属性も未使用)。推測でスケジューラ機能を持ち出さない。 +- **定期実行は OS の cron(または systemd timer 等)から `bin/console <コマンド名>` を叩く**のが事実上の手段。 + そのため、cron 用途のコマンドは「副作用が冪等/安全に再実行できる」「引数で対象範囲を絞れる」設計にしておく。 + +## よくある間違い + +整形・型・属性変換(`vendor/bin/rector` / `phpstan` / `php-cs-fixer`)が扱える範囲はここに挙げない。 +**ツールでは判断できない**観点だけ: + +- ❌ `execute()` に業務的な計算・判定・複数 Repository 横断処理を直書き → ✅ Service/Repository へ委譲し、コマンドは入出力と終了コードに徹する +- ❌ コンストラクタで `parent::__construct()` を呼び忘れる → ✅ コマンドでは必須(呼ばないと実行時エラー) +- ❌ サービス定義に手書きで `console.command` タグを足す → ✅ `#[AsCommand]` + `autoconfigure` 任せ(手動登録不要) +- ❌ `execute()` の戻り値を書かない/`void` にする → ✅ `int` を返す(正常 `0`、異常は非 0) +- ❌ ループ内で毎回 `flush()` してバッチが遅い → ✅ バッチサイズごとにまとめて `flush()`、端数も最後に flush +- ❌ 「Symfony Scheduler で定期実行」と推測で書く → ✅ コアに機構は無い。OS の cron から `bin/console` を叩く前提で冪等に作る +- ❌ コマンド名を独自の命名で付ける → ✅ `eccube:` 接頭辞のコロン区切り(既存コマンドに倣う) + +## 実行・確認方法 + +QA ツール(PHPUnit / PHPStan / PHP-CS-Fixer / Rector)の実行手順は **AGENTS.md「開発コマンド」**を参照。 +コマンド固有の確認は以下: + +```bash +bin/console list # 登録済みコマンド一覧(自分のコマンドが出るか) +bin/console list eccube # eccube: 名前空間のコマンド一覧 +bin/console help <コマンド名> # 引数・オプションの確認 +bin/console <コマンド名> --dry-run 等 # 副作用のあるバッチは小さい入力で試す +``` + +新規コマンドが `bin/console list` に現れれば autoconfigure による登録は成功している。 + +--- + +実装・改修後は、Skill `review-responsibility` で責務分離を点検すること。 diff --git a/.claude/skills/controller/SKILL.md b/.claude/skills/controller/SKILL.md index fbfa81acca6..317eee0e266 100644 --- a/.claude/skills/controller/SKILL.md +++ b/.claude/skills/controller/SKILL.md @@ -66,7 +66,7 @@ class ExampleController extends AbstractController - **CSRF**: **GET 以外の状態変更(更新・削除・Ajax 等)はトークンを検証する**。 - フォーム経由(`$form->handleRequest()` + `isValid()`)は CSRF 保護込み(Skill `formtype` 参照)。 - **フォームを介さない削除・Ajax アクションは、基底クラスの `$this->isTokenValid()` を明示的に呼ぶ**。 - 検証失敗時は `BadRequestHttpException` を投げる(コアの定石)。 + `isTokenValid()` は検証失敗時に `AccessDeniedHttpException` を投げる(`AbstractController::isTokenValid()`)。 ```php // 削除(フォームを介さない): methods は GET 以外にし、トークンを検証する @@ -131,6 +131,8 @@ vendor/bin/php-cs-fixer fix # PSR-12 整形・ライセン - ❌ 複数アクションに同じ処理をコピペ → ✅ Service の 1 メソッドに共通化 - ❌ 具象クラス型ヒントで密結合 → ✅ インターフェース型ヒント+コンストラクタ DI - ❌ 削除/Ajax 等の状態変更でトークン未検証 → ✅ `$this->isTokenValid()` を呼ぶ(GET 以外) +- ❌ `$this->isTokenValid();`(戻り値を捨てた bare 呼び出し)を「CSRF 未検証」と誤読 → ✅ 無効時は `AccessDeniedHttpException` を投げ戻り値は常に true。bare 呼び出しで検証は成立し、`if (!isTokenValid())` の false 分岐はデッドコード +- ❌ `#[Template]` 付きアクションの「エラー時に再描画される」を前提にレビュー判断 → ✅ `#[Template]` は配列を返したときのみ engage。Response/Redirect を返すパス(例: フォーム失敗で `redirectToRoute`)では描画されない - ❌ 管理アクションを `%eccube_admin_route%` 配下以外に置く → ✅ admin ファイアウォール配下に置く --- diff --git a/.claude/skills/csv/SKILL.md b/.claude/skills/csv/SKILL.md new file mode 100644 index 00000000000..eb0a96a7785 --- /dev/null +++ b/.claude/skills/csv/SKILL.md @@ -0,0 +1,179 @@ +--- +name: csv +description: EC-CUBE 4.4 の CSV 入出力(CsvImportService・CsvExportService・CSV 定義)を実装・改修するときの規約。「CSVインポートを実装して」「CSVエクスポートを追加して」「商品/受注のCSV出力を作って」「CSVの項目を増やして」などと言われたとき、または src/Eccube/Service/Csv*Service・CSV 定義を作成・編集するときに使用する。 +--- + +# CSV 入出力 規約(EC-CUBE 4.4) + +**対象**: `src/Eccube/Service/CsvImportService.php`, `src/Eccube/Service/CsvExportService.php`, +CSV 項目定義(`dtb_csv` = `Eccube\Entity\Csv` / `mtb_csv_type` = `Eccube\Entity\Master\CsvType`), +および CSV 入出力を行う管理画面コントローラ(`src/Eccube/Controller/Admin/**`)。 +**前提**: Symfony 7.4 / PHP 8.2+ / Doctrine ORM 3.x + +> 目的: EC-CUBE の CSV 入出力は「**出力項目をマスタ(`dtb_csv`)で定義**し、`CsvExportService` が +> エンティティから値を引く」「**入力は `CsvImportService`(Iterator)で 1 行ずつ読む**」という二つの確立した仕組みに乗る。 +> 自前で `fgetcsv` / `fputcsv` を書き散らさず、既存サービスとマスタ定義の枠組みに従う。 + +## 対象 / 前提 + +- **エクスポート**: `CsvExportService`(`Eccube\Service`)。`dtb_csv` の定義に従ってヘッダ・データ行を `php://output` へ流す。 + コントローラ側は `StreamedResponse` でラップして返す。 +- **インポート**: `CsvImportService`(`Eccube\Service`)。`\SplFileObject` を包む `\Iterator`/`\SeekableIterator`/`\Countable`。 + 1 行を連想配列(ヘッダ名 => 値)として返す。 +- **項目定義**: 何のエンティティのどのカラムを CSV のどの列に出すかは `dtb_csv`(`Csv` エンティティ)で持つ。 + CSV 種別(商品・会員・受注・配送…)は `mtb_csv_type`(`CsvType` マスタ、`CSV_TYPE_*` 定数)で区別する。 +- 文字コード・区切り文字は **コントローラやサービスにハードコードせず `EccubeConfig`(`eccube.yaml`)の設定値**を使う。 + +## 基本ルール + +- **新規に CSV 入出力ロジックを書くときも、まず既存サービスに乗れないか確認する**。 + `fputcsv` / `fgetcsv` の直書きは `CsvExportService::fputcsv()` / `CsvImportService` で吸収されている。 +- **出力項目はコードに埋め込まず `dtb_csv` 定義で表現する**。項目の追加・並び替え・有効無効は + `Csv`(`field_name` / `reference_field_name` / `disp_name` / `sort_no` / `enabled`)で制御する。 +- **文字コード・区切り文字は設定値を使う**(`src/Eccube/Service/CsvExportService.php` / `eccube.yaml`): + - 出力エンコーディング: `eccube_csv_export_encoding`(既定 `SJIS-win`) + - 出力区切り文字: `eccube_csv_export_separator`(既定 `,`) + - 出力日付フォーマット: `eccube_csv_export_date_format`(既定 `Y-m-d H:i:s`) + - 複数データ(one-to-many)の区切り: `eccube_csv_export_multidata_separator`(既定 `,`) + - 入力エンコーディング候補: `eccube_csv_import_encoding`、入力区切り/囲み: `eccube_csv_import_delimiter` / `eccube_csv_import_enclosure` +- **エクスポートは必ず `StreamedResponse`**。メモリに全件貯めず、ストリームへ逐次出力する + (件数が膨大になり得るため)。レスポンスは `Content-Type: application/octet-stream` + `Content-Disposition: attachment`。 +- **ストアド項目の追加・拡張はイベントで行う**。コア改変ではなく、`EccubeEvents` の CSV エクスポートイベント + (`ADMIN_*_CSV_EXPORT*`)を購読して `ExportCsvRow` に列を足す(後述)。 +- データアクセス・業務ロジックの分担は Skill `service` / `repository` に従う(検索条件の組み立ては Repository の + `getQueryBuilderBySearchData*()`、値のバインドは `setParameter()`)。 + +## 実装パターン + +### エクスポート(コントローラ側) + +`src/Eccube/Controller/Admin/Order/OrderController.php::exportCsv()` が定石。 +`CsvExportService` を `StreamedResponse` のコールバック内で駆動する: + +```php +protected function exportCsv(Request $request, int $csvTypeId, string $fileName): StreamedResponse +{ + set_time_limit(0); + // 大量出力時は SQL Logger を無効化 + $this->entityManager->getConfiguration()->setSQLLogger(); + + $response = new StreamedResponse(); + $response->setCallback(function () use ($request, $csvTypeId): void { + // 1. CSV 種別で初期化(dtb_csv から有効・sort_no 順の定義を読み込む) + $this->csvExportService->initCsvType($csvTypeId); + + // 2. 検索条件のクエリビルダを取得(Repository 由来) + $qb = $this->csvExportService->getOrderQueryBuilder($request); + + // 3. ヘッダ行(dtb_csv.disp_name) + $this->csvExportService->exportHeader(); + + // 4. データ行(100 件ずつページングし em->clear() しながら出力) + $this->csvExportService->setExportQueryBuilder($qb); + $this->csvExportService->exportData(function ($entity, $csvService): void { + $Csvs = $csvService->getCsvs(); + foreach ($entity->getOrderItems() as $OrderItem) { + $ExportCsvRow = new ExportCsvRow(); + foreach ($Csvs as $Csv) { + // getData() が「定義エンティティと一致するか」を判定して値を返す + $ExportCsvRow->setData($csvService->getData($Csv, $entity)); + if ($ExportCsvRow->isDataNull()) { + $ExportCsvRow->setData($csvService->getData($Csv, $OrderItem)); + } + // ...(必要なら Shipping 等もフォールバック探索) + $ExportCsvRow->pushData(); + } + $csvService->fputcsv($ExportCsvRow->getRow()); + } + }); + }); + + $response->headers->set('Content-Type', 'application/octet-stream'); + $response->headers->set('Content-Disposition', 'attachment; filename='.$fileName); + + return $response; +} +``` + +ポイント: +- `initCsvType()` は `dtb_csv` を `enabled = true` かつ `sort_no ASC` で読む(`CsvExportService::initCsvType()`)。 +- `getData(Csv $Csv, AbstractEntity $entity)` が値の取り出しを一手に担う: + - `Csv::getEntityName()` と実エンティティのクラスが一致しなければ `null`(複数エンティティを順に当てて探す前提)。 + - one-to-one は `reference_field_name` の値、one-to-many は `eccube_csv_export_multidata_separator` で連結、 + `\DateTime` は `eccube_csv_export_date_format`、bool は `'1'`/`'0'` に変換。 +- `fputcsv()` は `getConvertEncodingCallback()` を通して **UTF-8 → 出力エンコーディング**へ変換してから書き出す。 + +### インポート(コントローラ側) + +`AbstractCsvImportController`(`src/Eccube/Controller/Admin/AbstractCsvImportController.php`)を継承し、 +`getImportData()` で `CsvImportService` を得る。これが定石(商品/会員/受注インポート各コントローラが踏襲): + +```php +$data = $this->getImportData($formFile); // CsvImportService|false +if ($data === false) { + $this->addErrors(trans('admin.common.csv_invalid_format')); + return $this->renderWithError($form, $headers, false); +} + +// 必須ヘッダの充足チェック +$columnHeaders = $data->getColumnHeaders(); +if (count(array_diff($requireHeader, $columnHeaders)) > 0) { /* エラー */ } +if (count($data) < 1) { /* データ無しエラー */ } + +$this->entityManager->getConnection()->beginTransaction(); +try { + foreach ($data as $row) { // $row はヘッダ名 => 値 の連想配列 + $line = $data->key() + 1; // 行番号 + if ($headerSize != count($row)) { /* 列数不一致エラー */ } + // ... エンティティへマッピングし persist + } + if ($this->hasErrors()) { // 途中で addErrors されていたら + $this->entityManager->getConnection()->rollback(); + } else { + $this->entityManager->flush(); + $this->entityManager->getConnection()->commit(); + } +} finally { + $this->removeUploadedFile(); // 一時ファイルを必ず削除 +} +``` + +ポイント(`AbstractCsvImportController` 由来): +- `getImportData()` がアップロードファイルを `eccube_csv_temp_realdir` に退避し、 + `eccube_csv_import_delimiter` / `eccube_csv_import_enclosure` を使って `CsvImportService` を生成、`setHeaderRowNumber(0)` する。 +- `CsvImportService` は **先頭行を見て UTF-8 でなければ SJIS-win → UTF-8 の stream filter を自動適用**する + (`SjisToUtf8EncodingFilter` / `ConvertLineFeedFilter`)。エンコーディング判定を自前で書かない。 +- `count($data)` で行数、`$data->key()` で現在行番号、`foreach` で 1 行ずつ取得(メモリに全展開しない)。 +- 取り込みは**トランザクションで囲み、エラー時は rollback**。終了時に一時ファイルを削除する。 + +### CSV 項目を増やす(プラグイン / カスタマイズ) + +- **管理画面で増やせる出力項目**は `dtb_csv` のレコード追加(CSV 設定画面)で完結する。コード変更は不要。 +- **コードで列を足したい**場合は、コア改変ではなく **CSV エクスポートイベントを購読**する。 + `EccubeEvents` に種別ごとのイベントがある: + - `ADMIN_ORDER_CSV_EXPORT_ORDER` / `ADMIN_ORDER_CSV_EXPORT_SHIPPING` + - `ADMIN_PRODUCT_CSV_EXPORT` / `ADMIN_CUSTOMER_CSV_EXPORT` + - `ADMIN_PRODUCT_CATEGORY_CSV_EXPORT` / `ADMIN_PRODUCT_CLASS_NAME_CSV_EXPORT` / `ADMIN_PRODUCT_CLASS_CATEGORY_CSV_EXPORT` + 購読側で `EventArgs` から `ExportCsvRow` を受け取り、`setData()` / `pushData()` で列を追加する + (`OrderController::exportCsv()` のイベント dispatch 箇所を参照)。イベント実装の作法は Skill `event-subscriber`。 +- 新しいエンティティに紐づくマスタ種別を足すなら `mtb_csv_type` への INSERT(**STI なので `discriminator_type` 必須**、Skill `migration`)。 + +## よくある間違い + +- ❌ コントローラ/サービスで `fgetcsv` / `fputcsv` を直書きする → ✅ `CsvImportService` / `CsvExportService::fputcsv()` に乗る +- ❌ 文字コード・区切り文字をハードコードする(`'SJIS-win'`, `','` 直書き) → ✅ `EccubeConfig`(`eccube_csv_export_*` / `eccube_csv_import_*`)の設定値を使う +- ❌ エクスポートで全件を配列に貯めて一括出力する → ✅ `StreamedResponse` + `exportData()` のページング(100 件ずつ `em->clear()`)で逐次出力 +- ❌ 出力項目をコントローラに `if` で羅列する → ✅ `dtb_csv` 定義(`field_name` / `sort_no` / `enabled`)で表現し `getData()` に引かせる +- ❌ インポートで自前エンコーディング判定や全行読み込みをする → ✅ `CsvImportService`(stream filter 自動適用・Iterator)に任せ 1 行ずつ処理 +- ❌ インポートをトランザクション無しで `flush()`/エラー時も一時ファイルを残す → ✅ `beginTransaction`〜`commit`/`rollback` で囲み、終了時に `removeUploadedFile()` +- ❌ 出力項目追加のためにコアの export 処理を改変する → ✅ `ADMIN_*_CSV_EXPORT*` イベントを購読して `ExportCsvRow` に列追加 +- ❌ `mtb_csv_type` へ `discriminator_type` を指定せず INSERT する(STI のため壊れる) → ✅ 種別追加時は discriminator を必ず指定(Skill `migration`) +- ❌ 素の `fputcsv($fp, $row)` で escape 引数を省略(**PHP 8.4 で deprecation**:`the $escape parameter must be provided`)→ ✅ 第5引数まで明示(コアは `fputcsv(..., ',', '"', '\\')`)。そもそも `CsvExportService::fputcsv()` に乗れば吸収される + +## 実行・確認方法 + +- 実装後の整形・型・静的解析・テストは **AGENTS.md「開発コマンド」** に従って実行する + (PHP-CS-Fixer / PHPStan level 6 / PHPUnit)。 +- 関連レイヤの規約も参照: Skill `service`(サービス責務)/ `repository`(クエリビルダ)/ + `event-subscriber`(エクスポートイベント購読)/ `migration`(`mtb_csv_type` 追加)。 +- 実装・改修後は Skill `review-responsibility` で責務分離・セキュリティを点検すること。 diff --git a/.claude/skills/customize/SKILL.md b/.claude/skills/customize/SKILL.md new file mode 100644 index 00000000000..793c9f9a8a2 --- /dev/null +++ b/.claude/skills/customize/SKILL.md @@ -0,0 +1,196 @@ +--- +name: customize +description: EC-CUBE 4.4 の app/Customize によるカスタマイズ(コアのエンティティ/フォーム/サービス/テンプレートをアップグレード安全に拡張・上書き)の規約。「app/Customizeでカスタマイズして」「コアのエンティティにフィールドを足して」「既存フォームに項目を追加して」「コアのサービスを上書き/デコレートして」「テンプレートを上書きして」などと言われたとき、または app/Customize 配下を作成・編集するときに使用する。 +--- + +# app/Customize 規約(EC-CUBE 4.4) + +**対象**: `app/Customize/**`, `app/template/**`(テンプレート上書き) +**前提**: Symfony 7.4 / PHP 8.2+ / Doctrine ORM 3.x + +## 対象 / 前提 + +`app/Customize/` は **プロジェクト固有の改変** を置く場所。コア(`src/Eccube/`)を直接書き換えず、 +ここで拡張・上書きすることで、コアを直接書き換えずに改変するのが目的。 + +> **注意: 「アップグレード安全」は限定的(過信しない)** +> - 影響を受けにくいのは **パッチバージョン**の更新まで。マイナー/メジャー更新ではコア側の変更で破綻し得る。 +> - **コアエンティティそのものは上書き(置換)できない**。拡張は **trait+`#[EntityExtension]` による「追加」のみ**(既存カラム/メソッドの差し替えは不可)。 +> - サービス/テンプレートを `app/Customize` で **override すると、コアに当たった脆弱性パッチ・修正が自動では反映されず、個別に再適用が必要**になる。override は最小限にし、慎重に使う。 + +- PSR-4 で **`Customize\` = `app/Customize/`**(`composer.json` の `autoload.psr-4`)。 +- `app/config/eccube/services.yaml` で `Customize\` 名前空間は **autowire / autoconfigure 済み**として登録される + (`_defaults` が `autowire: true` / `autoconfigure: true`)。`Customize\Controller\` は `controller.service_arguments` タグ付き。 +- 除外: `Customize\` のサービス登録は `{Entity,Resource,Tests}` を除外する(Entity はサービスではないため)。 + +### plugin との使い分け(混同しない) + +| | `app/Customize/`(本 Skill) | `app/Plugin/{Code}/`(Skill `plugin`) | +|---|---|---| +| 名前空間 | **`Customize\`** | `Plugin\{Code}\`(独立名前空間) | +| 想定 | **プロジェクト固有・1 回限りの改変** | **着脱・再配布できる機能パッケージ** | +| ライフサイクル | なし(常時有効) | install/enable/disable/uninstall あり | +| メタデータ | なし | `composer.json` の `extra.code` 必須 | + +> プロジェクト固有の改変は `app/Customize/`、着脱・再配布するものは `app/Plugin/`。 +> エンティティ拡張・フォーム拡張・proxy 再生成といった**作法そのものは両者で共通**(trait+`#[EntityExtension]` 等)。 + +## 基本ルール + +- PHP ファイル先頭に EC-CUBE ライセンスヘッダ。型宣言を付ける(PHPStan level 6)。 +- 依存はコンストラクタインジェクション(autowire が効く)。 +- 各レイヤの作法は対応 Skill に従う(`entity` / `formtype` / `controller` / `service` / `repository`)。 + 本 Skill は **「Customize ならではの置き場所と上書き方法」** に絞る。 + +## 実装パターン + +### 1. エンティティ拡張(コアエンティティにフィールド追加) + +コアを書き換えず、`app/Customize/Entity/` に **trait** を置き、`#[EntityExtension(対象::class)]` を付ける。 +`Eccube\Attribute\EntityExtension` は `TARGET_CLASS | IS_REPEATABLE` の属性で、`value`(対象エンティティの FQCN)を取る。 + +```php +// app/Customize/Entity/ProductTrait.php +namespace Customize\Entity; + +use Doctrine\ORM\Mapping as ORM; +use Eccube\Attribute\EntityExtension; + +#[EntityExtension(\Eccube\Entity\Product::class)] +trait ProductTrait +{ + #[ORM\Column(name: 'custom_note', type: 'string', length: 255, nullable: true)] + private ?string $customNote = null; + + public function getCustomNote(): ?string + { + return $this->customNote; + } + + public function setCustomNote(?string $customNote): self + { + $this->customNote = $customNote; + + return $this; + } +} +``` + +- trait を足したら **proxy 再生成**(`bin/console eccube:generate:proxies`)で `app/proxy/entity/` に反映される。 + `#[EntityExtension]` を付け忘れると proxy に乗らずカラムが認識されない。 +- カラムを足すだけならマイグレーション不要(属性が源泉。`schema:update --force` が反映)。詳細は Skill `entity` / `migration`。 + +### 2. フォーム拡張(既存フォームに項目追加) + +`Symfony\Component\Form\AbstractTypeExtension` を継承し、`getExtendedTypes()` で対象 FormType を返す。 +`app/Customize/Form/Extension/` に置く(autoconfigure で `form.type_extension` として自動登録される)。 + +```php +// app/Customize/Form/Extension/ProductTypeExtension.php +namespace Customize\Form\Extension; + +use Eccube\Form\Type\Admin\ProductType; +use Symfony\Component\Form\AbstractTypeExtension; +use Symfony\Component\Form\Extension\Core\Type\TextType; +use Symfony\Component\Form\FormBuilderInterface; + +class ProductTypeExtension extends AbstractTypeExtension +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->add('custom_note', TextType::class, [ + 'required' => false, + 'mapped' => true, // エンティティ拡張のプロパティに紐づける場合 + ]); + } + + public static function getExtendedTypes(): iterable + { + return [ProductType::class]; + } +} +``` + +- 実例(コア側の AbstractTypeExtension): `src/Eccube/Form/Extension/HelpTypeExtension.php`(`getExtendedTypes()` で `FormType::class` を返し全フィールドを拡張)。 +- 追加項目を画面に出すには、対応するテンプレート側にも出力を足す(下記 4)。詳細は Skill `formtype`。 + +### 3. サービスの上書き / デコレーション + +EC-CUBE は `#[AsDecorator]` 属性は使っていない(コアに用例なし)。 +**`app/config/eccube/services.yaml` に明示的な定義を足し、Symfony のデコレーション(`decorates`)で包む**のが基本。 + +```yaml +# app/config/eccube/services.yaml に追記 +services: + Customize\Service\MyCartServiceDecorator: + decorates: Eccube\Service\CartService + # 元サービスは .inner で受け取る(コンストラクタ DI) + arguments: + $inner: '@.inner' +``` + +```php +// app/Customize/Service/MyCartServiceDecorator.php +namespace Customize\Service; + +use Eccube\Service\CartService; + +class MyCartServiceDecorator +{ + public function __construct(private CartService $inner) + { + } + + // 必要なメソッドだけ振る舞いを変え、それ以外は $this->inner に委譲する +} +``` + +- `decorates` / `decoration_priority` / `decoration_inner_name` 等のサービスキーが使える + (`app/config/eccube/reference.php` の DefaultsType/InstanceofType に定義あり)。 +- 単純に同名サービス ID で**置き換えたい**場合は、`services.yaml` で同じクラス ID に `class:` を上書き定義する手もあるが、 + 元の振る舞いを残したい拡張は **デコレーション**が安全。元クラスへ依存している箇所を壊さないよう、型は元サービスを満たすこと。 +- ロジックの責務分離は Skill `service`。コントローラを追加する場合は `app/Customize/Controller/` に置く + (`routes.yaml` の `customize_controllers` が `type: attribute` で `#[Route]` を走査、services.yaml で `controller.service_arguments` タグ付き)。 + +### 4. テンプレート上書き + +`app/template/` の **コアと同じ相対パス**に同名ファイルを置くと上書きできる(`app/config/eccube/packages/twig.yaml` の `paths`)。 +Twig の検索パスは **`app/template/...`(`eccube_theme_front_dir` / `eccube_theme_admin_dir`)がコア既定(`*_default_dir`)より先**に登録されているため、同名なら app 側が優先される。 + +| 対象 | 置き場所(上書き先) | コア原本 | +|---|---|---| +| 店頭(フロント) | `app/template/{テーマコード}/...`(`eccube.theme` = `ECCUBE_TEMPLATE_CODE`、既定 `default`) | `src/Eccube/Resource/template/default/...` | +| 管理画面 | `app/template/admin/...`(Twig 名前空間 `admin`) | `src/Eccube/Resource/template/admin/...` | + +- 例: フロントの `Product/detail.twig` を変えるなら `app/template/{テーマコード}/Product/detail.twig` にコピーして編集。 +- 一部だけ差し込みたい場合は、コア改変や全文コピーより **テンプレートイベント**で挿入する方が壊れにくい(Skill `event-subscriber` / `twig-template`)。 +- XSS・`raw` の扱いは Skill `twig-template`。 + +## よくある間違い + +- ❌ コア(`src/Eccube/`)を直接書き換える → ✅ `app/Customize/` で拡張・上書きし、アップグレード安全にする +- ❌ プロジェクト固有の 1 回限りの改変をプラグイン化 → ✅ それは `app/Customize/`。着脱・再配布するものだけ `app/Plugin/` +- ❌ 名前空間を `Plugin\{Code}\` と混同 → ✅ Customize は **`Customize\` = `app/Customize/`** +- ❌ エンティティ拡張の trait に `#[EntityExtension(対象::class)]` を付け忘れ → ✅ 付けないと proxy に乗らずカラムが認識されない +- ❌ trait 追加後に proxy 再生成を忘れる → ✅ `bin/console eccube:generate:proxies` +- ❌ カラム追加に ALTER マイグレーションを書く → ✅ 属性が源泉。`schema:update --force` が反映(マイグレーションは INSERT・型変更等に限る。Skill `migration`) +- ❌ 既存フォームを直接改変 → ✅ `AbstractTypeExtension` + `getExtendedTypes()`(`app/Customize/Form/Extension/`)で拡張 +- ❌ サービスを `#[AsDecorator]` で包む(コアの作法と不一致) → ✅ `services.yaml` で `decorates` + `@.inner` 委譲 +- ❌ テンプレートを上書こうとしてコア原本側を編集 → ✅ `app/template/` に同じ相対パスで同名ファイルを置く(app 側が優先) +- ❌ 上書きパスのテーマ名を間違える → ✅ フロントは `app/template/{ECCUBE_TEMPLATE_CODE}/`(既定 `default`)、管理画面は `app/template/admin/` + +## 実行・確認方法 + +コンソール・QA ツール(PHPUnit / PHPStan / PHP-CS-Fixer)の実行方法は AGENTS.md「開発コマンド」を参照。 + +```bash +bin/console eccube:generate:proxies # エンティティ拡張(trait)を足したら proxy 再生成 +bin/console doctrine:schema:update --dump-sql # 追加カラムの差分プレビュー +bin/console doctrine:schema:update --force # 属性差分を反映(単純なカラム追加) +bin/console cache:clear # services.yaml / テンプレート上書きの反映確認 +bin/console doctrine:schema:validate # スキーマ整合確認 +``` + +- サービス上書きの反映は `bin/console debug:container ` / `debug:autowiring` で確認できる。 +- 追加・改修後は各レイヤ Skill(`entity` / `formtype` / `service` / `controller` / `twig-template`)と + `review-responsibility` で責務分離・セキュリティを点検する。 diff --git a/.claude/skills/entity/SKILL.md b/.claude/skills/entity/SKILL.md index c6cdbb12d39..8f987edb744 100644 --- a/.claude/skills/entity/SKILL.md +++ b/.claude/skills/entity/SKILL.md @@ -77,6 +77,10 @@ if (!class_exists(Example::class)) { - 例: `OrderItem::getTotalPrice()`(単価 × 数量)、`Order` の各種金額の合算、 `Customer` の表示名の組み立て、ステータス定数の判定メソッドなど。 - 副作用を持たず、外部(Repository・EntityManager・他サービス)に依存しない純粋な計算/判定はエンティティの責務。 + - **金額プロパティ(`Types::DECIMAL`)は Doctrine ORM 3.x で `?string`**(getter は `string` 戻り、setter も `string` 引数)。 + `Order` の `total` / `subtotal` / `payment_total` 等(`src/Eccube/Entity/Order.php`)が実例。 + 計算は **float で四則演算せず `bcmath`**(`bcadd` / `bcmul` / `bccomp` 等、スケール 2)で行う。 + 実例: `OrderItem::getTotalPrice()` = `bcmul($this->getPriceIncTax(), $this->getQuantity(), 2)`(`src/Eccube/Entity/OrderItem.php`)。 - **外に出す(副作用・横断・採番を伴う「処理」)**: 永続化や複数エンティティ・外部リソースを巻き込む処理。 - **在庫引当・注文番号の採番・ポイント付与・値引き適用などの受注処理は PurchaseFlow(Skill `service` 参照)パイプラインへ**。 - DB アクセス(クエリ)は Repository、トランザクションを伴う業務操作は Service へ(Skill `service`)。 @@ -101,3 +105,7 @@ if (!class_exists(Example::class)) { - ❌ 在庫引当・採番・ポイント付与などの受注処理をエンティティに書く → ✅ PurchaseFlow / Service へ。エンティティは自身の状態から導く計算/判定まで - ❌ `class_exists` ラッパなしでコアエンティティを定義 → ✅ プロキシ拡張に対応するラッパで囲う - ❌ プロパティ/戻り値の型宣言省略 → ✅ 型を付け、PHPStan level 6 を通す +- ❌ 金額 getter(`Order::getTotal()`・`OrderItem::getTotalPrice()` 等)の戻り値を int/float 扱い → ✅ DECIMAL は `?string`(getter は `string`)。型宣言・代入もこれに合わせる +- ❌ 金額を float で四則演算(丸め誤差)→ ✅ `bcmath`(`bcadd` / `bcmul` / `bccomp`、スケール 2)で計算する +- ❌ `create_date` / `update_date` を自前の `#[ORM\PrePersist]`(+`#[ORM\HasLifecycleCallbacks]`)でセット → ✅ コアの `SaveEventSubscriber`(グローバル Doctrine prePersist/preUpdate)が `method_exists` で `setCreateDate`/`setUpdateDate`/`setCreator` を自動セットする(`src/Eccube/Doctrine/EventSubscriber/SaveEventSubscriber.php`)。setter さえ生やせばよく、自前 PrePersist は二重実装になるので書かない +- ❌ 他エンティティ(特にコアの `Product`/`Customer` 等、自分で制御できない親)への関連で親削除時の挙動を未決定 → ✅ FK は既定で削除を止める(RESTRICT 相当)。未指定だと**退会・商品削除が FK 違反で失敗**したり孤児化する。`onDelete`(`SET NULL`/`CASCADE`)を指定するか、Service・プラグイン disable 等で後始末する(コアは `onDelete` を限定使用し[95 JoinColumn 中 2 件]、多くは Service 側で関連を整理している) diff --git a/.claude/skills/event-subscriber/SKILL.md b/.claude/skills/event-subscriber/SKILL.md new file mode 100644 index 00000000000..0ad7bb0f073 --- /dev/null +++ b/.claude/skills/event-subscriber/SKILL.md @@ -0,0 +1,126 @@ +--- +name: event-subscriber +description: EC-CUBE 4.4 のイベント(EventSubscriber/EventListener・EC-CUBE独自イベント・テンプレートイベント・Doctrineイベント)を実装・改修するときの規約。「イベントサブスクライバを作って」「リスナーを追加して」「このイベントを購読して」「処理にフックして」「テンプレートに差し込んで」「ログイン時に処理を足して」などと言われたとき、または src/Eccube/Event・src/Eccube/EventListener・app/Customize/EventListener 配下を作成・編集するときに使用する。 +--- + +# イベント規約(EC-CUBE 4.4) + +**対象**: `src/Eccube/Event/**`, `src/Eccube/EventListener/**`, `src/Eccube/Doctrine/EventSubscriber/**`, +`app/Customize/EventListener/**`, プラグインの `EventListener/**` +**前提**: Symfony 7.4(EventDispatcher)/ PHP 8.2+ + +> 目的: EC-CUBE の拡張は「コア改変ではなくイベント購読」が基本。 +> Symfony 標準の `EventSubscriberInterface` に EC-CUBE 独自イベント・テンプレートイベント・Doctrine イベントが乗る構造を正しく使う。 + +## イベントの4分類(まず種類を見分ける) + +| 種類 | ペイロード | 購読キー | 用途 | +|---|---|---|---| +| **Symfony Kernel イベント** | `RequestEvent` / `ResponseEvent` 等 | `KernelEvents::REQUEST` 等 | リクエスト/レスポンスのライフサイクル | +| **EC-CUBE 独自イベント** | `EventArgs` | `EccubeEvents::XXX` 定数 | コントローラ処理の前後にフック | +| **テンプレートイベント** | `TemplateEvent` | **テンプレートのファイル名** | 画面への差し込み(Skill `twig-template`) | +| **Doctrine イベント** | `LifecycleEventArgs` 等 | `#[AsDoctrineListener(event: ...)]` | エンティティの永続化前後 | + +## 基本ルール + +- サブスクライバは `Symfony\Component\EventDispatcher\EventSubscriberInterface` を実装する。 +- **`getSubscribedEvents()` は `static` メソッド**で、`[イベント名 => メソッド名]` を返す(static にしないと登録されない)。 +- **サービス登録は不要**。`services.yaml` の `autoconfigure: true` で `Eccube\` / `Customize\` / `Plugin\` 配下は + 自動的に `kernel.event_subscriber` タグが付く。**手動で services.yaml に登録すると二重登録になる**。 +- EC-CUBE 独自イベントの名前は **`EccubeEvents` クラスの定数**を使う(文字列直書きはタイポの温床)。 + 命名は `CONTEXT_CONTROLLER_ACTION_PHASE`(例: `FRONT_PRODUCT_INDEX_INITIALIZE`, `ADMIN_ORDER_EDIT_COMPLETE`)。 +- **優先度(priority)は数値が大きいほど先に実行**される。 +- Doctrine の作成日時/更新者の自動設定などは `#[AsDoctrineListener]` を使う(`SaveEventSubscriber` が手本)。 +- **イベントリスナーに業務ロジックを集中させない**。重い処理は Service に委譲し、リスナーは「フック点で Service を呼ぶ」薄い層に保つ(Skill `service`)。 + +## 実装パターン + +### EC-CUBE 独自イベントの購読(最も多い拡張) +コントローラが `new EventArgs([...], $request)` を dispatch する。リスナーは `getArgument()`/`setArgument()` で値を読み書きする。 + +```php +class ProductListExtendListener implements EventSubscriberInterface +{ + public static function getSubscribedEvents(): array + { + return [ + EccubeEvents::FRONT_PRODUCT_INDEX_SEARCH => 'onSearch', + ]; + } + + public function onSearch(EventArgs $event): void + { + $qb = $event->getArgument('qb'); // コントローラが渡した QueryBuilder + // ... 検索条件を足す ... + $event->setArgument('qb', $qb); // 変更を書き戻す + } +} +``` + +- EventArgs の **第1引数は arguments 配列**(後で `getArgument('key')`)、**第2引数は `Request`**。`new EventArgs(['key' => $v], $request)`。 +- レスポンスを差し替えたいときは `$event->setResponse(...)`。コントローラ側は `if ($event->hasResponse()) return $event->getResponse();` で受ける。 + +### Kernel イベント(複数メソッド・優先度指定) +```php +public static function getSubscribedEvents(): array +{ + return [ + KernelEvents::REQUEST => [ + ['onKernelRequestEarly', 500], // 大きい数値 = 先に実行 + ['onKernelRequest', 6], + ], + KernelEvents::EXCEPTION => ['onKernelException', -4], + ]; +} + +public function onKernelRequest(RequestEvent $event): void +{ + if (!$event->isMainRequest()) { // サブリクエストを除外するのが定石 + return; + } + // ... +} +``` + +### Doctrine イベント +```php +#[AsDoctrineListener(event: Events::prePersist)] +#[AsDoctrineListener(event: Events::preUpdate)] +class ExampleDoctrineListener +{ + public function prePersist(LifecycleEventArgs $args): void + { + $entity = $args->getObject(); + // method_exists でトレイト拡張の有無を見てから触るのがコアの作法 + } +} +``` + +### テンプレートイベント +ファイル名がイベント名。`addSnippet()` / `addAsset()` / `setSource()` で差し込む。詳細は Skill `twig-template`。 + +## よくある間違い + +- ❌ `getSubscribedEvents()` を非 static で定義 → ✅ `public static function` にする(さもないと登録されない) +- ❌ イベント名を文字列直書き(`'front.product.index.initialize'`)→ ✅ `EccubeEvents::FRONT_PRODUCT_INDEX_INITIALIZE` 定数 +- ❌ autoconfigure 済みなのに services.yaml で手動登録 → ✅ 登録しない(二重発火を防ぐ) +- ❌ EventArgs の第1引数に値を直接渡す → ✅ `['key' => $value]` の連想配列で渡し `getArgument('key')` で取る +- ❌ 優先度を「小さいほど先」と誤解 → ✅ **大きい数値が先** +- ❌ Kernel イベントでサブリクエストを除外し忘れる → ✅ `if (!$event->isMainRequest()) return;` +- ❌ テンプレートイベント/Doctrine イベントに業務ロジックを書き込む → ✅ Service へ委譲し、リスナーは薄く保つ + +## 実行・確認方法 + +QA ツール・コンソール(PHPUnit / PHPStan / PHP-CS-Fixer)の実行方法は AGENTS.md「開発コマンド」を参照。 + +```bash +bin/console debug:event-dispatcher # 登録済みリスナー一覧 +bin/console debug:event-dispatcher 'front.product.index.initialize' # 特定イベントの購読状況・優先度 +``` + +- 自作リスナーが効かないときは、まず `debug:event-dispatcher` に出ているか(=登録されているか)を確認する。 +- 出ていなければ `getSubscribedEvents()` が static か、クラスが autoconfigure 対象パスにあるかを疑う。 + +--- + +実装・改修後は、Skill `review-responsibility` でリスナーに業務ロジックが偏っていないか点検すること。 diff --git a/.claude/skills/mail/SKILL.md b/.claude/skills/mail/SKILL.md new file mode 100644 index 00000000000..bc8c5637d4d --- /dev/null +++ b/.claude/skills/mail/SKILL.md @@ -0,0 +1,183 @@ +--- +name: mail +description: EC-CUBE 4.4 のメール送信(MailService・メールテンプレート・MailHistory)を実装・改修するときの規約。「メールを送って」「メール送信処理を追加して」「メールテンプレートを足して」「注文確定メールをカスタマイズして」「送信履歴を残して」などと言われたとき、または src/Eccube/Service/MailService・メール用 twig を作成・編集するときに使用する。 +--- + +# メール送信規約(EC-CUBE 4.4) + +**対象**: `src/Eccube/Service/MailService.php`, `app/Customize/Service/**`, メール用 twig(`Resource/template/**/Mail/`, `app/template/**/Mail/`) +**前提**: Symfony 7.4 / PHP 8.2+ / Symfony Mailer(`symfony/mailer`)/ Twig 3.x + +> 目的: メール送信は「件名・本文の組み立て(Twig)」「送信(Mailer)」「送信履歴(MailHistory)」「差出人設定(BaseInfo)」が +> 一体で動く。これを Service に集約し、コントローラやテンプレートに送信ロジックを散らさないこと。 +> Skill `service`(業務ロジックの置き場所)と対で使う。 + +## 対象 / 前提(構成要素) + +| 要素 | 実体 | 役割 | +|---|---|---| +| `MailService` | `src/Eccube/Service/MailService.php` | メール送信の入口。`send〜Mail()` の各公開メソッドを持つ | +| `MailTemplate` | Entity `dtb_mail_template`(STI, `discriminator_type`) | 件名(`mail_subject`)と Twig ファイル名(`file_name`)を保持 | +| `MailHistory` | Entity `dtb_mail_history`(STI, `discriminator_type`) | 送信済みメールの件名・本文・HTML 本文・送信日時・`Order` を記録 | +| メール用 twig | `Resource/template//Mail/*.twig` | 本文テンプレート(プレーンテキスト)。`*.html.twig` があれば HTML メールにもなる | +| `BaseInfo` | Entity `dtb_base_info` | 差出人・返信先・ReturnPath(`email01`〜`email04`)と店名を保持 | +| トランスポート | `MailerInterface`(DSN は `%env(MAILER_DSN)%`) | 実際の送信。`app/config/eccube/packages/mailer.yaml` | + +## 基本ルール + +- **メール送信は必ず `MailService` 経由**。コントローラから `MailerInterface` を直接叩いて `Email` を組み立てない。 + 既存の送信は `MailService::sendOrderMail()` / `sendShippingNotifyMail()` / `sendCustomerCompleteMail()` 等の公開メソッドに集約されている。 +- **件名・本文は `MailTemplate` + Twig から組み立てる**。テンプレート ID は `EccubeConfig`(`app/config/eccube/packages/eccube.yaml`)で固定されている。 + - 例: `eccube_order_mail_template_id: 1`(注文受付), `eccube_shipping_notify_mail_template_id: 8`(出荷通知)等。 + - `MailService` は `$this->mailTemplateRepository->find($this->eccubeConfig['eccube_order_mail_template_id'])` で取得し、`$MailTemplate->getFileName()` を Twig に渡してレンダリングする。 +- **差出人・返信先は `BaseInfo` を使う**(ハードコード禁止)。役割は固定: + - `email01` … **From / Bcc**(送信元メールアドレス。多くの送信で自身を Bcc にも入れる) + - `email02` … お問い合わせ系の From / Bcc / ReplyTo(`sendContactMail` で使用) + - `email03` … **ReplyTo**(返信先) + - `email04` … **ReturnPath**(送信エラー通知先) +- **宛先は `convertRFCViolatingEmail()` を通す**。RFC 違反アドレス(`eccube_rfc_email_check=false` のとき)の local part をクォートして `Address` を返す。生の文字列を `->to()` に渡さない。 +- **送信失敗はログに記録して握りつぶす**。送信は `try { $this->mailer->send($message); } catch (TransportExceptionInterface $e) { log_critical($e->getMessage()); }` の形。送信失敗で受注処理全体を止めない設計(受注メール等)。 +- **送信履歴(`MailHistory`)は受注に紐づくメールだけ記録する**。現状 `MailHistory` を作るのは `sendOrderMail` と `sendShippingNotifyMail` の 2 つ。会員系メールは履歴を残していない(`MailHistory` の関連は `Order` のみで会員に紐づける口がない)。 +- **`MailHistory` は persist のみ、`flush()` は呼び出し側**。`MailService` は `$this->mailHistoryRepository->save($MailHistory)`(= `EntityManager::persist`)までで、`flush()` しない。呼び出し側(例: `ShoppingController` は `sendOrderMail` の直後に `$this->entityManager->flush()`)で確定させる。履歴を保存する新メソッドを足すときも flush は呼び出し側に委ねる。 +- **テンプレートのカスタマイズはコア改変ではなく上書き**。`app/template/<コード>/Mail/order.twig` に置けばコアの `Resource/template/default/Mail/order.twig` を上書きできる(管理画面のメール設定でテンプレート本文を編集する運用もある)。 +- **送信前に必ず `EccubeEvents::MAIL_*` を dispatch する**。プラグイン/カスタマイズが件名・本文・宛先を差し替えられるよう、`EventArgs` に `message` 等を載せて発火してから送る(後述)。 + +## 実装パターン + +### 既存の送信メソッドの型(`MailService` 内) + +`sendCustomerCompleteMail` 等はすべて同じ骨格。新しいメールを足すときもこの形に揃える。 + +```php +public function sendOrderMail(Order $Order): Email +{ + log_info('受注メール送信開始'); + + // 1) テンプレート取得(ID は EccubeConfig で固定) + $MailTemplate = $this->mailTemplateRepository->find( + $this->eccubeConfig['eccube_order_mail_template_id'] + ); + + // 2) 本文を Twig でレンダリング(テンプレートのファイル名を使う) + $body = $this->twig->render($MailTemplate->getFileName(), [ + 'Order' => $Order, + ]); + + // 3) Email を組み立て。差出人は BaseInfo、宛先は convertRFCViolatingEmail を通す + $message = (new Email()) + ->subject('['.$this->BaseInfo->getShopName().'] '.$MailTemplate->getMailSubject()) + ->from(new Address($this->BaseInfo->getEmail01(), $this->BaseInfo->getShopName())) + ->to($this->convertRFCViolatingEmail($Order->getEmail())) + ->bcc($this->BaseInfo->getEmail01()) + ->replyTo($this->BaseInfo->getEmail03()) + ->returnPath($this->BaseInfo->getEmail04()); + + // 4) HTML テンプレート(*.html.twig)があれば multipart 化 + $htmlFileName = $this->getHtmlTemplate($MailTemplate->getFileName()); + if (!is_null($htmlFileName)) { + $htmlBody = $this->twig->render($htmlFileName, ['Order' => $Order]); + $message->text($body)->html($htmlBody); + } else { + $message->text($body); + } + + // 5) 送信前にイベントを発火(プラグインの差し替え口) + $event = new EventArgs([ + 'message' => $message, + 'Order' => $Order, + 'MailTemplate' => $MailTemplate, + 'BaseInfo' => $this->BaseInfo, + ]); + $this->eventDispatcher->dispatch($event, EccubeEvents::MAIL_ORDER); + + // 6) 送信。失敗はログに記録して握りつぶす + try { + $this->mailer->send($message); + } catch (TransportExceptionInterface $e) { + log_critical($e->getMessage()); + } + + // 7) 受注メールは MailHistory に記録(persist のみ。flush は呼び出し側) + $MailHistory = (new MailHistory()) + ->setMailSubject($message->getSubject()) + ->setMailBody($message->getTextBody()) + ->setOrder($Order) + ->setSendDate(new \DateTime()); + if (!empty($message->getHtmlBody())) { + $MailHistory->setMailHtmlBody($message->getHtmlBody()); + } + $this->mailHistoryRepository->save($MailHistory); + + log_info('受注メール送信完了'); + + return $message; +} +``` + +### HTML メールの規約(`getHtmlTemplate`) + +HTML メールは別テンプレートを **命名規約で発見**する。プレーンテキスト `Mail/order.twig` に対し +`Mail/order.html.twig` が存在すれば自動的に HTML パートを付ける(`getHtmlTemplate()` が +`.html.` を組み立てて `$this->twig->getLoader()->exists()` で判定)。 +HTML メールを足したいときは **同じディレクトリに `*.html.twig` を置くだけ**。コード側の分岐は不要。 + +### メール本文 twig の書き方 + +`Resource/template/default/Mail/*.twig` を踏襲する。 + +```twig +{% autoescape 'safe_textmail' %} +{{ Order.name01 }} {{ Order.name02 }} 様 + +ご注文番号:{{ Order.order_no }} +お支払い合計:{{ Order.payment_total|price }} +{% endautoescape %} +``` + +- **プレーンテキストメールは `{% autoescape 'safe_textmail' %}` で囲む**(`SafeTextmailEscaperExtension`)。通常の HTML エスケープはテキストメールに不要・有害なため専用エスケープ戦略を使う。 +- 渡せる変数は `MailService` がレンダリング時に渡したもの(`Order` / `Customer` / `BaseInfo` / `data` 等)だけ。新しい変数を使うなら送信メソッド側の `$this->twig->render(..., [...])` に追加する。 +- HTML メール(`*.html.twig`)はこの `autoescape` を使わず、通常の HTML として書く。 + +### イベント定数(`EccubeEvents`) + +送信前に dispatch する `MAIL_*` 定数(`src/Eccube/Event/EccubeEvents.php`): +`MAIL_ORDER` / `MAIL_SHIPPING_NOTIFY` / `MAIL_CONTACT` / `MAIL_CUSTOMER_CONFIRM` / +`MAIL_CUSTOMER_COMPLETE` / `MAIL_CUSTOMER_WITHDRAW` / `MAIL_ADMIN_CUSTOMER_CONFIRM` / +`MAIL_ADMIN_ORDER` / `MAIL_PASSWORD_RESET` / `MAIL_PASSWORD_RESET_COMPLETE` / +`MAIL_CUSTOMER_CHANGE_NOTIFY`。 +購読してメールを差し替えるときは Skill `event-subscriber` を参照(`EventArgs` から `message` を取り出して件名・宛先・本文を変更)。 + +### プラグイン / カスタマイズから独自メールを送る + +- **既存メールの差し替え**: 新規送信メソッドを足さず、対応する `MAIL_*` イベントを購読して `EventArgs` の `message` を加工する(件名・宛先・本文・添付の追加など)。これが第一選択。 +- **独自メールの新規送信**: `app/Customize/Service/` または `Plugin\{Code}\Service\` に Service を作り、`MailerInterface` + `Environment`(Twig)+ `BaseInfoRepository` を DI して上記の型に倣う。 + - 本文 twig はプラグインなら `Plugin\{Code}` のテンプレートパス、カスタマイズなら `app/template/<コード>/Mail/` に置く。 + - 差出人は `BaseInfo` を使い、宛先は `convertRFCViolatingEmail()`(または同等の処理)を通す。 + - 受注に紐づくなら送信後に `MailHistory` を作って `Order` に関連付ける。 + +## よくある間違い + +- ❌ コントローラで `MailerInterface` を直接呼んで `Email` を組み立てる → ✅ `MailService` の送信メソッドに集約する +- ❌ 差出人・返信先をハードコードする → ✅ `BaseInfo` の `email01`(From/Bcc)/ `email03`(ReplyTo)/ `email04`(ReturnPath)を使う +- ❌ 宛先に生の文字列を `->to($email)` で渡す → ✅ `convertRFCViolatingEmail($email)` を通す +- ❌ 件名・本文を PHP 内で文字列連結する → ✅ `MailTemplate` + Twig(`render()`)で組み立てる +- ❌ プレーンテキストメール twig を素で書く / 通常の HTML エスケープをかける → ✅ `{% autoescape 'safe_textmail' %}` で囲む +- ❌ HTML メール用に送信メソッドへ分岐を足す → ✅ 同名 `*.html.twig` を置けば `getHtmlTemplate()` が自動で multipart 化する +- ❌ 送信失敗で例外を投げて受注処理を止める → ✅ `TransportExceptionInterface` を catch して `log_critical` で記録(既存の方針に合わせる) +- ❌ `MailHistory` を保存した後 `MailService` 内で `flush()` する → ✅ `save()`(persist)まで。`flush()` は呼び出し側 +- ❌ 会員系メールを `MailHistory` に残そうとする → ✅ `MailHistory` の関連は `Order` のみ。会員に紐づける口は無い(履歴対象は受注メール・出荷通知メール) +- ❌ コアの `Resource/template/default/Mail/*.twig` を直接書き換える → ✅ `app/template/<コード>/Mail/` で上書きする +- ❌ 送信前のイベント dispatch を省く → ✅ プラグインの差し替え口として `EccubeEvents::MAIL_*` を必ず発火する + +## 実行・確認方法 + +QA ツール(PHPUnit / PHPStan / PHP-CS-Fixer / Rector)の実行方法は AGENTS.md「開発コマンド」を参照。 + +- 送信トランスポートは `MAILER_DSN`(`app/config/eccube/packages/mailer.yaml` の `%env(MAILER_DSN)%`)で決まる。デフォルトは `null://null`(送信しない)。 +- `docker-compose.yml` には mailcatcher コンテナが含まれるため、開発環境では `MAILER_DSN` を mailcatcher(SMTP)に向ければ、送信メールをブラウザ UI で確認できる。 +- 送信履歴は管理画面(受注詳細のメール履歴)および `dtb_mail_history` テーブルで確認する。 + +--- + +実装・改修後は、Skill `service`(業務ロジックの責務分離)・`event-subscriber`(差し替え)・ +`twig-template`(テンプレートのエスケープ)と `review-responsibility` で点検すること。 diff --git a/.claude/skills/plugin/SKILL.md b/.claude/skills/plugin/SKILL.md new file mode 100644 index 00000000000..572f92a9dc9 --- /dev/null +++ b/.claude/skills/plugin/SKILL.md @@ -0,0 +1,166 @@ +--- +name: plugin +description: EC-CUBE 4.4 のプラグインを実装・改修するときの規約。「プラグインを作って」「プラグインで機能を追加して」「PluginManagerを書いて」「プラグインでエンティティ/フォーム/コントローラを拡張して」「composer.jsonのメタデータを直して」「プラグインのライフサイクル処理を実装して」などと言われたとき、または app/Plugin 配下を作成・編集するときに使用する。 +--- + +# プラグイン規約(EC-CUBE 4.4) + +**対象**: `app/Plugin/{PluginCode}/**`(コア側の仕組みは `src/Eccube/Plugin/`, `src/Eccube/Service/PluginService.php`) +**前提**: Symfony 7.4 / PHP 8.2+ + +> 目的: 自己完結したパッケージとして機能を追加し、コアやプロジェクト固有カスタマイズ(`app/Customize/`)と混同しないこと。 +> プロジェクト固有の 1 回限りの改変は `app/Customize/`、再配布・着脱可能な機能は `app/Plugin/`。 + +## 雛形の生成(まず CLI で骨組みを作る) + +新規プラグインは**手書きで一から作らず、コアの生成コマンドで雛形を作る**のが定石。 + +```bash +bin/console eccube:plugin:generate +# 例: bin/console eccube:plugin:generate "My Plugin" Example 1.0.0 +``` + +`app/Plugin/{code}/` に骨組み一式が生成される(`src/Eccube/Command/PluginGenerateCommand.php`): +`composer.json` ・ 管理画面の `Controller/Admin/ConfigController.php` ・ `Entity/Config.php`(`plg_{code}_config`)・ +`Repository/ConfigRepository.php` ・ `Form/Type/Admin/ConfigType.php` ・ `Resource/template/admin/config.twig` ・ +`TwigBlock.php` / `Nav.php` / `Event.php` ・ `Resource/locale/messages.ja.yaml` 等 ・ +`.github/workflows/release.yml` ・ `.gitattributes`。 + +- 引数は **`name`(表示名)/ `code`(PluginCode)/ `ver`(composer.json の version)** の順(位置引数)。 +- 生成物は **Config 画面・Entity 込みのフル構成**。**使わないファイルは削ってよい**(残すべき最小は下記)。 +- `code` は `^\w+$`(後述の制約)。`PluginManager.php` は生成されないので、ライフサイクル処理が要るときは下記に従い手で足す。 + +> **開発時の置き場所(事故防止・重要)**: `app/Plugin/{code}/` 直下で直接開発すると、**プラグイン削除(uninstall)のテストをした瞬間にソースごと消える**。 +> 実開発では**別ディレクトリで開発し、シンボリックリンクで配置**するのが安全。コアの local path リポジトリ機能を使う: +> ```bash +> bin/console eccube:composer:require <パッケージ名> --from <別ディレクトリのパス> +> ``` +> `--from` で指定したローカルパスを composer リポジトリとして登録し、`app/Plugin/` へシンボリックリンクで取り込む(`ComposerRequireCommand` の `--from` オプション。参考: PR #5843)。 + +## プラグインの最小構成と配置 + +プラグインは必ず **`app/Plugin/{PluginCode}/`** に置く。PSR-4 で `Plugin\{PluginCode}\` = `app/Plugin/{PluginCode}/`。 +`generate` が作る雛形から不要分を削ると、最終的に残すべきは次の構成(手書きするときもこれが下限)。 + +``` +app/Plugin/{PluginCode}/ + ├── composer.json # 必須 + └── PluginManager.php # 任意(ライフサイクル処理が要るときだけ) +``` + +- **PluginCode は `^\w+$`(英数字とアンダースコアのみ)**。ディレクトリ名・名前空間・クラス名に使われるため厳格。`-` は不可。 +- `composer.json` の必須は **`version` と `extra.code`**。`extra.code` が無いと install で失敗する。 + 推奨: `name`(`ec-cube/xxx`), `description`, `type: "eccube-plugin"`, `require` に `ec-cube/plugin-installer`。 + +```json +{ + "name": "ec-cube/example", + "version": "1.0.0", + "description": "...", + "type": "eccube-plugin", + "require": { "ec-cube/plugin-installer": "*" }, + "extra": { "code": "Example" } +} +``` + +## ライフサイクル(PluginManager) + +ライフサイクル処理が必要なときだけ **`Plugin\{Code}\PluginManager`**(クラス名は固定)を `AbstractPluginManager` を継承して作る。 +5 メソッドはすべて**デフォルト no-op**なので、必要なものだけ override すればよい。 + +| メソッド | 呼ばれる契機 | 用途の例 | +|---|---|---| +| `install` | インストール時(postInstall 経由) | 初期データ投入 | +| `enable` | 有効化時 | マイグレーション適用 | +| `disable` | 無効化時 | マイグレーションを戻す | +| `update` | 更新時 | 差分マイグレーション | +| `uninstall` | アンインストール時(initialized 済みのみ) | クリーンアップ | + +```php +namespace Plugin\Example; + +use Eccube\Plugin\AbstractPluginManager; +use Symfony\Component\DependencyInjection\ContainerInterface; + +class PluginManager extends AbstractPluginManager +{ + public function enable(array $meta, ContainerInterface $container): void + { + // マイグレーション適用(AbstractPluginManager::migration を利用) + $conn = $container->get('doctrine')->getManager()->getConnection(); + $this->migration($conn, $meta['code']); + } +} +``` + +- メソッドのシグネチャは **`(array $meta, ContainerInterface $container)`**。`$meta['code']` は composer.json の `extra.code`。 +- **install 直後はデフォルト無効(enabled=false)**。有効化は `eccube:plugin:enable` コマンドか管理画面で行う(無効化はコンソールコマンドが無く、管理画面から行う)。 + +## 拡張パターン(プラグインから何を足すか) + +| 拡張 | 置き場所 / 名前空間 | 作法 | 参照 Skill | +|---|---|---|---| +| **エンティティ拡張** | `Plugin\{Code}\Entity\*Trait` | トレイトに `#[EntityExtension(\Eccube\Entity\Target::class)]` を付け、`#[ORM\Column]` でカラム追加 | `entity` | +| **コントローラ追加** | `Plugin\{Code}\Controller` | `#[Route]` 属性でルーティング | `controller` | +| **フォーム拡張** | `Plugin\{Code}\Form\Extension` | `AbstractTypeExtension` を継承し `getExtendedTypes()` で対象指定 | `formtype` | +| **リポジトリ拡張** | `Plugin\{Code}\Repository` | — | `repository` | +| **イベント購読** | `Plugin\{Code}\EventListener` 等 | `EventSubscriberInterface`(autoconfigure で自動登録) | `event-subscriber` | +| **受注処理の拡張** | `Plugin\{Code}\Service\PurchaseFlow\Processor` | `#[CartFlow]` / `#[ShoppingFlow]` / `#[OrderFlow]` 属性で対象フローへ自動登録 | `service` | +| **マイグレーション** | `Plugin\{Code}\DoctrineMigrations\Version*` | `AbstractMigration` を継承(テーブルは `migration_{code}` で管理) | `migration` | + +```php +// エンティティ拡張の例: app/Plugin/Example/Entity/CustomerExampleTrait.php +namespace Plugin\Example\Entity; + +use Doctrine\ORM\Mapping as ORM; +use Eccube\Attribute\EntityExtension; + +#[EntityExtension(\Eccube\Entity\Customer::class)] +trait CustomerExampleTrait +{ + #[ORM\Column(name: 'example_no', type: 'smallint', nullable: true)] + public $example_no; +} +``` + +## プロキシ再生成(忘れやすい急所) + +エンティティ拡張(トレイト)を足したら **プロキシの再生成が必要**。 +enable/disable/uninstall 時はコア(`PluginService`)が自動で再生成するが、**開発中に手で確認するときは明示実行する**: + +```bash +bin/console eccube:generate:proxies # app/proxy/entity/ を再生成 +``` + +トレイトに `#[EntityExtension]` を付け忘れるとプロキシに反映されず、カラムが認識されない。 + +## よくある間違い + +- ❌ 雛形を手で一から作る → ✅ `bin/console eccube:plugin:generate ` で骨組みを生成し、不要分を削る +- ❌ `composer.json` に `extra.code` が無い → ✅ 必須。無いと install で失敗 +- ❌ PluginCode に `-` を使う → ✅ `^\w+$`(英数字・アンダースコアのみ) +- ❌ install しただけで動くと思う → ✅ install 直後は無効。`eccube:plugin:enable --code=...` で有効化 +- ❌ エンティティトレイトに `#[EntityExtension(Target::class)]` を付け忘れ → ✅ 付けないとプロキシに乗らない +- ❌ トレイト追加後にプロキシ再生成を忘れる → ✅ `bin/console eccube:generate:proxies` +- ❌ プロジェクト固有の 1 回限りの改変をプラグイン化 → ✅ それは `app/Customize/`。着脱・再配布するものだけプラグイン +- ❌ `app/Customize`(`Eccube\` を直接拡張)と `app/Plugin`(`Plugin\{Code}\` 独立名前空間)の名前空間を混同 → ✅ 置き場所で名前空間を使い分ける + +## 実行・確認方法 + +コンソール・QA ツール(PHPUnit / PHPStan / PHP-CS-Fixer)の実行方法は AGENTS.md「開発コマンド」を参照。 + +```bash +bin/console eccube:plugin:generate "My Plugin" Example 1.0.0 # 雛形生成(name code ver) +bin/console eccube:plugin:install --code=Example # 既存ディレクトリからインストール +bin/console eccube:plugin:enable --code=Example # 有効化 +bin/console eccube:plugin:update Example # 更新(PluginManager::update を呼ぶ) +bin/console eccube:generate:proxies # プロキシ再生成 +bin/console doctrine:schema:validate # スキーマ整合確認 +``` + +- 状態確認は `dtb_plugin` テーブル(`code` / `enabled` / `initialized`)と `app/proxy/entity/` を見る。 + +--- + +実装・改修後は、各レイヤの Skill(`entity` / `controller` / `formtype` / `migration` 等)と +`review-responsibility` で責務分離を点検すること。 diff --git a/.claude/skills/purchase-flow/SKILL.md b/.claude/skills/purchase-flow/SKILL.md new file mode 100644 index 00000000000..a5d9ed73ea4 --- /dev/null +++ b/.claude/skills/purchase-flow/SKILL.md @@ -0,0 +1,252 @@ +--- +name: purchase-flow +description: EC-CUBE 4.4 の受注処理(PurchaseFlow の Processor/Validator)を実装・改修するときの規約。「受注処理を追加して」「値引き/送料/ポイントの計算を入れて」「在庫チェックを追加して」「Processorを作って」「Validatorを作って」「PurchaseFlowにフックして」などと言われたとき、または src/Eccube/Service/PurchaseFlow・プラグインの PurchaseFlow 配下を作成・編集するときに使用する。 +--- + +# PurchaseFlow 規約 — 受注処理パイプライン(EC-CUBE 4.4) + +**対象**: `src/Eccube/Service/PurchaseFlow/**`, `app/Plugin/{Code}/Service/PurchaseFlow/**` +**前提**: Symfony 7.4 / PHP 8.2+ + +> 目的: 受注に関わる計算・検証・確定(送料/手数料/税/値引き/ポイント/在庫引当・採番)は、 +> コントローラや汎用 Service に直書きせず、`PurchaseFlow` のパイプライン上の Processor/Validator に置く。 +> これが EC-CUBE の受注処理の核心。Skill `service` と対で使う。 + +## パイプラインの構造(`PurchaseFlow::validate()` の実行順) + +`PurchaseFlow` は **cart / shopping / order** の 3 フローぶん存在し(`PurchaseContext::CART_FLOW` / +`SHOPPING_FLOW` / `ORDER_FLOW`)、それぞれ別の Processor 群を持つ。`validate()` は次の順で実行する +(各段階の間で金額の再集計 `calculateAll()` が走る): + +| 段階 | コンポーネント(基底) | 役割 | 実コード例 | +|---|---|---|---| +| 明細検証 | `ItemValidator`(abstract) | 明細1行ごとの検証 | `StockValidator` `PriceChangeValidator` | +| 受注検証 | `ItemHolderValidator`(abstract) | カート/受注全体の検証 | `EmptyItemsValidator` `StockMultipleValidator` | +| 明細前処理 | `ItemPreprocessor`(interface) | 明細1行ごとの調整 | (コアでは未使用。拡張ポイント) | +| 受注前処理 | `ItemHolderPreprocessor`(interface) | 送料/税/手数料明細の付与・調整 | `TaxProcessor` `DeliveryFeePreprocessor` | +| 値引き | `DiscountProcessor`(interface) | 値引き明細の削除→追加 | `PointProcessor` | +| 最終検証 | `ItemHolderPostValidator`(abstract) | 全処理後の最終検証・確定値の確定 | `AddPointProcessor` `PaymentTotalNegativeValidator` | + +確定系は別メソッドで、`validate()` とは独立に呼ばれる: + +| メソッド | コンポーネント | 役割 | 実コード例 | +|---|---|---|---| +| `prepare()` | `PurchaseProcessor`(interface) | 仮確定(在庫引当) | `StockReduceProcessor::prepare()` | +| `commit()` | `PurchaseProcessor` | 確定 | `OrderNoProcessor` 系 | +| `rollback()` | `PurchaseProcessor` | 仮確定の取消(在庫戻し) | `StockReduceProcessor::rollback()` | + +> `validate()` 内では `removeDiscountItem()` を**全 DiscountProcessor について先に呼び**、値引き明細をクリアしてから +> `addDiscountItem()` を呼ぶ。値引きは「いったん全消し→再計算で積み直す」のが大前提(後述)。 + +## Item と ItemHolder の違い + +- **`ItemInterface`(明細1行)**: `OrderItem` / `CartItem`。`isProduct()` / `isDeliveryFee()` / `isCharge()` / + `isDiscount()` / `isPoint()` / `isTax()` で**明細種別**を判定し、`getPrice()` / `getPriceIncTax()` / + `getQuantity()` / `getProductClass()` を持つ。送料・手数料・値引き・税も「明細の1行」として表現される点に注意。 +- **`ItemHolderInterface`(受注/カート全体)**: `Order` / `Cart`。`getItems()`(`ItemCollection`)で明細を束ねる。 + **`Order` 固有の処理は `instanceof Order` でガードする**(例: `Cart` には Shipping もポイントも無い)。 +- **`PurchaseContext`**: 実行中コンテキスト。`isCartFlow()` / `isShoppingFlow()` / `isOrderFlow()` で + どのフローかを判定でき、`getOriginHolder()`(フロー実行前の状態)/ `getUser()` を持つ。 + +## 基本ルール + +- **追加先のコンポーネントを正しく選ぶ**: 検証なら Validator、明細の付与/調整なら Preprocessor、値引きなら + DiscountProcessor、在庫引当・採番など確定処理なら PurchaseProcessor。上表で対応づける。 +- **`abstract` 基底は `validate()`(protected)を override する**。`execute()` は `final` で、`InvalidItemException` + を捕捉して `ProcessResult` に変換する(自分で try/catch しない)。 +- **`interface` 系(ItemPreprocessor / ItemHolderPreprocessor / DiscountProcessor / PurchaseProcessor)は + メソッドを実装する**。PurchaseProcessor は `AbstractPurchaseProcessor` を継承すれば必要なメソッドだけ override 可。 +- **`supports()` で早期 return**: フロー種別・`Order` か否か・店舗設定(`BaseInfo`)で適用可否を判定し、 + 対象外なら何もしない(`AddPointProcessor::supports()` が手本)。 +- **金額計算は `bcmath`**(`bcadd` / `bcsub` / `bcmul` / `bccomp`)。float 演算で組まない。 + 合計・税・送料・値引きの集計は `PurchaseFlow::calculateAll()` が各段階後に行うので、Processor 側は + **明細(Item)を足し引きする**ことに集中する(合計の手計算は不要)。 + +## エラーと警告の使い分け + +| 投げ方 | どう扱われるか | 用途 | +|---|---|---| +| `ItemValidator` で `throwInvalidItemException(...)` | **常に warning** に変換され、`handle()` で後処理(数量丸め等)が走る | カート段階の自動補正(在庫超過を在庫数に丸める等) | +| `ItemHolderValidator` / `ItemHolderPostValidator` で `throwInvalidItemException(..., warning: true)` | warning | 続行可能な注意 | +| 同上で `warning` を付けない | **error**(`PurchaseFlowResult::hasError()` が true → 呼び出し側が処理中断) | 購入を止めるべき致命的検証 | +| PurchaseProcessor で `throw new PurchaseException(...)` / `ShoppingException` | 例外が伝播し確定処理が中断 | 在庫引当失敗など確定時の異常 | + +- `throwInvalidItemException()` は `ValidatorTrait` のヘルパ。`ProductClass` を渡すと商品名つきメッセージになる。 + メッセージは**翻訳キー**を渡す(`trans()` 相当が内部で走る)。 +- `ProcessResult` は `success()` / `warn()` / `error()` のファクトリのみ(直接 new 不可)。`addError` のような + メソッドは無い。**例外を投げる→基底の `execute()` が `ProcessResult` に変換する**のが正規フロー。 + +## 実装パターン + +### 明細検証(ItemValidator) + +```php +namespace Eccube\Service\PurchaseFlow\Processor; + +use Eccube\Entity\ItemInterface; +use Eccube\Service\PurchaseFlow\ItemValidator; +use Eccube\Service\PurchaseFlow\PurchaseContext; + +class StockValidator extends ItemValidator +{ + #[\Override] + protected function validate(ItemInterface $item, PurchaseContext $context): void + { + if (!$item->isProduct()) { + return; // 商品明細以外は対象外 + } + if ($item->getProductClass()->isStockUnlimited()) { + return; + } + if ($item->getProductClass()->getStock() < $item->getQuantity()) { + // ProductClass を渡すと商品名つきメッセージになる。常に warning 化される。 + $this->throwInvalidItemException('front.shopping.out_of_stock', $item->getProductClass()); + } + } + + #[\Override] + protected function handle(ItemInterface $item, PurchaseContext $context): void + { + // warning 後の自動補正(在庫数に丸める) + $item->setQuantity($item->getProductClass()->getStock()); + } +} +``` + +### 受注前処理(ItemHolderPreprocessor)— 明細の付与・調整 + +```php +class DeliveryFeePreprocessor implements ItemHolderPreprocessor +{ + #[\Override] + public function process(ItemHolderInterface $itemHolder, PurchaseContext $context): void + { + if (!$itemHolder instanceof Order) { + return; // Cart には Shipping が無い + } + // 1. 自分が前に作った明細を消す(ProcessorName で識別) + // 2. 計算し直して付け直す(冪等にする) + // OrderItem を new し、setProcessorName(self::class) で自前の明細に印を付ける + } +} +``` + +> **冪等性が要**: Preprocessor は `validate()` が複数回走っても結果が変わらないよう、 +> 自分が追加した明細を `getProcessorName() === self::class` で識別して**毎回いったん削除→再追加**する +> (`DeliveryFeePreprocessor` が手本)。 + +### 値引き(DiscountProcessor) + +```php +interface DiscountProcessor // 実装する2メソッド +{ + public function removeDiscountItem(ItemHolderInterface $itemHolder, PurchaseContext $context): void; + public function addDiscountItem(ItemHolderInterface $itemHolder, PurchaseContext $context): ?ProcessResult; +} +``` + +- `removeDiscountItem()` で自分の値引き明細を削除 → `addDiscountItem()` で追加。**合計金額を超える値引きを作らない** + (超える場合は利用可能額まで丸めるかスキップし、`ProcessResult::warn()` を返す)。`PointProcessor` が手本。 + +### 確定処理(PurchaseProcessor)— 在庫引当・採番・ポイント付与 + +```php +class StockReduceProcessor extends AbstractPurchaseProcessor +{ + #[\Override] + public function prepare(ItemHolderInterface $itemHolder, PurchaseContext $context): void + { + if (!$itemHolder instanceof Order) { + return; + } + // 在庫を引く。失敗時は ShoppingException / PurchaseException を投げる + } + + #[\Override] + public function rollback(ItemHolderInterface $itemHolder, PurchaseContext $context): void + { + // prepare の逆操作(在庫を戻す)を必ず実装する + } +} +``` + +## 対象フローへの登録方法 + +PurchaseFlow への登録は **2 通り**。どちらも「対象フロー(cart/shopping/order)」を指定する。 + +### (A) コア: `purchaseflow.yaml` のタグで登録 + +`app/config/eccube/packages/purchaseflow.yaml` でサービス定義にタグを付ける。`flow_type` で対象フロー、 +`priority` で実行順(**降順=大きいほど先**)を指定する。 + +```yaml +eccube.purchase.flow.item.validator.stock.validator: + class: Eccube\Service\PurchaseFlow\Processor\StockValidator + tags: + - { name: eccube.item.validator, flow_type: cart, priority: 700 } +``` + +タグ名(`PurchaseFlowPass` の定数)と対応コンポーネント: + +| タグ名 | コンポーネント | +|---|---| +| `eccube.item.validator` | ItemValidator | +| `eccube.item.holder.validator` | ItemHolderValidator | +| `eccube.item.preprocessor` | ItemPreprocessor | +| `eccube.item.holder.preprocessor` | ItemHolderPreprocessor | +| `eccube.discount.processor` | DiscountProcessor | +| `eccube.item.holder.post.validator` | ItemHolderPostValidator | +| `eccube.purchase.processor` | PurchaseProcessor | + +### (B) プラグイン/Customize: 属性 `#[CartFlow]` / `#[ShoppingFlow]` / `#[OrderFlow]` で登録 + +`Kernel` が基底(`ItemValidator` 等)を `registerForAutoconfiguration` でタグ付けするため、 +**基底を継承/実装したクラスは自動でタグが付く**。あとは**どのフローに乗せるかを属性で宣言**する +(`src/Eccube/Attribute/CartFlow.php` 等)。`flow_type` ごとの YAML 配線は不要。 + +```php +use Eccube\Attribute\CartFlow; +use Eccube\Attribute\ShoppingFlow; +use Eccube\Attribute\OrderFlow; +use Eccube\Service\PurchaseFlow\ItemValidator; + +#[CartFlow] +#[ShoppingFlow] +#[OrderFlow] // 乗せたいフローだけ付ける +class SaleLimitOneValidator extends ItemValidator +{ + protected function validate(ItemInterface $item, PurchaseContext $context): void { /* ... */ } +} +``` + +- 手本は `app/Plugin/PurchaseProcessors/Service/PurchaseFlow/Processor/SaleLimitOneValidator.php`。 +- `PurchaseFlowPass` は YAML 配線済みなら属性での二重登録を防ぐ(`alreadyWired()`)。**(A) と (B) を併用しない**。 +- 属性方式は priority を指定できない(属性だけでは順序制御不可)。**実行順が重要なら (A) の YAML タグ**を使う。 + +## よくある間違い + +- ❌ 在庫引当・採番・ポイント付与・送料/値引き計算をコントローラや汎用 Service に直書き → ✅ 該当 Processor/Validator を拡張する +- ❌ 検証なのに ItemHolderPreprocessor、明細付与なのに Validator、と取り違える → ✅ パイプライン表で役割に合うコンポーネントを選ぶ +- ❌ abstract 基底の `execute()` を override / 自前で try-catch → ✅ `validate()`(protected)だけ override。`execute()` は `final` +- ❌ `ProcessResult` を `new` する / `addError()` を探す → ✅ 例外(`throwInvalidItemException` / `InvalidItemException`)を投げ、基底に変換させる +- ❌ ItemValidator で「購入を止めたい」のに止まらない → ✅ ItemValidator は**常に warning**。中断したい検証は `ItemHolderValidator`/`PostValidator` で warning なしの error にする +- ❌ Preprocessor で明細を追加しっぱなし(再実行で多重化) → ✅ `setProcessorName(self::class)` で印を付け、毎回削除→再追加で冪等にする +- ❌ 値引きで合計金額を超える明細を作る → ✅ 利用可能額まで丸めるかスキップし `ProcessResult::warn()` を返す +- ❌ 金額を float / `+`・`*` で計算 → ✅ `bcadd`/`bcsub`/`bcmul`/`bccomp` を使う +- ❌ `Cart` でも `getShippings()` / `getCustomer()` を呼ぶ → ✅ `instanceof Order` でガード(Cart には Shipping もポイントも無い) +- ❌ PurchaseProcessor の `rollback()` を実装し忘れる → ✅ `prepare()` の逆操作(在庫戻し等)を必ず実装する +- ❌ 属性方式で実行順を制御しようとする → ✅ 順序が要るなら YAML タグの `priority`(降順)で指定する +- ❌ (A) YAML タグと (B) 属性を両方付ける → ✅ どちらか一方。コアは YAML、プラグイン/Customize は属性が定石 + +## 実行・確認方法 + +コンソール・QA ツール(PHPUnit / PHPStan / PHP-CS-Fixer / Rector)の実行方法は AGENTS.md「開発コマンド」を参照。 + +- パイプラインに実際にどの Processor が、どの順で乗っているかは `PurchaseFlow::dump()`(`__toString()`)で + ツリー表示できる。登録できているか・順序が意図どおりかの確認に使う。 +- プラグインでエンティティ拡張を伴う場合はプロキシ再生成(`bin/console eccube:generate:proxies`)を忘れない。 + +--- + +実装・改修後は、Skill `service`(責務分離)と `review-responsibility` で点検すること。 +プラグインから追加する場合は Skill `plugin` も参照。 diff --git a/.claude/skills/repository/SKILL.md b/.claude/skills/repository/SKILL.md index 9ecae2a8d42..70868569dce 100644 --- a/.claude/skills/repository/SKILL.md +++ b/.claude/skills/repository/SKILL.md @@ -71,3 +71,4 @@ class ExampleRepository extends AbstractRepository - ❌ Repository に業務ロジックを書く → ✅ データアクセスに徹し、ロジックは Service - ❌ `ServiceEntityRepository` を直接継承 → ✅ `AbstractRepository` を継承 - ❌ オーバーライドで親と異なるシグネチャ → ✅ 親シグネチャを厳守 +- ❌ 画面表示の一覧・関連取得を無制限に全件取得(件数が際限なく増え得る)→ ✅ ページング(Paginator 用に QueryBuilder を返す)か上限を設ける diff --git a/.claude/skills/review-responsibility/SKILL.md b/.claude/skills/review-responsibility/SKILL.md index 43015d78ccd..c0d89f91090 100644 --- a/.claude/skills/review-responsibility/SKILL.md +++ b/.claude/skills/review-responsibility/SKILL.md @@ -1,36 +1,67 @@ --- name: review-responsibility -description: EC-CUBE 4.4 で実装・改修したコードの責務分離をチェックする。「責務分離を確認して」「Fatコントローラ/Fatサービスになってないか見て」「実装後のレビューをして」「リファクタの観点を出して」「レイヤ違反がないか確認して」などと言われたとき、またはコントローラ/サービスの実装・改修が一区切りついた直後に使用する。業務ロジックの偏りやレイヤ違反を点検する。 +description: EC-CUBE 4.4 で実装・改修したコードを実装直後に自己レビューする全層チェックリスト。「責務分離を確認して」「実装後のレビューをして」「Fatコントローラ/Fatサービスになってないか見て」「レイヤ違反がないか確認して」「認可/CSRF/XSSの抜けを確認して」「リファクタの観点を出して」などと言われたとき、またはコントローラ/サービス/フォーム/テンプレート等の実装・改修が一区切りついた直後に使用する。責務分離・セキュリティ・レイヤ違反を横断的に点検する。 --- -# 責務分離レビュー(実装直後の自己チェック) +# 実装直後の自己レビュー(全層チェックリスト) -コントローラ/サービスの実装・改修が一区切りついたら、次の手順で責務分離を点検する。 -**これは助言であり、必ずしも全件修正を要求するものではない**(既存コードの一括修正は求めない)。 - -## 手順 +実装・改修が一区切りついたら、変更差分を次の観点で横断的に点検する。 +**これは助言であり、必ずしも全件修正を要求しない**(既存コードの一括修正は求めない)。 > 行数・依存数などの数値で線を引かず、**質的シグナル**で判断する。 -> 整形・型・アノテーション変換は `vendor/bin/rector process --dry-run` / `phpstan analyse src` / `php-cs-fixer fix` に委ね、レビューは責務分離に集中する。 +> 整形・型・アノテーション変換は `rector --dry-run` / `phpstan analyse src` / `php-cs-fixer fix` に委ね、レビューは下記の観点に集中する(QA 実行方法は AGENTS.md「開発コマンド」を参照)。 + +## 進め方 + +1. 変更したファイルが**どの層に属すか**を洗い出す。 +2. 各層に対応する Skill の「よくある間違い」を**正典として読み込み**、差分を照合する(詳細は再記述せず、各 Skill を参照する)。 +3. 層をまたぐ観点(下記「層境界」)を最後に確認する。 +4. 指摘は「**新規・改修分**」を優先。具体的な是正案(どの処理をどこへ出すか)を添える。 + +## 層ごとの観点(対応 Skill の「よくある間違い」を参照) + +### 責務分離(コントローラ / サービス) — Skill [`controller`](../controller/SKILL.md) / [`service`](../service/SKILL.md) +- 業務ロジック(金額・送料・ポイント計算、複数 Repository 横断、外部連携、メール送信)がコントローラに残っていないか → **Service へ抽出**。 +- 受注の計算・検証・確定(在庫引当・採番・ポイント付与・値引き)が **PurchaseFlow 外**に書かれていないか → 該当 Processor/Validator へ。 +- コントローラ内に `$em->persist()` / `$em->flush()` の**業務的な直書き**がないか → Service へ。 +- Service が `Request`/`Response` に依存していないか → レイヤ違反(依存は Controller → Service → Repository の一方向)。 +- 同一処理のコピペが複数箇所にないか → 共通 Service メソッドへ。 + +### セキュリティ — Skill [`security`](../security/SKILL.md) +- 新規の管理アクションが **`%eccube_admin_route%` 配下**に置かれているか(firewall 保護下か)。 +- GET 以外の状態変更(更新・削除・Ajax)で **CSRF が検証**されているか(フォーム経由 or `$this->isTokenValid()`)。 +- フロントで `{id}` から取得したリソースに**所有権チェック**があるか(**IDOR**)。 +- パスワード変更・退会など重要操作が `IS_AUTHENTICATED_FULLY` で守られているか。 + +### Twig 拡張・テンプレート — Skill [`twig-template`](../twig-template/SKILL.md) +- ユーザー入力・DB 値を `|raw` で出力していないか(**反射型/蓄積型 XSS**)。JS 文脈で `|escape('js')` を使っているか。 +- **蓄積型(stored)XSS**: DB に保存したユーザー入力(レビュー・コメント・氏名等)を表示する箇所で `|raw` していないか/入力時のサニタイズに頼って出力エスケープを省いていないか(保存値は常に未信頼として出力時にエスケープ)。 +- `is_safe => ['html']` を付けた関数内で外部入力を未エスケープ連結していないか。 +- テンプレート上書きのパス・名前空間(`@admin` 等)が正しいか。 + +### イベント — Skill [`event-subscriber`](../event-subscriber/SKILL.md) +- リスナー/サブスクライバに**業務ロジックが偏っていないか**(重い処理は Service へ委譲、リスナーは薄く)。 +- `getSubscribedEvents()` が `static` か、イベント名に `EccubeEvents` 定数を使っているか。 + +### Entity — Skill [`entity`](../entity/SKILL.md) +- 金額(DECIMAL)を int/float 扱いしていないか(`?string`/bcmath)。`create_date`/`update_date` を自前 PrePersist で二重実装していないか。 +- 他エンティティ(特にコアの `Product`/`Customer` 等)への関連で、親削除時の挙動(`onDelete` or Service/disable での後始末)を決めているか(未決定だと退会・商品削除を FK で止める)。 + +### Repository — Skill [`repository`](../repository/SKILL.md) +- 生 SQL 連結でなく QueryBuilder+`setParameter` か。画面表示の一覧・関連取得が無制限になっていないか(ページング/上限)。 -### 1. 規約に照らした目視レビュー +### プラグイン — Skill [`plugin`](../plugin/SKILL.md) +- エンティティトレイトに `#[EntityExtension]` を付け、**proxy 再生成**を意識しているか。 +- プロジェクト固有の改変を不要にプラグイン化していないか(`app/Customize` との使い分け)。 -変更差分について、対応する規約を読み込んで確認する: -- コントローラ: Skill [`controller`](../controller/SKILL.md) -- サービス: Skill [`service`](../service/SKILL.md) +## 層境界(横断観点・per-layer では拾えないもの) -確認観点(質的シグナル): -- 業務ロジック(金額・送料・ポイント計算、複数 Repository 横断、外部連携、メール送信)が - コントローラに残っていないか → あれば **Service への抽出**を提案。 -- 受注の計算・検証・確定(在庫引当・採番・ポイント付与・値引き)が **PurchaseFlow 外**に書かれていないか - → 該当する Processor/Validator への移動を提案。 -- コントローラ内に `$em->persist()` / `$em->flush()` の**業務的な直書き**がないか → 永続化を伴う業務操作は Service へ。 -- Service が Controller / HTTP(`Request`/`Response`)に依存していないか → レイヤ違反(依存は Controller → Service → Repository の一方向)は是正を提案。 -- 同じ処理が複数箇所にコピペされていないか → 共通の Service メソッドへ。 -- 1 つのクラス/メソッドに無関係な責務が同居していないか(行数の多寡そのものではなく、関心事の数で見る)。 +- コントローラ/サービスが**未エスケープのユーザー入力を Twig に渡し**、テンプレート側で `|raw` 出力していないか(security × twig)。 +- フォーム未経由の入力(Ajax/API)が**バリデーションと CSRF の両方**を通っているか(controller × formtype × security)。 +- イベントリスナー内で**認可・所有権チェックを迂回**していないか(event × security)。 -### 2. 提案のまとめ方 +## 提案のまとめ方 - **新規・改修したコードの指摘**を優先して提示する。 -- 既存(変更していない)コードの Fat は、無理に直さず「将来のリファクタ候補」として軽く触れるに留める。 -- 各指摘に「どの処理を、どの Service の何というメソッドへ出すか」の具体案を添える。 +- 既存(未変更)コードの問題は、無理に直さず「将来のリファクタ候補」として軽く触れるに留める。 +- 各指摘に「どの処理を、どこ(どの Service/Processor、どのエスケープ)へ」の具体案を添える。 diff --git a/.claude/skills/security/SKILL.md b/.claude/skills/security/SKILL.md new file mode 100644 index 00000000000..b9deddbaa54 --- /dev/null +++ b/.claude/skills/security/SKILL.md @@ -0,0 +1,110 @@ +--- +name: security +description: EC-CUBE 4.4 の認証・認可・CSRF などセキュリティを実装・改修・点検するときの規約。「認可を追加して」「アクセス制御を直して」「このルートに権限チェックを入れて」「CSRF対策を確認して」「Voterを作って」「セキュリティ監査して」などと言われたとき、または src/Eccube/Security 配下・app/config/eccube/packages/security.yaml を作成・編集するとき、認可漏れ/CSRF漏れ/IDOR を点検するときに使用する。 +--- + +# セキュリティ規約 — 認証・認可・CSRF(EC-CUBE 4.4) + +**対象**: `src/Eccube/Security/**/*.php`, `app/config/eccube/packages/security.yaml`, +および全コントローラの認可・CSRF 判断(コントローラ側の作法は Skill `controller` も参照) +**前提**: Symfony 7.4 / PHP 8.2+ + +> 目的: EC-CUBE の「ファイアウォール+ロール+Voter」というアクセス制御モデルを正しく理解し、 +> 認可漏れ・CSRF 漏れ・IDOR(他人のリソース参照)を作り込まない/見逃さないこと。 + +## アクセス制御モデル(まず全体像を掴む) + +EC-CUBE は **個別アクションの `#[IsGranted]` ではなく、ファイアウォール+ロール+Voter** で制御する。 +設定は `app/config/eccube/packages/security.yaml`。 + +- **firewalls** は 3 つ: + - `admin`: `pattern: '^/%eccube_admin_route%/'` — `Member`(管理者) を認証。`enable_csrf: true`、login throttling 有り。 + - `customer`: `pattern: '^/'`(サイト全体)— `Customer`(会員) を認証。remember_me 有り。 + - `dev`: `security: false` — 静的リソース等を認証対象外にする。 +- **access_decision**: `strategy: unanimous` / `allow_if_all_abstain: false`。 + → **1 つでも Voter が DENY すれば拒否**。全 Voter が棄権したら拒否(明示的に許可が必要)。 +- **ロール**: + - `ROLE_ADMIN` — `Member::getRoles()` が固定で返す(管理者)。 + - `ROLE_USER` — フロント会員の暗黙ロール。 + - 管理者の権限細分は **`Authority` マスタ**(`ADMIN=0` システム管理者 / `OWNER=1` 店舗オーナー)。 + +**最重要の含意**: 新規の管理アクションは、認可の主装置が**ファイアウォール**なので +**必ず `%eccube_admin_route%` プレフィックス配下のパス**に置く。配下に置けば認証が要求される。 +配下から外すと無認証で到達できる(よくある重大な事故)。 + +## 基本ルール + +- 管理アクションは `#[Route(path: '/%eccube_admin_route%/...')]` に置く(admin firewall の保護下に入れる)。 +- 管理画面内のさらに細かい権限制御は **`AuthorityVoter`**(`src/Eccube/Security/Voter/AuthorityVoter.php`)が担う。 + `AuthorityRole` の `deny_url`(正規表現)に基づき、URL 単位で `ACCESS_DENIED` を返す。 + → 「特定の権限にこの画面を見せない」は **コントローラ改変ではなく `dtb_authority_role` の設定**で実現される。 +- **GET 以外の状態変更(更新・削除・Ajax)は必ず CSRF トークンを検証する**。 + フォーム経由(`handleRequest`+`isValid`)は保護込み。フォームを介さない処理は `$this->isTokenValid()` を明示的に呼ぶ。 +- **認証状態の使い分け**: + - パスワード変更・退会・購入確定など重要操作 → **`IS_AUTHENTICATED_FULLY`**(remember-me を除外)。 + - 単なるログイン状態の確認 → `IS_AUTHENTICATED_REMEMBERED` でよい。 +- **フロントで `{id}` 等の他人のリソースを受け取るアクションは所有権を検証する**(後述の IDOR)。 +- パスワードは `PasswordHasher`(`algorithm: 'auto'`)任せ。**自前ハッシュ・平文比較を書かない**。 +- Twig 出力のエスケープ(XSS)は Skill `twig-template` を参照(`|raw` の濫用に注意)。 + +## 実装パターン + +### CSRF トークン検証(フォームを介さない削除・Ajax) +基底クラス `AbstractController::isTokenValid()` を呼ぶ。トークン名は `Constant::TOKEN_NAME`(`src/Eccube/Common/Constant.php`、値は `'_token'`)、 +リクエストパラメータ `_token` またはヘッダ `ECCUBE-CSRF-TOKEN` から取得し、失敗時は例外を投げる。 + +```php +// src/Eccube/Controller/.../CustomerController.php の delete が定石 +$this->isTokenValid(); // CSRF 検証。失敗で AccessDeniedHttpException +$this->customerService->delete($Customer); +``` + +### 認可チェック(コントローラ内で明示する場合) +```php +// ROLE で分岐(例: ログイン済み管理者をホームへ) +if ($this->authorizationChecker->isGranted('ROLE_ADMIN')) { ... } + +// 重要操作は FULLY を要求(remember-me を弾く) +if ($this->isGranted('IS_AUTHENTICATED_FULLY')) { ... } +``` + +### URL 単位の権限制御(Voter 経由・コア標準) +`AuthorityVoter` は `Member` の `Authority` に紐づく `AuthorityRole.deny_url` を取得し、 +リクエストパスが一致したら `ACCESS_DENIED` を返す(`src/Eccube/Security/Voter/AuthorityVoter.php`)。 +**新しい「見せない画面」を増やすときは Voter を書くのではなく deny_url 設定で対応できないか先に検討する。** + +### 独自 Voter を追加する場合 +`Symfony\Component\Security\Core\Authorization\Voter\Voter` を継承し `supports()`/`voteOnAttribute()` を実装する。 +`services.yaml` の `autoconfigure: true` により `security.voter` タグは自動付与される(手動登録は不要。コアの既存 Voter に倣う)。 +**`access_decision` が unanimous なので、棄権(ABSTAIN)と拒否(DENY)の使い分けを誤ると全体が拒否になる**点に注意。 + +## よくある間違い(認可・CSRF・IDOR — ツールでは検出できない観点) + +- ❌ 管理アクションを `%eccube_admin_route%` 配下**以外**に置く → ✅ 配下に置き admin firewall の保護下にする +- ❌ フォームを介さない POST/DELETE/Ajax で CSRF 未検証 → ✅ `$this->isTokenValid()` を呼ぶ(GET 以外) +- ❌ Ajax 専用アクションで XHR 以外(直アクセス等)も受け付ける → ✅ CSRF 検証に加え **`$request->isXmlHttpRequest()`** を併用し XHR 由来に限定する(コア例: `NonMemberShoppingController` / `Admin/Content/MaintenanceController`) +- ❌ フロントで `{id}` から取得したエンティティを所有権チェックせず編集/削除(**IDOR**) + → ✅ `$this->getUser()` と突き合わせ、他人のリソースなら `AccessDeniedHttpException` +- ❌ パスワード変更・退会など重要操作を `IS_AUTHENTICATED_REMEMBERED` で許可 + → ✅ `IS_AUTHENTICATED_FULLY` を要求(盗難 Cookie での実行を防ぐ) +- ❌ 独自 Voter で「対象外」を `ACCESS_DENIED` で返す → ✅ 対象外は `ACCESS_ABSTAIN`(unanimous 戦略で誤拒否を防ぐ) +- ❌ 自前でパスワードをハッシュ/平文比較 → ✅ `PasswordHasher` 経由に統一 +- ❌ ユーザー入力を Twig で `|raw` 出力 → ✅ エスケープを効かせる(Skill `twig-template`) +- ❌ アップロード/ファイル操作を伴う管理ルートを新設したが `eccube_restrict_file_upload` の遮断対象を考慮しない → ✅ コアは `eccube_restrict_file_upload === '1'` のとき `eccube_restrict_file_upload_urls` のルートを `RestrictFileUploadListener` で遮断する。新規ルートを遮断対象に含めるべきか検討する +- ❌ ユーザー指定のファイル名/パスをそのまま読み書き・配信(**ディレクトリトラバーサル**)→ ✅ `..` を拒否し `realpath()` で解決、許可ベースディレクトリ内かを `str_starts_with(realpath($target), realpath($baseDir))` で検証する(コアの `FileController::checkDir()` が手本。基準は `html/user_data` 等の jail ディレクトリ) + +## 実行・確認方法 + +QA ツール(PHPUnit / PHPStan / PHP-CS-Fixer)の実行方法は AGENTS.md「開発コマンド」を参照。 + +- **Voter の単体テスト**: `tests/Eccube/Tests/Security/Voter/AuthorityVoterTest.php`(deny_url パターンごとの GRANTED/DENIED)。 +- **管理ログイン/リダイレクトのテスト**: `tests/Eccube/Tests/Web/Admin/LoginControllerTest.php` + (未ログインで admin 配下にアクセスすると 302 になることを確認)。 +- **認可漏れの目視点検**: 新規 admin ルートが `%eccube_admin_route%` 配下にあるか、 + 状態変更アクションで `isTokenValid()` またはフォーム検証を通っているか、 + フロントの `{id}` 取得に所有権チェックがあるかを確認する。 +- 静的解析・整形は `vendor/bin/phpstan analyse src` / `vendor/bin/php-cs-fixer fix`(Docker 経由)。 + +--- + +実装・改修後は、Skill `review-responsibility` で責務分離とあわせて認可・CSRF・IDOR を点検すること。 diff --git a/.claude/skills/twig-template/SKILL.md b/.claude/skills/twig-template/SKILL.md new file mode 100644 index 00000000000..543f6581cf6 --- /dev/null +++ b/.claude/skills/twig-template/SKILL.md @@ -0,0 +1,115 @@ +--- +name: twig-template +description: EC-CUBE 4.4 の Twig 拡張(Extension/Filter/Function)とテンプレートを実装・改修・点検するときの規約。「Twig拡張を作って」「フィルタ/関数を追加して」「テンプレートを上書きして」「このテンプレートを直して」「XSS/エスケープを確認して」「rawの使い方を点検して」などと言われたとき、または src/Eccube/Twig/Extension・app/template・Resource/template 配下を作成・編集するときに使用する。 +--- + +# Twig 拡張・テンプレート規約(EC-CUBE 4.4) + +**対象**: `src/Eccube/Twig/Extension/**/*.php`, `src/Eccube/Resource/template/**/*.twig`, `app/template/**/*.twig` +**前提**: Symfony 7.4 / Twig 3.x / PHP 8.2+ + +> 目的: オートエスケープを前提に **XSS を作り込まない**こと、テンプレートの**上書きパス・名前空間を正しく**選ぶこと。 +> 直近でコアに XSS 修正が入っている領域なので、`|raw` と `is_safe` の扱いは特に慎重に。 + +## オートエスケープと XSS(最優先) + +- Twig は **HTML オートエスケープがデフォルト有効**(`packages/twig.yaml` に明示設定はなく Twig 既定動作)。 + 通常の `{{ value }}` は自動でエスケープされる。**わざわざ `|raw` を付けない限り安全**、が大原則。 +- **`|raw` はエスケープを無効化する**。ユーザー入力・DB 由来の文字列に `|raw` を付けると XSS になる。 + `|raw` を書く前に「この値は本当に信頼できる HTML か?」を必ず自問する。 +- **コンテキストに応じたエスケープ**を使う: + - JavaScript の中に値を埋めるなら `{{ value|escape('js') }}`(`|e('js')`)。HTML エスケープでは JS 文脈の XSS を防げない。 + - 例: 管理画面 `Order/search_product.twig` は `{{ Product.id|escape('js') }}` と JS 文脈エスケープを使っている。 +- PHP 側で HTML を返すフィルタ/関数は **`['is_safe' => ['html']]`** を付ける(付けないと二重エスケープされる)。 + **ただし `is_safe` を付ける=そのフィルタの出力責任を開発者が負う**ということ。中で生成する HTML に + 外部入力を混ぜるなら `htmlspecialchars($value, ENT_QUOTES, 'UTF-8')` で**自前エスケープしてから**返す。 + +## Twig 拡張の実装パターン + +`src/Eccube/Twig/Extension/` に Twig 標準の `AbstractExtension`(`\Twig\Extension\AbstractExtension`)を継承して置く。`autoconfigure: true`(`services.yaml`)で +自動的に Twig 拡張として登録される(手動タグ不要)。代表例: `EccubeExtension` / `TaxExtension` / `CsrfExtension` / `IntlExtension`。 + +```php +class ExampleExtension extends AbstractExtension +{ + public function getFilters(): array + { + return [ + // HTML を返すフィルタは is_safe を明示。中の外部入力は自前でエスケープする + new TwigFilter('file_ext_icon', $this->getExtensionIcon(...), ['is_safe' => ['html']]), + new TwigFilter('price', $this->getPriceFilter(...)), + ]; + } + + public function getFunctions(): array + { + return [ + new TwigFunction('product', $this->getProduct(...)), + new TwigFunction('class_categories_as_json', $this->getClassCategoriesAsJson(...)), + ]; + } +} +``` + +- 既存のフィルタ例: `price` / `date_format` / `ellipsis` / `no_image_product` / `file_ext_icon`。 +- 既存の関数例: `has_errors()` / `active_menus()` / `product()` / `class_categories_as_json()` / `currency_symbol()`。 +- Twig で使えるグローバル: `BaseInfo` / `eccube_config` / `Layout` / `Page` / `event_dispatcher`(`TwigInitializeListener` が注入)。 + +## テンプレートの配置と上書き + +コアテンプレートは `src/Eccube/Resource/template/` にあり、`app/template/` に**同じ相対パス**で置くと上書きできる。 +名前空間と探索優先順は `packages/twig.yaml` の `paths` で決まる。 + +| 用途 | コア(既定) | 上書き先 | 名前空間 | +|---|---|---|---| +| 店頭(フロント) | `src/Eccube/Resource/template/default/` | `app/template/{テーマ}/` | なし(既定) | +| 管理画面 | `src/Eccube/Resource/template/admin/` | `app/template/admin/` | `@admin` | +| ユーザーデータ | — | `app/template/user_data/` | `@user_data` | + +- **管理画面テンプレートを参照するときは `@admin` 名前空間**を付ける(例: `{{ include('@admin/...') }}`)。名前空間を忘れると探索先を誤る。 +- 上書きは `app/template/` 直下ではなく、**`admin/` か `default(テーマ)/` の正しいサブディレクトリ**に置く。 +- **ユーザーが編集できるテンプレート文字列(CMS コンテンツ・フリーエリア・メール本文等)を描画するときは Twig サンドボックスを通す**。 + 文字列テンプレートは `template_from_string(...)` + `sandboxed = true` で描画する(コアの定石。例: `default_frame.twig` の CMS メタタグ)。 + 許可するタグ/フィルタ/関数はコアの `SecurityPolicyDecorator`(`src/Eccube/Twig/Sandbox/`)で制御されており、**サンドボックスを外すとテンプレートインジェクションになる**(過去の脆弱性修正の中心領域)。 + +## テンプレートイベント(差し込み) + +全テンプレートは描画時に **ファイル名をイベント名として** `TemplateEvent` が dispatch される +(`TemplateEventExtension` / `Twig/Template.php`)。プラグイン・カスタマイズはここに差し込む。 + +```php +public function onTemplateCart(TemplateEvent $event): void +{ + $event->addAsset('@MyPlugin/cart_script.twig'); // 等へアセット追加 + $event->addSnippet('@MyPlugin/cart_footer.twig'); // 既定位置へスニペット挿入 + // $event->setSource(...) でテンプレート本体を置換も可能 +} +``` + +詳細なイベントの購読方法は Skill `event-subscriber` を参照。**テンプレートイベントは見た目の調整に使い、業務ロジック(永続化等)を書かない**。 + +## よくある間違い(XSS・上書き — ツールでは検出しにくい観点) + +- ❌ ユーザー入力・DB 値に `{{ value|raw }}` → ✅ `|raw` を外す。HTML が必要なら出力前にサニタイズ +- ❌ JS の中に `{{ value }}`(HTML エスケープのみ)→ ✅ `{{ value|escape('js') }}` +- ❌ `is_safe => ['html']` を付けた関数内で外部入力を未エスケープ連結 → ✅ `htmlspecialchars(..., ENT_QUOTES, 'UTF-8')` +- ❌ HTML を返すフィルタに `is_safe` を付け忘れ → ✅ 付ける(さもないと二重エスケープで `<` 等が表示される) +- ❌ 上書きを `app/template/` 直下に置く / `@admin` 名前空間を付け忘れる → ✅ 正しいサブディレクトリ・名前空間に置く +- ❌ **管理画面テンプレートだから安全**と油断して `|raw` する → ✅ admin 配下も XSS シンク(過去の XSS 修正は管理画面テンプレートに多い)。DB/入力由来の値は admin でも必ずエスケープする +- ❌ テンプレートイベントにエンティティ永続化など業務処理を書く → ✅ 見た目調整のみ。業務は対応するコントローライベントへ + +## 実行・確認方法 + +QA ツール(PHPUnit / PHPStan / PHP-CS-Fixer)の実行方法は AGENTS.md「開発コマンド」を参照。 + +```bash +bin/console lint:twig src/Eccube/Resource/template/ # Twig 構文チェック +bin/console cache:clear # テンプレート変更の反映(var/cache/{env} クリア) +``` + +- 上書きが効かない/変更が反映されない場合は、まず `cache:clear` と上書きパス・名前空間を疑う。 +- `|raw` を追加・改修したら、その値の出所(ユーザー入力か固定か)を必ず確認する。 + +--- + +実装・改修後は、Skill `review-responsibility` でエスケープ漏れ・上書きパスを点検すること。 diff --git a/AGENTS.md b/AGENTS.md index fd5883927b3..d896bec4fec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -198,6 +198,15 @@ frontmatter の `description` がトリガ条件で、該当レイヤを触る | Entity(Doctrine エンティティ) | [`.claude/skills/entity/SKILL.md`](./.claude/skills/entity/SKILL.md) | `entity` | | Repository(データアクセス) | [`.claude/skills/repository/SKILL.md`](./.claude/skills/repository/SKILL.md) | `repository` | | FormType(フォーム) | [`.claude/skills/formtype/SKILL.md`](./.claude/skills/formtype/SKILL.md) | `formtype` | +| セキュリティ(認証・認可・CSRF・IDOR) | [`.claude/skills/security/SKILL.md`](./.claude/skills/security/SKILL.md) | `security` | +| Twig 拡張・テンプレート(XSS・上書き) | [`.claude/skills/twig-template/SKILL.md`](./.claude/skills/twig-template/SKILL.md) | `twig-template` | +| イベント(Subscriber・テンプレート/Doctrine イベント) | [`.claude/skills/event-subscriber/SKILL.md`](./.claude/skills/event-subscriber/SKILL.md) | `event-subscriber` | +| プラグイン(ライフサイクル・配置・拡張) | [`.claude/skills/plugin/SKILL.md`](./.claude/skills/plugin/SKILL.md) | `plugin` | +| 受注処理(PurchaseFlow の Processor/Validator) | [`.claude/skills/purchase-flow/SKILL.md`](./.claude/skills/purchase-flow/SKILL.md) | `purchase-flow` | +| メール(MailService・テンプレート・MailHistory) | [`.claude/skills/mail/SKILL.md`](./.claude/skills/mail/SKILL.md) | `mail` | +| カスタマイズ(app/Customize での拡張・上書き・デコレーション) | [`.claude/skills/customize/SKILL.md`](./.claude/skills/customize/SKILL.md) | `customize` | +| CSV 入出力(CsvImport/Export・CSV 定義) | [`.claude/skills/csv/SKILL.md`](./.claude/skills/csv/SKILL.md) | `csv` | +| コンソールコマンド(Symfony Console・バッチ) | [`.claude/skills/command/SKILL.md`](./.claude/skills/command/SKILL.md) | `command` | | 責務分離レビュー(実装直後の自己チェック・全層) | [`.claude/skills/review-responsibility/SKILL.md`](./.claude/skills/review-responsibility/SKILL.md) | `review-responsibility` | > 規約は必要になった時点で `.claude/skills//SKILL.md` を 1 ファイル追加して足す(`.codex`/`.agents` は symlink で自動共有)。