diff --git a/.gitignore b/.gitignore index 52dcccb2915..8338e814354 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,9 @@ node_modules !/app/Plugin/.gitkeep /app/PluginData/* !/app/PluginData/.gitkeep +/app/keystore/* +!/app/keystore/.gitkeep +!/app/keystore/.htaccess /app/template/* !/app/template/admin !/app/template/default diff --git a/.htaccess b/.htaccess index 4439047aca8..015024cf20f 100644 --- a/.htaccess +++ b/.htaccess @@ -12,7 +12,7 @@ DirectoryIndex index.php index.html .ht Allow from all - + order allow,deny deny from all diff --git a/app/DoctrineMigrations/Version20260604120000.php b/app/DoctrineMigrations/Version20260604120000.php new file mode 100644 index 00000000000..74274ee06bb --- /dev/null +++ b/app/DoctrineMigrations/Version20260604120000.php @@ -0,0 +1,55 @@ + alpha-2) の初期データを投入する. + * + * テーブル自体は doctrine:schema:update でエンティティから生成される。 + * 新規インストールは import_csv (definition.yml) で投入されるため、 + * 本マイグレーションは既存インストールのアップグレード時の backfill を担う + * (空テーブルのときのみ INSERT する冪等実装)。 + */ +final class Version20260604120000 extends AbstractMigration +{ + public const NAME = 'mtb_country_iso_code'; + + public function up(Schema $schema): void + { + if (!$schema->hasTable(self::NAME)) { + return; + } + + // 既にデータがある場合 (新規インストールの fixtures 投入済み等) は二重投入しない. + $count = $this->connection->fetchOne('SELECT COUNT(*) FROM '.self::NAME); + if ($count > 0) { + return; + } + + $this->addSql("INSERT INTO mtb_country_iso_code (id, name, sort_no, discriminator_type) VALUES (352, 'IS', 1, 'countryisocode'), (372, 'IE', 2, 'countryisocode'), (31, 'AZ', 3, 'countryisocode'), (4, 'AF', 4, 'countryisocode'), (840, 'US', 5, 'countryisocode'), (850, 'VI', 6, 'countryisocode'), (16, 'AS', 7, 'countryisocode'), (784, 'AE', 8, 'countryisocode'), (12, 'DZ', 9, 'countryisocode'), (32, 'AR', 10, 'countryisocode'), (533, 'AW', 11, 'countryisocode'), (8, 'AL', 12, 'countryisocode'), (51, 'AM', 13, 'countryisocode'), (660, 'AI', 14, 'countryisocode'), (24, 'AO', 15, 'countryisocode'), (28, 'AG', 16, 'countryisocode'), (20, 'AD', 17, 'countryisocode'), (887, 'YE', 18, 'countryisocode'), (826, 'GB', 19, 'countryisocode'), (86, 'IO', 20, 'countryisocode'), (92, 'VG', 21, 'countryisocode'), (376, 'IL', 22, 'countryisocode'), (380, 'IT', 23, 'countryisocode'), (368, 'IQ', 24, 'countryisocode'), (364, 'IR', 25, 'countryisocode'), (356, 'IN', 26, 'countryisocode'), (360, 'ID', 27, 'countryisocode'), (876, 'WF', 28, 'countryisocode'), (800, 'UG', 29, 'countryisocode'), (804, 'UA', 30, 'countryisocode'), (860, 'UZ', 31, 'countryisocode'), (858, 'UY', 32, 'countryisocode'), (218, 'EC', 33, 'countryisocode'), (818, 'EG', 34, 'countryisocode'), (233, 'EE', 35, 'countryisocode'), (231, 'ET', 36, 'countryisocode'), (232, 'ER', 37, 'countryisocode'), (222, 'SV', 38, 'countryisocode'), (36, 'AU', 39, 'countryisocode'), (40, 'AT', 40, 'countryisocode'), (248, 'AX', 41, 'countryisocode'), (512, 'OM', 42, 'countryisocode'), (528, 'NL', 43, 'countryisocode'), (288, 'GH', 44, 'countryisocode'), (132, 'CV', 45, 'countryisocode'), (831, 'GG', 46, 'countryisocode'), (328, 'GY', 47, 'countryisocode'), (398, 'KZ', 48, 'countryisocode'), (634, 'QA', 49, 'countryisocode'), (581, 'UM', 50, 'countryisocode'), (124, 'CA', 51, 'countryisocode'), (266, 'GA', 52, 'countryisocode'), (120, 'CM', 53, 'countryisocode'), (270, 'GM', 54, 'countryisocode'), (116, 'KH', 55, 'countryisocode'), (580, 'MP', 56, 'countryisocode'), (324, 'GN', 57, 'countryisocode'), (624, 'GW', 58, 'countryisocode'), (196, 'CY', 59, 'countryisocode'), (192, 'CU', 60, 'countryisocode'), (531, 'CW', 61, 'countryisocode'), (300, 'GR', 62, 'countryisocode'), (296, 'KI', 63, 'countryisocode'), (417, 'KG', 64, 'countryisocode'), (320, 'GT', 65, 'countryisocode'), (312, 'GP', 66, 'countryisocode'), (316, 'GU', 67, 'countryisocode'), (414, 'KW', 68, 'countryisocode'), (184, 'CK', 69, 'countryisocode'), (304, 'GL', 70, 'countryisocode'), (162, 'CX', 71, 'countryisocode'), (268, 'GE', 72, 'countryisocode'), (308, 'GD', 73, 'countryisocode'), (191, 'HR', 74, 'countryisocode'), (136, 'KY', 75, 'countryisocode'), (404, 'KE', 76, 'countryisocode'), (384, 'CI', 77, 'countryisocode'), (166, 'CC', 78, 'countryisocode'), (188, 'CR', 79, 'countryisocode'), (174, 'KM', 80, 'countryisocode'), (170, 'CO', 81, 'countryisocode'), (178, 'CG', 82, 'countryisocode'), (180, 'CD', 83, 'countryisocode'), (682, 'SA', 84, 'countryisocode'), (239, 'GS', 85, 'countryisocode'), (882, 'WS', 86, 'countryisocode'), (678, 'ST', 87, 'countryisocode'), (652, 'BL', 88, 'countryisocode'), (894, 'ZM', 89, 'countryisocode'), (666, 'PM', 90, 'countryisocode'), (674, 'SM', 91, 'countryisocode'), (663, 'MF', 92, 'countryisocode'), (694, 'SL', 93, 'countryisocode'), (262, 'DJ', 94, 'countryisocode'), (292, 'GI', 95, 'countryisocode'), (832, 'JE', 96, 'countryisocode'), (388, 'JM', 97, 'countryisocode'), (760, 'SY', 98, 'countryisocode'), (702, 'SG', 99, 'countryisocode'), (534, 'SX', 100, 'countryisocode'), (716, 'ZW', 101, 'countryisocode'), (756, 'CH', 102, 'countryisocode'), (752, 'SE', 103, 'countryisocode'), (729, 'SD', 104, 'countryisocode'), (744, 'SJ', 105, 'countryisocode'), (724, 'ES', 106, 'countryisocode'), (740, 'SR', 107, 'countryisocode'), (144, 'LK', 108, 'countryisocode'), (703, 'SK', 109, 'countryisocode'), (705, 'SI', 110, 'countryisocode'), (748, 'SZ', 111, 'countryisocode'), (690, 'SC', 112, 'countryisocode'), (226, 'GQ', 113, 'countryisocode'), (686, 'SN', 114, 'countryisocode'), (688, 'RS', 115, 'countryisocode'), (659, 'KN', 116, 'countryisocode'), (670, 'VC', 117, 'countryisocode'), (426, 'LS', 118, 'countryisocode'), (654, 'SH', 119, 'countryisocode'), (662, 'LC', 120, 'countryisocode'), (706, 'SO', 121, 'countryisocode'), (90, 'SB', 122, 'countryisocode'), (796, 'TC', 123, 'countryisocode'), (764, 'TH', 124, 'countryisocode'), (410, 'KR', 125, 'countryisocode'), (158, 'TW', 126, 'countryisocode'), (762, 'TJ', 127, 'countryisocode'), (834, 'TZ', 128, 'countryisocode'), (203, 'CZ', 129, 'countryisocode'), (148, 'TD', 130, 'countryisocode'), (140, 'CF', 131, 'countryisocode'), (156, 'CN', 132, 'countryisocode'), (788, 'TN', 133, 'countryisocode'), (408, 'KP', 134, 'countryisocode'), (152, 'CL', 135, 'countryisocode'), (798, 'TV', 136, 'countryisocode'), (208, 'DK', 137, 'countryisocode'), (276, 'DE', 138, 'countryisocode'), (768, 'TG', 139, 'countryisocode'), (772, 'TK', 140, 'countryisocode'), (214, 'DO', 141, 'countryisocode'), (212, 'DM', 142, 'countryisocode'), (780, 'TT', 143, 'countryisocode'), (795, 'TM', 144, 'countryisocode'), (792, 'TR', 145, 'countryisocode'), (776, 'TO', 146, 'countryisocode'), (566, 'NG', 147, 'countryisocode'), (520, 'NR', 148, 'countryisocode'), (516, 'NA', 149, 'countryisocode'), (10, 'AQ', 150, 'countryisocode'), (570, 'NU', 151, 'countryisocode'), (558, 'NI', 152, 'countryisocode'), (562, 'NE', 153, 'countryisocode'), (392, 'JP', 154, 'countryisocode'), (732, 'EH', 155, 'countryisocode'), (540, 'NC', 156, 'countryisocode'), (554, 'NZ', 157, 'countryisocode'), (524, 'NP', 158, 'countryisocode'), (574, 'NF', 159, 'countryisocode'), (578, 'NO', 160, 'countryisocode'), (334, 'HM', 161, 'countryisocode'), (48, 'BH', 162, 'countryisocode'), (332, 'HT', 163, 'countryisocode'), (586, 'PK', 164, 'countryisocode'), (336, 'VA', 165, 'countryisocode'), (591, 'PA', 166, 'countryisocode'), (548, 'VU', 167, 'countryisocode'), (44, 'BS', 168, 'countryisocode'), (598, 'PG', 169, 'countryisocode'), (60, 'BM', 170, 'countryisocode'), (585, 'PW', 171, 'countryisocode'), (600, 'PY', 172, 'countryisocode'), (52, 'BB', 173, 'countryisocode'), (275, 'PS', 174, 'countryisocode'), (348, 'HU', 175, 'countryisocode'), (50, 'BD', 176, 'countryisocode'), (626, 'TL', 177, 'countryisocode'), (612, 'PN', 178, 'countryisocode'), (242, 'FJ', 179, 'countryisocode'), (608, 'PH', 180, 'countryisocode'), (246, 'FI', 181, 'countryisocode'), (64, 'BT', 182, 'countryisocode'), (74, 'BV', 183, 'countryisocode'), (630, 'PR', 184, 'countryisocode'), (234, 'FO', 185, 'countryisocode'), (238, 'FK', 186, 'countryisocode'), (76, 'BR', 187, 'countryisocode'), (250, 'FR', 188, 'countryisocode'), (254, 'GF', 189, 'countryisocode'), (258, 'PF', 190, 'countryisocode'), (260, 'TF', 191, 'countryisocode'), (100, 'BG', 192, 'countryisocode'), (854, 'BF', 193, 'countryisocode'), (96, 'BN', 194, 'countryisocode'), (108, 'BI', 195, 'countryisocode'), (704, 'VN', 196, 'countryisocode'), (204, 'BJ', 197, 'countryisocode'), (862, 'VE', 198, 'countryisocode'), (112, 'BY', 199, 'countryisocode'), (84, 'BZ', 200, 'countryisocode'), (604, 'PE', 201, 'countryisocode'), (56, 'BE', 202, 'countryisocode'), (616, 'PL', 203, 'countryisocode'), (70, 'BA', 204, 'countryisocode'), (72, 'BW', 205, 'countryisocode'), (535, 'BQ', 206, 'countryisocode'), (68, 'BO', 207, 'countryisocode'), (620, 'PT', 208, 'countryisocode'), (344, 'HK', 209, 'countryisocode'), (340, 'HN', 210, 'countryisocode'), (584, 'MH', 211, 'countryisocode'), (446, 'MO', 212, 'countryisocode'), (807, 'MK', 213, 'countryisocode'), (450, 'MG', 214, 'countryisocode'), (175, 'YT', 215, 'countryisocode'), (454, 'MW', 216, 'countryisocode'), (466, 'ML', 217, 'countryisocode'), (470, 'MT', 218, 'countryisocode'), (474, 'MQ', 219, 'countryisocode'), (458, 'MY', 220, 'countryisocode'), (833, 'IM', 221, 'countryisocode'), (583, 'FM', 222, 'countryisocode'), (710, 'ZA', 223, 'countryisocode'), (728, 'SS', 224, 'countryisocode'), (104, 'MM', 225, 'countryisocode'), (484, 'MX', 226, 'countryisocode'), (480, 'MU', 227, 'countryisocode'), (478, 'MR', 228, 'countryisocode'), (508, 'MZ', 229, 'countryisocode'), (492, 'MC', 230, 'countryisocode'), (462, 'MV', 231, 'countryisocode'), (498, 'MD', 232, 'countryisocode'), (504, 'MA', 233, 'countryisocode'), (496, 'MN', 234, 'countryisocode'), (499, 'ME', 235, 'countryisocode'), (500, 'MS', 236, 'countryisocode'), (400, 'JO', 237, 'countryisocode'), (418, 'LA', 238, 'countryisocode'), (428, 'LV', 239, 'countryisocode'), (440, 'LT', 240, 'countryisocode'), (434, 'LY', 241, 'countryisocode'), (438, 'LI', 242, 'countryisocode'), (430, 'LR', 243, 'countryisocode'), (642, 'RO', 244, 'countryisocode'), (442, 'LU', 245, 'countryisocode'), (646, 'RW', 246, 'countryisocode'), (422, 'LB', 247, 'countryisocode'), (638, 'RE', 248, 'countryisocode'), (643, 'RU', 249, 'countryisocode')"); + } + + public function down(Schema $schema): void + { + // 新規インストールでは up() が no-op (import_csv 投入済み) のため、 + // 一律 DELETE すると import_csv 由来の初期データまで巻き添えで消える。 + // マスタ初期データは不可逆として扱い、ロールバックでは何もしない。 + $this->throwIrreversibleMigrationException(self::NAME.' はマスタ初期データのため down は非対応です.'); + } +} diff --git a/app/DoctrineMigrations/Version20260611120000.php b/app/DoctrineMigrations/Version20260611120000.php new file mode 100644 index 00000000000..5f1d4269b0a --- /dev/null +++ b/app/DoctrineMigrations/Version20260611120000.php @@ -0,0 +1,63 @@ + + */ + private const INSERTS = [ + 'mtb_checkout_session_status' => "INSERT INTO mtb_checkout_session_status (id, name, sort_no, discriminator_type) VALUES (1, 'incomplete', 1, 'checkoutsessionstatus'), (2, 'ready', 2, 'checkoutsessionstatus'), (3, 'completed', 3, 'checkoutsessionstatus'), (4, 'canceled', 4, 'checkoutsessionstatus'), (5, 'expired', 5, 'checkoutsessionstatus')", + 'mtb_agent_protocol' => "INSERT INTO mtb_agent_protocol (id, name, sort_no, discriminator_type) VALUES (1, 'acp', 1, 'agentprotocol'), (2, 'ucp', 2, 'agentprotocol')", + ]; + + public function up(Schema $schema): void + { + foreach (self::INSERTS as $table => $sql) { + if (!$schema->hasTable($table)) { + continue; + } + + // 既にデータがある場合 (新規インストールの fixtures 投入済み等) は二重投入しない. + $count = $this->connection->fetchOne('SELECT COUNT(*) FROM '.$table); + if ($count > 0) { + continue; + } + + $this->addSql($sql); + } + } + + public function down(Schema $schema): void + { + // 新規インストールでは up() が no-op (import_csv 投入済み) のため、 + // 一律 DELETE すると import_csv 由来の初期データまで巻き添えで消える。 + // マスタ初期データは不可逆として扱い、ロールバックでは何もしない。 + $this->throwIrreversibleMigrationException('エージェントコマースの区分値マスタはマスタ初期データのため down は非対応です.'); + } +} diff --git a/app/DoctrineMigrations/Version20260617120000.php b/app/DoctrineMigrations/Version20260617120000.php new file mode 100644 index 00000000000..892ae66f294 --- /dev/null +++ b/app/DoctrineMigrations/Version20260617120000.php @@ -0,0 +1,69 @@ + + */ + private const ROWS = [ + [6, 'requires_action'], + [7, 'in_progress'], + ]; + + public function up(Schema $schema): void + { + if (!$schema->hasTable('mtb_checkout_session_status')) { + return; + } + + foreach (self::ROWS as [$id, $name]) { + $exists = $this->connection->fetchOne( + 'SELECT COUNT(*) FROM mtb_checkout_session_status WHERE id = :id', + ['id' => $id] + ); + if ($exists > 0) { + continue; + } + + $this->addSql( + 'INSERT INTO mtb_checkout_session_status (id, name, sort_no, discriminator_type) VALUES (:id, :name, :sort_no, :discriminator_type)', + ['id' => $id, 'name' => $name, 'sort_no' => $id, 'discriminator_type' => 'checkoutsessionstatus'] + ); + } + } + + public function down(Schema $schema): void + { + // マスタ初期データは不可逆として扱い、ロールバックでは何もしない + // (一律 DELETE すると import_csv 由来の初期データまで巻き添えになるため)。 + $this->throwIrreversibleMigrationException('エージェントコマースの区分値マスタはマスタ初期データのため down は非対応です.'); + } +} diff --git a/app/config/eccube/packages/eccube.yaml b/app/config/eccube/packages/eccube.yaml index 0b4a38f45d5..956bd3d4595 100644 --- a/app/config/eccube/packages/eccube.yaml +++ b/app/config/eccube/packages/eccube.yaml @@ -83,6 +83,11 @@ parameters: eccube_default_page_count: 50 eccube_admin_product_stock_status: 3 eccube_customer_reset_expire: 10 + # エージェントチェックアウトの追加対応 (3DS/escalation) / 非同期決済の待機中に在庫を確保する期限 (分). + # この間 prepare で引き当てた在庫は他購入者に対し確保されたまま保持される。超過すると + # 期限切れ回収 (CheckoutSessionRepository::findExpired) で rollback され在庫が戻る。 + # EMV-3DS のタイムアウト (10 分) より大きい値とすること。 + eccube_agent_checkout_escalation_expire: 15 # CSVの区切り文字(タブ区切りにしたい場合は'\t'ではなく' 'で設定する eccube_csv_export_separator: , # 出力エンコーディング diff --git a/app/config/eccube/services.yaml b/app/config/eccube/services.yaml index ebdebaa9853..c4a96c76196 100644 --- a/app/config/eccube/services.yaml +++ b/app/config/eccube/services.yaml @@ -237,3 +237,56 @@ services: eccube.asset.user_data_version_strategy: alias: Eccube\Asset\FilemtimeVersionStrategy public: true + + # Agent Commerce (ACP / UCP) 共通基盤 + Eccube\Service\AgentCommerce\Security\FilesystemKeyStore: + arguments: + $projectDir: '%kernel.project_dir%' + $envPathOverrides: + ucp_signing: '%env(default::string:ECCUBE_AGENT_COMMERCE_UCP_SIGNING_KEY)%' + + Eccube\Service\AgentCommerce\Security\KeyStoreInterface: + alias: Eccube\Service\AgentCommerce\Security\FilesystemKeyStore + + Eccube\Service\AgentCommerce\Security\AgentCommerceMessageSignerInterface: + alias: Eccube\Service\AgentCommerce\Security\UcpMessageSigner + + # Agent Commerce CheckoutSession 中核 (#6777 Phase 1b) + Eccube\Service\AgentCommerce\Fulfillment\FulfillmentOptionMapperInterface: + alias: Eccube\Service\AgentCommerce\Fulfillment\StandardFulfillmentOptionMapper + + # 標準はゲスト購入 (会員解決なし)。会員 ID 連携は eccube-api4#189 landing 後に差し替える。 + Eccube\Service\AgentCommerce\CheckoutSession\CustomerResolverInterface: + alias: Eccube\Service\AgentCommerce\CheckoutSession\GuestCustomerResolver + + # OAuth2 トークン検証。リソースサーバー (eccube-api4#188) 未導入時は handler が null となり 503 を返す。 + Eccube\Service\AgentCommerce\Security\AgentCommerceOAuth2Authenticator: + arguments: + $accessTokenHandler: '@?Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface' + + # Agent Commerce 決済ハンドラ (#6574 UCP / #6776 ACP) + # 具象ハンドラは決済プラグインが agent_commerce.payment_handler タグで寄与する。 + _instanceof: + Eccube\Service\AgentCommerce\Payment\AgentCheckoutPaymentHandlerInterface: + tags: ['agent_commerce.payment_handler'] + + # 決済ハンドラレジストリ。具象ハンドラ (決済プラグイン) は agent_commerce.payment_handler タグで寄与する。 + Eccube\Service\AgentCommerce\Payment\AgentCheckoutPaymentHandlerRegistry: + arguments: + $handlers: !tagged_iterator agent_commerce.payment_handler + + # complete 状態機械オーケストレータ。在庫確保期限は eccube.yaml の設定値を注入。 + Eccube\Service\AgentCommerce\CheckoutSession\AgentCheckoutCompletionService: + arguments: + $escalationExpireMinutes: '%eccube_agent_checkout_escalation_expire%' + + # 冪等性記録は DB 一意制約 (dtb_agent_checkout_idempotency) で管理する (EM/Repository は autowire)。 + + # UCP インバウンド署名検証 (RFC 9421・api4 非依存)。許可ドメイン/必須化は運用で設定する。 + Eccube\Service\AgentCommerce\Ucp\Signature\UcpRequestSignatureVerifier: + arguments: + $allowedDomains: [] + + Eccube\Service\AgentCommerce\Ucp\Signature\UcpSignatureSubscriber: + arguments: + $requireSignature: false diff --git a/app/config/eccube/services_test.yaml b/app/config/eccube/services_test.yaml index ea2133e30e6..33333dbd5fb 100644 --- a/app/config/eccube/services_test.yaml +++ b/app/config/eccube/services_test.yaml @@ -77,3 +77,53 @@ services: Eccube\Service\Composer\ComposerApiService: autowire: true public: true + # AddressMappingService はまだ consumer が無く private では除去されるためテスト時のみ public 化 + Eccube\Service\AgentCommerce\AddressMappingService: + autowire: true + public: true + # CheckoutSession 中核 (#6777 Phase 1b) も consumer (checkout controller) が #6776/#6574 まで + # 無いため、テスト時のみ public 化してコンテナから取得する。 + Eccube\Service\AgentCommerce\AgentCheckoutPurchaseFlowAdapter: + autowire: true + public: true + # 明示定義により _defaults の $shoppingPurchaseFlow bind が外れるため、ここで再束縛する。 + arguments: + $shoppingPurchaseFlow: '@eccube.purchase.flow.shopping' + Eccube\Service\AgentCommerce\Fulfillment\StandardFulfillmentOptionMapper: + autowire: true + public: true + Eccube\Service\AgentCommerce\CheckoutSession\GuestCustomerResolver: + autowire: true + public: true + # UCP checkout (#6574) サービス群。テストからコンテナ取得するため public 化する。 + Eccube\Service\AgentCommerce\Ucp\UcpCheckoutSessionMapper: + autowire: true + public: true + Eccube\Service\AgentCommerce\Ucp\UcpMessageMapper: + autowire: true + public: true + Eccube\Service\AgentCommerce\Ucp\UcpStatusMapper: + autowire: true + public: true + Eccube\Service\AgentCommerce\Ucp\Signature\Rfc9421SignatureBaseBuilder: + autowire: true + public: true + Eccube\Service\AgentCommerce\Ucp\Signature\UcpProfileFetcher: + autowire: true + public: true + Eccube\Service\AgentCommerce\Ucp\Signature\UcpRequestSignatureVerifier: + autowire: true + public: true + arguments: + $allowedDomains: [] + Eccube\Service\AgentCommerce\Idempotency\AgentCheckoutIdempotencyStore: + autowire: true + public: true + Eccube\Service\AgentCommerce\Payment\AgentCheckoutPaymentHandlerRegistry: + autowire: true + public: true + arguments: + $handlers: !tagged_iterator agent_commerce.payment_handler + Eccube\Service\AgentCommerce\StorefrontUrlResolver: + autowire: true + public: true diff --git a/app/keystore/.gitkeep b/app/keystore/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/app/keystore/.htaccess b/app/keystore/.htaccess new file mode 100644 index 00000000000..baa56e5a369 --- /dev/null +++ b/app/keystore/.htaccess @@ -0,0 +1,2 @@ +order allow,deny +deny from all \ No newline at end of file diff --git a/composer.json b/composer.json index fc737569d9d..f2f875e3b99 100644 --- a/composer.json +++ b/composer.json @@ -71,6 +71,7 @@ "symfony/flex": "^2.7", "symfony/form": "^7.4", "symfony/framework-bundle": "^7.4", + "symfony/http-client": "^7.4", "symfony/http-foundation": "^7.4", "symfony/http-kernel": "^7.4", "symfony/intl": "^7.4", diff --git a/composer.lock b/composer.lock index ac85cf5fffc..688514724ca 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "98f03b30b0f09f78545a7ae3f43316e9", + "content-hash": "e1647bfa3f3b5c8f5aa869fdcc3a6561", "packages": [ { "name": "carbonphp/carbon-doctrine-types", @@ -7539,6 +7539,189 @@ ], "time": "2026-03-30T12:55:43+00:00" }, + { + "name": "symfony/http-client", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client.git", + "reference": "e8a112b8415707265a7e614278136a9d92989a6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client/zipball/e8a112b8415707265a7e614278136a9d92989a6a", + "reference": "e8a112b8415707265a7e614278136a9d92989a6a", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-client-contracts": "~3.4.4|^3.5.2", + "symfony/polyfill-php83": "^1.29", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "amphp/amp": "<2.5", + "amphp/socket": "<1.1", + "php-http/discovery": "<1.15", + "symfony/http-foundation": "<6.4" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "1.0", + "symfony/http-client-implementation": "3.0" + }, + "require-dev": { + "amphp/http-client": "^4.2.1|^5.0", + "amphp/http-tunnel": "^1.0|^2.0", + "guzzlehttp/promises": "^1.4|^2.0", + "nyholm/psr7": "^1.0", + "php-http/httplug": "^1.0|^2.0", + "psr/http-client": "^1.0", + "symfony/amphp-http-client-meta": "^1.0|^2.0", + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously", + "homepage": "https://symfony.com", + "keywords": [ + "http" + ], + "support": { + "source": "https://github.com/symfony/http-client/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-24T09:57:54+00:00" + }, + { + "name": "symfony/http-client-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client-contracts.git", + "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d", + "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to HTTP clients", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-06T13:17:50+00:00" + }, { "name": "symfony/http-foundation", "version": "v7.4.13", diff --git a/src/Eccube/Controller/AgentCommerce/UcpCheckoutController.php b/src/Eccube/Controller/AgentCommerce/UcpCheckoutController.php new file mode 100644 index 00000000000..e5083c32ab2 --- /dev/null +++ b/src/Eccube/Controller/AgentCommerce/UcpCheckoutController.php @@ -0,0 +1,439 @@ + 201 + * - GET /ucp/checkout-sessions/{id} (get) -> 200 + * - PUT /ucp/checkout-sessions/{id} (update) -> 200 (全置換) + * - POST /ucp/checkout-sessions/{id}/complete -> 200 (order) + * - POST /ucp/checkout-sessions/{id}/cancel -> 200 (canceled) + * + * 設計: + * - セッションレスなエージェント向けに {@link CheckoutSession} で Cart/Order を束ねる。 + * - 見積/確定は通常購入と同一の shopping flow を {@link AgentCheckoutPurchaseFlowAdapter} 経由で再利用。 + * - **2 系統エラー**: プロトコル系は HTTP 4xx/5xx + Error、ビジネス系は HTTP 200 + messages[]。 + * - インバウンド認証は RFC 9421 署名 (UcpSignatureSubscriber が適用、api4 非依存)。 + * - `ucp_checkout_enabled` が false のときは NotFoundHttpException (ルートは削除しない既存パターン)。 + * + * @see https://github.com/EC-CUBE/ec-cube/issues/6574 + * @see https://github.com/Universal-Commerce-Protocol/ucp UCP checkout-rest.md + */ +class UcpCheckoutController extends AbstractController +{ + public function __construct( + private readonly BaseInfoRepository $baseInfoRepository, + private readonly CheckoutSessionRepository $checkoutSessionRepository, + private readonly AgentProtocolRepository $agentProtocolRepository, + private readonly CheckoutSessionStatusRepository $statusRepository, + private readonly AgentCheckoutPurchaseFlowAdapter $purchaseFlowAdapter, + private readonly UcpCheckoutSessionMapper $mapper, + private readonly UcpMessageMapper $messageMapper, + private readonly AgentCheckoutIdempotencyStore $idempotencyStore, + private readonly AgentCheckoutPaymentHandlerRegistry $paymentHandlerRegistry, + private readonly CustomerResolverInterface $customerResolver, + private readonly AgentCheckoutCompletionService $completionService, + ) { + } + + #[Route(path: '/ucp/checkout-sessions', name: 'ucp_checkout_create', methods: ['POST'])] + public function create(Request $request): JsonResponse + { + $this->assertEnabled(); + + return $this->withIdempotency($request, function () use ($request): array { + try { + $payload = $this->decodeBody($request); + $checkoutRequest = $this->mapper->toCheckoutRequest($payload, $this->resolveAgentId($request)); + + $session = (new CheckoutSession()) + ->setSessionId($this->generateSessionId()) + ->setProtocol($this->agentProtocolRepository->find(AgentProtocol::UCP)) + ->setStatus($this->findStatus(CheckoutSessionStatus::INCOMPLETE)) + ->setCurrencyCode($checkoutRequest->currencyCode) + ->setMetadata(['line_items' => $this->lineItemsMetadata($checkoutRequest)]); + + return ['status' => 201, 'body' => $this->buildAndPersist($session, $checkoutRequest)]; + } catch (AgentCheckoutException $e) { + return $this->protocolError(422, $e->getErrorCode()->value, $e->getMessage()); + } + }); + } + + #[Route(path: '/ucp/checkout-sessions/{sessionId}', name: 'ucp_checkout_get', methods: ['GET'])] + public function get(string $sessionId): JsonResponse + { + $this->assertEnabled(); + $session = $this->findSession($sessionId); + + $order = $session->getOrder(); + if ($order !== null) { + return new JsonResponse($this->mapper->buildResponseFromOrder($session, $order, []), Response::HTTP_OK); + } + + return new JsonResponse($this->mapper->buildProvisionalResponse($session, $this->provisionalRequestFromSession($session), []), Response::HTTP_OK); + } + + #[Route(path: '/ucp/checkout-sessions/{sessionId}', name: 'ucp_checkout_update', methods: ['PUT'])] + public function update(Request $request, string $sessionId): JsonResponse + { + $this->assertEnabled(); + $session = $this->findSession($sessionId); + + return $this->withIdempotency($request, function () use ($request, $session): array { + try { + $payload = $this->decodeBody($request); + $checkoutRequest = $this->mapper->toCheckoutRequest($payload, $this->resolveAgentId($request)); + + // 全置換セマンティクス: 明細スナップショットを差し替える。 + $session->setMetadata(array_merge($session->getMetadata() ?? [], ['line_items' => $this->lineItemsMetadata($checkoutRequest)])); + + return ['status' => 200, 'body' => $this->buildAndPersist($session, $checkoutRequest)]; + } catch (AgentCheckoutException $e) { + return $this->protocolError(422, $e->getErrorCode()->value, $e->getMessage()); + } + }); + } + + #[Route(path: '/ucp/checkout-sessions/{sessionId}/complete', name: 'ucp_checkout_complete', methods: ['POST'])] + public function complete(Request $request, string $sessionId): JsonResponse + { + $this->assertEnabled(); + $session = $this->findSession($sessionId); + + return $this->withIdempotency($request, function () use ($request, $session): array { + $order = $session->getOrder(); + if ($order === null) { + // Order が未構築のセッションは確定不能 (プロトコル系エラー 4xx)。 + return $this->protocolError(422, 'invalid_session_state', 'The checkout session is not ready for completion.'); + } + + try { + $payload = $this->decodeBody($request); + // UCP は payment.instruments[].handler_id で決済ハンドラを解決し、クレデンシャルを + // ゲートウェイトークンへ交換する。交換後の中立データを状態機械へ渡す。 + $paymentData = $this->resolvePaymentData($payload); + // complete は「中断→再開」状態機械 (#6777)。追加認証 (3DS/escalation) は + // エラーでなく requires_action 等の中間状態として返る。在庫引当の保持/回収・ + // トランザクション境界・与信→売上→確定の順序は CompletionService が担う。 + $result = $this->completionService->complete($session, $paymentData); + } catch (AgentCheckoutException $e) { + return $this->protocolError(422, $e->getErrorCode()->value, $e->getMessage()); + } + + $ucpMessages = $this->messageMapper->toUcpMessages($result->messages); + $continueUrl = $this->continueUrlFor($session, $result); + + return ['status' => 200, 'body' => $this->mapper->buildResponseFromOrder($session, $order, $ucpMessages, $continueUrl)]; + }); + } + + #[Route(path: '/ucp/checkout-sessions/{sessionId}/cancel', name: 'ucp_checkout_cancel', methods: ['POST'])] + public function cancel(Request $request, string $sessionId): JsonResponse + { + $this->assertEnabled(); + $session = $this->findSession($sessionId); + + return $this->withIdempotency($request, function () use ($session): array { + if ($session->getStatus()?->getId() === CheckoutSessionStatus::COMPLETED) { + return $this->protocolError(422, 'invalid_session_state', 'A completed checkout session cannot be canceled.'); + } + + $session->setStatus($this->findStatus(CheckoutSessionStatus::CANCELED)); + $this->entityManager->flush(); + + $order = $session->getOrder(); + $body = $order !== null + ? $this->mapper->buildResponseFromOrder($session, $order, []) + : $this->mapper->buildProvisionalResponse($session, $this->provisionalRequestFromSession($session), []); + + return ['status' => 200, 'body' => $body]; + }); + } + + /** + * 中立リクエストからセッションを確定 (見積) し、レスポンスボディを返す. + * + * 住所未確定なら暫定見積 + incomplete、住所ありなら shopping flow で再計算し ready/incomplete。 + * + * @return array + */ + private function buildAndPersist(CheckoutSession $session, AgentCheckoutRequest $checkoutRequest): array + { + if ($checkoutRequest->buyer === null) { + $session->setStatus($this->findStatus(CheckoutSessionStatus::INCOMPLETE)); + $this->entityManager->persist($session); + $this->entityManager->flush(); + + $messages = [[ + 'type' => 'error', + 'severity' => 'recoverable', + 'content' => 'Shipping address is required to calculate shipping and complete checkout.', + 'content_type' => 'plain', + ]]; + + return $this->mapper->buildProvisionalResponse($session, $checkoutRequest, $messages); + } + + $result = $this->purchaseFlowAdapter->buildOrder($checkoutRequest, $this->customerResolver->resolve($session)); + $order = $result->order; + + $session + ->setOrder($order) + ->setBuyerData($this->buyerData($checkoutRequest->buyer)) + ->setStatus($this->findStatus($result->hasError() ? CheckoutSessionStatus::INCOMPLETE : CheckoutSessionStatus::READY)); + $this->entityManager->persist($session); + $this->entityManager->flush(); + + return $this->mapper->buildResponseFromOrder($session, $order, $this->messageMapper->toUcpMessages($result->messages)); + } + + /** + * payment.instruments[].handler_id で UCP 決済ハンドラを解決し、クレデンシャルをゲートウェイ + * トークンへ交換した中立データを返す. + * + * 与信/売上 (authorize/capture) は状態機械 (CompletionService) が実行するため、ここでは交換のみ行う。 + * ハンドラ未登録 (代引・無償等) の場合は空配列を返し、状態機械が与信不要として確定する。 + * + * @param array $payload + * + * @return array + */ + private function resolvePaymentData(array $payload): array + { + $instrument = $payload['payment']['instruments'][0] ?? null; + if (!is_array($instrument)) { + return []; + } + + $handlerId = is_string($instrument['handler_id'] ?? null) ? $instrument['handler_id'] : null; + if ($handlerId === null) { + return []; + } + + $handler = $this->paymentHandlerRegistry->resolveUcpByHandlerId($handlerId); + if ($handler === null) { + return []; + } + + $credential = is_array($instrument['credential'] ?? null) ? $instrument['credential'] : []; + + return $handler->exchangePaymentToken($credential); + } + + /** + * requires_action (escalation) のとき buyer ハンドオフ用の continue_url を解決する. + * + * UCP では `requires_escalation` 時に continue_url が MUST。決済ハンドラが actionData で + * 提供すればそれを優先し、無ければセッション取得 URL を絶対 HTTPS で生成する + * (`continue_url` は絶対 HTTPS URL でなければならない)。それ以外の状態では null。 + */ + private function continueUrlFor(CheckoutSession $session, AgentCheckoutCompletionResult $result): ?string + { + if ($session->getStatus()?->getId() !== CheckoutSessionStatus::REQUIRES_ACTION) { + return null; + } + + $fromHandler = $result->actionData['continue_url'] ?? null; + if (is_string($fromHandler) && str_starts_with($fromHandler, 'https://')) { + return $fromHandler; + } + + $url = $this->generateUrl('ucp_checkout_get', ['sessionId' => $session->getSessionId()], UrlGeneratorInterface::ABSOLUTE_URL); + + // continue_url は絶対 HTTPS URL でなければならない (RequestContext が http のときも https に強制)。 + return str_starts_with($url, 'http://') ? substr_replace($url, 'https://', 0, 7) : $url; + } + + /** + * Idempotency-Key を考慮してハンドラを実行し JsonResponse を返す. + * + * @param callable(): array{status: int, body: array} $handler + */ + private function withIdempotency(Request $request, callable $handler): JsonResponse + { + $key = $request->headers->get('Idempotency-Key'); + // 認証済みエージェント (UcpSignatureSubscriber が検証時に設定) を主体として名前空間化し、 + // 別エージェントによる越境リプレイを防ぐ。 + $subject = $request->attributes->get('ucp_agent_profile'); + $subject = is_string($subject) ? $subject : null; + $requestHash = $this->idempotencyStore->hashRequest([ + 'path' => $request->getPathInfo(), + 'method' => $request->getMethod(), + 'body' => $request->getContent(), + 'agent' => $subject, + ]); + + try { + $result = $this->idempotencyStore->execute($key, $requestHash, $handler, $subject); + } catch (IdempotencyConflictException $e) { + return new JsonResponse(['code' => 'idempotency_conflict', 'content' => $e->getMessage()], Response::HTTP_CONFLICT); + } + + return new JsonResponse($result['body'], $result['status']); + } + + /** + * @return array{status: int, body: array} + */ + private function protocolError(int $status, string $code, string $content): array + { + return ['status' => $status, 'body' => ['code' => $code, 'content' => $content]]; + } + + private function assertEnabled(): void + { + if (!$this->baseInfoRepository->get()->isUcpCheckoutEnabled()) { + throw new NotFoundHttpException('UCP checkout is not enabled.'); + } + } + + private function findSession(string $sessionId): CheckoutSession + { + $session = $this->checkoutSessionRepository->findOneBySessionId($sessionId); + if ($session === null || $session->getProtocol()?->getId() !== AgentProtocol::UCP) { + // 他プロトコル/他マーチャントのセッションへの越境アクセスを遮断する。 + throw new NotFoundHttpException(sprintf('Checkout session "%s" was not found.', $sessionId)); + } + + return $session; + } + + private function findStatus(int $id): CheckoutSessionStatus + { + /** @var CheckoutSessionStatus $status */ + $status = $this->statusRepository->find($id); + + return $status; + } + + /** + * @return array + */ + private function decodeBody(Request $request): array + { + $content = $request->getContent(); + if ($content === '') { + return []; + } + + try { + $decoded = json_decode($content, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new AgentCheckoutException(AgentCheckoutErrorCode::EMPTY_LINE_ITEMS, 'Malformed JSON request body.', $e); + } + + if (!is_array($decoded)) { + return []; + } + + // JSON オブジェクトのキーを文字列に正規化する (トップレベルが配列のとき int キーになり得る)。 + $normalized = []; + foreach ($decoded as $key => $value) { + $normalized[(string) $key] = $value; + } + + return $normalized; + } + + private function generateSessionId(): string + { + return 'ucp_cs_'.bin2hex(random_bytes(16)); + } + + private function resolveAgentId(Request $request): ?string + { + // 署名検証済みのエージェント profile URL があれば agent_id として用いる。 + $verified = $request->attributes->get('ucp_agent_profile'); + + return is_string($verified) ? $verified : $request->headers->get('UCP-Agent'); + } + + /** + * @return list + */ + private function lineItemsMetadata(AgentCheckoutRequest $checkoutRequest): array + { + return array_map( + static fn (AgentCheckoutLineItem $item): array => ['pc' => $item->productClassId, 'qty' => $item->quantity], + $checkoutRequest->lineItems + ); + } + + /** + * GET/cancel で Order 未生成のセッションを暫定表示するため、metadata から明細を復元する. + */ + private function provisionalRequestFromSession(CheckoutSession $session): AgentCheckoutRequest + { + $lineItems = []; + $stored = $session->getMetadata()['line_items'] ?? []; + if (is_array($stored)) { + foreach ($stored as $entry) { + if (is_array($entry) && isset($entry['pc'], $entry['qty'])) { + $lineItems[] = new AgentCheckoutLineItem((int) $entry['pc'], (int) $entry['qty']); + } + } + } + + return new AgentCheckoutRequest($lineItems, null, $session->getCurrencyCode(), AgentProtocol::UCP); + } + + /** + * 中立住所 DTO を buyer_data (echo 用) へ写す. + * + * @return array + */ + private function buyerData(AgentCheckoutAddress $address): array + { + return array_filter([ + 'family_name' => $address->name01, + 'given_name' => $address->name02, + 'email' => $address->email, + 'phone' => $address->phoneNumber, + ], static fn ($value): bool => $value !== null); + } +} diff --git a/src/Eccube/Entity/AgentCheckoutIdempotency.php b/src/Eccube/Entity/AgentCheckoutIdempotency.php new file mode 100644 index 00000000000..93941552972 --- /dev/null +++ b/src/Eccube/Entity/AgentCheckoutIdempotency.php @@ -0,0 +1,190 @@ + true])] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'IDENTITY')] + private ?int $id = null; + + /** + * エージェントが指定した Idempotency-Key. + */ + #[ORM\Column(name: 'idempotency_key', type: Types::STRING, length: 255)] + private string $idempotency_key = ''; + + /** + * 主体 (認証済みエージェント識別子)。未認証は空文字. + */ + #[ORM\Column(name: 'subject', type: Types::STRING, length: 255)] + private string $subject = ''; + + /** + * リクエスト内容のハッシュ (同一キーの異パラメータ再利用検知用). + */ + #[ORM\Column(name: 'request_hash', type: Types::STRING, length: 255)] + private string $request_hash = ''; + + /** + * 保存済みレスポンスの HTTP ステータス (処理中は NULL). + */ + #[ORM\Column(name: 'response_status', type: Types::INTEGER, nullable: true)] + private ?int $response_status = null; + + /** + * 保存済みレスポンスボディ (処理中は NULL). + * + * @var array|null + */ + #[ORM\Column(name: 'response_body', type: Types::JSON, nullable: true)] + private ?array $response_body = null; + + #[ORM\Column(name: 'create_date', type: Types::DATETIMETZ_MUTABLE)] + private \DateTime $create_date; + + #[ORM\Column(name: 'update_date', type: Types::DATETIMETZ_MUTABLE)] + private \DateTime $update_date; + + public function getId(): ?int + { + return $this->id; + } + + public function getIdempotencyKey(): string + { + return $this->idempotency_key; + } + + public function setIdempotencyKey(string $idempotency_key): self + { + $this->idempotency_key = $idempotency_key; + + return $this; + } + + public function getSubject(): string + { + return $this->subject; + } + + public function setSubject(string $subject): self + { + $this->subject = $subject; + + return $this; + } + + public function getRequestHash(): string + { + return $this->request_hash; + } + + public function setRequestHash(string $request_hash): self + { + $this->request_hash = $request_hash; + + return $this; + } + + public function getResponseStatus(): ?int + { + return $this->response_status; + } + + public function setResponseStatus(?int $response_status = null): self + { + $this->response_status = $response_status; + + return $this; + } + + /** + * @return array|null + */ + public function getResponseBody(): ?array + { + return $this->response_body; + } + + /** + * @param array|null $response_body + */ + public function setResponseBody(?array $response_body = null): self + { + $this->response_body = $response_body; + + return $this; + } + + public function getCreateDate(): ?\DateTime + { + return $this->create_date ?? null; + } + + public function setCreateDate(\DateTime $create_date): self + { + $this->create_date = $create_date; + + return $this; + } + + public function getUpdateDate(): ?\DateTime + { + return $this->update_date ?? null; + } + + public function setUpdateDate(\DateTime $update_date): self + { + $this->update_date = $update_date; + + return $this; + } + + /** + * レスポンスが確定済か (処理中=予約のみは false). + */ + public function hasResponse(): bool + { + return $this->response_status !== null; + } + } +} diff --git a/src/Eccube/Entity/BaseInfo.php b/src/Eccube/Entity/BaseInfo.php index 042253f5157..b5e3fd92f66 100644 --- a/src/Eccube/Entity/BaseInfo.php +++ b/src/Eccube/Entity/BaseInfo.php @@ -154,6 +154,15 @@ class BaseInfo extends AbstractEntity #[ORM\Column(name: 'ga_id', type: Types::STRING, length: 255, nullable: true)] private ?string $gaId = null; + #[ORM\Column(name: 'acp_checkout_enabled', type: Types::BOOLEAN, options: ['default' => false])] + private bool $acp_checkout_enabled = false; + + #[ORM\Column(name: 'ucp_checkout_enabled', type: Types::BOOLEAN, options: ['default' => false])] + private bool $ucp_checkout_enabled = false; + + #[ORM\Column(name: 'ucp_catalog_requires_auth', type: Types::BOOLEAN, options: ['default' => false])] + private bool $ucp_catalog_requires_auth = false; + /** * Get id. * @@ -831,5 +840,59 @@ public function getGaId(): ?string { return $this->gaId; } + + /** + * Set acpCheckoutEnabled. + */ + public function setAcpCheckoutEnabled(bool $acpCheckoutEnabled): BaseInfo + { + $this->acp_checkout_enabled = $acpCheckoutEnabled; + + return $this; + } + + /** + * Get acpCheckoutEnabled. + */ + public function isAcpCheckoutEnabled(): bool + { + return $this->acp_checkout_enabled; + } + + /** + * Set ucpCheckoutEnabled. + */ + public function setUcpCheckoutEnabled(bool $ucpCheckoutEnabled): BaseInfo + { + $this->ucp_checkout_enabled = $ucpCheckoutEnabled; + + return $this; + } + + /** + * Get ucpCheckoutEnabled. + */ + public function isUcpCheckoutEnabled(): bool + { + return $this->ucp_checkout_enabled; + } + + /** + * Set ucpCatalogRequiresAuth. + */ + public function setUcpCatalogRequiresAuth(bool $ucpCatalogRequiresAuth): BaseInfo + { + $this->ucp_catalog_requires_auth = $ucpCatalogRequiresAuth; + + return $this; + } + + /** + * Get ucpCatalogRequiresAuth. + */ + public function isUcpCatalogRequiresAuth(): bool + { + return $this->ucp_catalog_requires_auth; + } } } diff --git a/src/Eccube/Entity/Cart.php b/src/Eccube/Entity/Cart.php index ce85c4b14d3..a19ba10e541 100644 --- a/src/Eccube/Entity/Cart.php +++ b/src/Eccube/Entity/Cart.php @@ -45,6 +45,16 @@ class Cart extends AbstractEntity implements PurchaseInterface, ItemHolderInterf #[ORM\Column(name: 'cart_key', type: Types::STRING, nullable: true)] private ?string $cart_key = null; + /** + * エージェントコマース (CheckoutSession) が所有するカートか. + * + * true のカートは Web ストアフロント (CartService) のカート解決から除外され、 + * ログイン会員のカートマージ・再計算・購入完了等で操作されない。 + * 通常購入のカートは常に false。 + */ + #[ORM\Column(name: 'agent_owned', type: Types::BOOLEAN, options: ['default' => false])] + private bool $agent_owned = false; + #[ORM\ManyToOne(targetEntity: Customer::class)] #[ORM\JoinColumn(name: 'customer_id', referencedColumnName: 'id')] private ?Customer $Customer = null; @@ -125,6 +135,18 @@ public function setCartKey(string $cartKey): Cart return $this; } + public function isAgentOwned(): bool + { + return $this->agent_owned; + } + + public function setAgentOwned(bool $agentOwned): Cart + { + $this->agent_owned = $agentOwned; + + return $this; + } + /** * @deprecated 使用しないので削除予定 */ diff --git a/src/Eccube/Entity/CheckoutSession.php b/src/Eccube/Entity/CheckoutSession.php new file mode 100644 index 00000000000..626836db8cf --- /dev/null +++ b/src/Eccube/Entity/CheckoutSession.php @@ -0,0 +1,366 @@ + true])] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'IDENTITY')] + private ?int $id = null; + + /** + * エージェントへ公開するセッション識別子 (推測困難・API パスで使用). + * + * 整数 PK は列挙可能なため、外部公開用にはこの不透明な識別子を用いる。 + */ + #[ORM\Column(name: 'session_id', type: Types::STRING, length: 255)] + private string $session_id = ''; + + /** + * プロトコル種別 (ACP / UCP) マスタへの参照. + */ + #[ORM\ManyToOne(targetEntity: AgentProtocol::class)] + #[ORM\JoinColumn(name: 'agent_protocol_id', referencedColumnName: 'id')] + private ?AgentProtocol $Protocol = null; + + /** + * セッションを開始したエージェントの識別子. + */ + #[ORM\Column(name: 'agent_id', type: Types::STRING, length: 255, nullable: true)] + private ?string $agent_id = null; + + /** + * 正規化ステータス (incomplete/ready/completed/canceled/expired) マスタへの参照. + */ + #[ORM\ManyToOne(targetEntity: CheckoutSessionStatus::class)] + #[ORM\JoinColumn(name: 'checkout_session_status_id', referencedColumnName: 'id')] + private ?CheckoutSessionStatus $Status = null; + + /** + * 通貨コード (ISO 4217 alpha-3). + */ + #[ORM\Column(name: 'currency_code', type: Types::STRING, length: 3)] + private string $currency_code = 'JPY'; + + /** + * セッションの有効期限. + */ + #[ORM\Column(name: 'expires_at', type: Types::DATETIMETZ_MUTABLE, nullable: true)] + private ?\DateTime $expires_at = null; + + /** + * 購入者情報 (氏名・住所・連絡先等) の中立表現. + * + * @var array|null + */ + #[ORM\Column(name: 'buyer_data', type: Types::JSON, nullable: true)] + private ?array $buyer_data = null; + + /** + * 配送 (fulfillment) 情報の中立表現. + * + * @var array|null + */ + #[ORM\Column(name: 'fulfillment_data', type: Types::JSON, nullable: true)] + private ?array $fulfillment_data = null; + + /** + * 支払情報の中立表現 (token はマスキング/暗号化して保持). + * + * @var array|null + */ + #[ORM\Column(name: 'payment_data', type: Types::JSON, nullable: true)] + private ?array $payment_data = null; + + /** + * プロトコル固有のメタデータ (正規化ステータスに収まらない情報を保持). + * + * @var array|null + */ + #[ORM\Column(name: 'metadata', type: Types::JSON, nullable: true)] + private ?array $metadata = null; + + /** + * 明細を保持する Cart (セッションレスに束ねる対象). + */ + #[ORM\ManyToOne(targetEntity: Cart::class)] + #[ORM\JoinColumn(name: 'cart_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')] + private ?Cart $Cart = null; + + /** + * 確定時に生成される Order (完了前は null). + */ + #[ORM\ManyToOne(targetEntity: Order::class)] + #[ORM\JoinColumn(name: 'order_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')] + private ?Order $Order = null; + + /** + * 紐づく会員 (ゲスト購入では null). + */ + #[ORM\ManyToOne(targetEntity: Customer::class)] + #[ORM\JoinColumn(name: 'customer_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')] + private ?Customer $Customer = null; + + #[ORM\Column(name: 'create_date', type: Types::DATETIMETZ_MUTABLE)] + private \DateTime $create_date; + + #[ORM\Column(name: 'update_date', type: Types::DATETIMETZ_MUTABLE)] + private \DateTime $update_date; + + public function getId(): ?int + { + return $this->id; + } + + public function getSessionId(): string + { + return $this->session_id; + } + + public function setSessionId(string $session_id): CheckoutSession + { + $this->session_id = $session_id; + + return $this; + } + + public function getProtocol(): ?AgentProtocol + { + return $this->Protocol; + } + + public function setProtocol(?AgentProtocol $Protocol = null): CheckoutSession + { + $this->Protocol = $Protocol; + + return $this; + } + + public function getAgentId(): ?string + { + return $this->agent_id; + } + + public function setAgentId(?string $agent_id = null): CheckoutSession + { + $this->agent_id = $agent_id; + + return $this; + } + + public function getStatus(): ?CheckoutSessionStatus + { + return $this->Status; + } + + public function setStatus(?CheckoutSessionStatus $Status = null): CheckoutSession + { + $this->Status = $Status; + + return $this; + } + + public function getCurrencyCode(): string + { + return $this->currency_code; + } + + public function setCurrencyCode(string $currency_code): CheckoutSession + { + $this->currency_code = $currency_code; + + return $this; + } + + public function getExpiresAt(): ?\DateTime + { + return $this->expires_at; + } + + public function setExpiresAt(?\DateTime $expires_at = null): CheckoutSession + { + $this->expires_at = $expires_at; + + return $this; + } + + /** + * @return array|null + */ + public function getBuyerData(): ?array + { + return $this->buyer_data; + } + + /** + * @param array|null $buyer_data + */ + public function setBuyerData(?array $buyer_data = null): CheckoutSession + { + $this->buyer_data = $buyer_data; + + return $this; + } + + /** + * @return array|null + */ + public function getFulfillmentData(): ?array + { + return $this->fulfillment_data; + } + + /** + * @param array|null $fulfillment_data + */ + public function setFulfillmentData(?array $fulfillment_data = null): CheckoutSession + { + $this->fulfillment_data = $fulfillment_data; + + return $this; + } + + /** + * @return array|null + */ + public function getPaymentData(): ?array + { + return $this->payment_data; + } + + /** + * @param array|null $payment_data + */ + public function setPaymentData(?array $payment_data = null): CheckoutSession + { + $this->payment_data = $payment_data; + + return $this; + } + + /** + * @return array|null + */ + public function getMetadata(): ?array + { + return $this->metadata; + } + + /** + * @param array|null $metadata + */ + public function setMetadata(?array $metadata = null): CheckoutSession + { + $this->metadata = $metadata; + + return $this; + } + + public function getCart(): ?Cart + { + return $this->Cart; + } + + public function setCart(?Cart $Cart = null): CheckoutSession + { + $this->Cart = $Cart; + + return $this; + } + + public function getOrder(): ?Order + { + return $this->Order; + } + + public function setOrder(?Order $Order = null): CheckoutSession + { + $this->Order = $Order; + + return $this; + } + + public function getCustomer(): ?Customer + { + return $this->Customer; + } + + public function setCustomer(?Customer $Customer = null): CheckoutSession + { + $this->Customer = $Customer; + + return $this; + } + + public function getCreateDate(): ?\DateTime + { + return $this->create_date ?? null; + } + + public function setCreateDate(\DateTime $create_date): CheckoutSession + { + $this->create_date = $create_date; + + return $this; + } + + public function getUpdateDate(): ?\DateTime + { + return $this->update_date ?? null; + } + + public function setUpdateDate(\DateTime $update_date): CheckoutSession + { + $this->update_date = $update_date; + + return $this; + } + + /** + * 有効期限を過ぎているか. + */ + public function isExpired(\DateTime $now): bool + { + return $this->expires_at !== null && $this->expires_at <= $now; + } + } +} diff --git a/src/Eccube/Entity/Master/AgentProtocol.php b/src/Eccube/Entity/Master/AgentProtocol.php new file mode 100644 index 00000000000..16292b49ce5 --- /dev/null +++ b/src/Eccube/Entity/Master/AgentProtocol.php @@ -0,0 +1,42 @@ + */ @@ -1177,6 +1195,30 @@ public function appendCompleteMailMessage(?string $complete_mail_message = null) return $this; } + public function getAgentProtocol(): ?AgentProtocol + { + return $this->AgentProtocol; + } + + public function setAgentProtocol(?AgentProtocol $AgentProtocol = null): Order + { + $this->AgentProtocol = $AgentProtocol; + + return $this; + } + + public function getAgentId(): ?string + { + return $this->agent_id; + } + + public function setAgentId(?string $agent_id = null): Order + { + $this->agent_id = $agent_id; + + return $this; + } + /** * 商品の受注明細を取得 * diff --git a/src/Eccube/Form/Type/Admin/ShopMasterType.php b/src/Eccube/Form/Type/Admin/ShopMasterType.php index ec5f4de8a9c..2bf342d587e 100644 --- a/src/Eccube/Form/Type/Admin/ShopMasterType.php +++ b/src/Eccube/Form/Type/Admin/ShopMasterType.php @@ -218,6 +218,9 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ]), ], ]) + // エージェントコマース checkout の有効化フラグ (discovery / catalog は常時公開、checkout のみ制御) + ->add('acp_checkout_enabled', ToggleSwitchType::class) + ->add('ucp_checkout_enabled', ToggleSwitchType::class) ; $builder->add( diff --git a/src/Eccube/Repository/AgentCheckoutIdempotencyRepository.php b/src/Eccube/Repository/AgentCheckoutIdempotencyRepository.php new file mode 100644 index 00000000000..d8da0528910 --- /dev/null +++ b/src/Eccube/Repository/AgentCheckoutIdempotencyRepository.php @@ -0,0 +1,55 @@ + + */ +class AgentCheckoutIdempotencyRepository extends AbstractRepository +{ + public function __construct(RegistryInterface $registry) + { + parent::__construct($registry, AgentCheckoutIdempotency::class); + } + + /** + * Idempotency-Key と主体で 1 件取得する. + */ + public function findOneByKeyAndSubject(string $key, string $subject): ?AgentCheckoutIdempotency + { + return $this->findOneBy(['idempotency_key' => $key, 'subject' => $subject]); + } + + /** + * 指定時刻より前に作成された記録を削除する (保管期間超過分のクリーンアップ用). + * + * クリーンアップコマンド (派生 issue) から利用する想定。 + * + * @return int 削除件数 + */ + public function deleteOlderThan(\DateTime $threshold): int + { + return (int) $this->createQueryBuilder('i') + ->delete() + ->where('i.create_date < :threshold') + ->setParameter('threshold', $threshold) + ->getQuery() + ->execute(); + } +} diff --git a/src/Eccube/Repository/CheckoutSessionRepository.php b/src/Eccube/Repository/CheckoutSessionRepository.php new file mode 100644 index 00000000000..75400f0deb9 --- /dev/null +++ b/src/Eccube/Repository/CheckoutSessionRepository.php @@ -0,0 +1,70 @@ + + */ +class CheckoutSessionRepository extends AbstractRepository +{ + public function __construct(RegistryInterface $registry) + { + parent::__construct($registry, CheckoutSession::class); + } + + /** + * エージェント公開用のセッション識別子で 1 件取得する. + */ + public function findOneBySessionId(string $sessionId): ?CheckoutSession + { + return $this->findOneBy(['session_id' => $sessionId]); + } + + /** + * 指定時刻より前に有効期限が切れた未完了セッションを取得する. + * + * クリーンアップコマンド (派生 issue) から利用する想定。 + * + * @return array + */ + public function findExpired(\DateTime $now): array + { + $qb = $this->createQueryBuilder('cs'); + + /** @var array $result */ + $result = $qb + ->where('cs.expires_at IS NOT NULL') + ->andWhere('cs.expires_at < :now') + ->andWhere($qb->expr()->orX( + 'cs.Status IS NULL', + $qb->expr()->notIn('IDENTITY(cs.Status)', ':terminalStatuses') + )) + ->setParameter('now', $now) + ->setParameter('terminalStatuses', [ + CheckoutSessionStatus::COMPLETED, + CheckoutSessionStatus::CANCELED, + CheckoutSessionStatus::EXPIRED, + ]) + ->getQuery() + ->getResult(); + + return $result; + } +} diff --git a/src/Eccube/Repository/Master/AgentProtocolRepository.php b/src/Eccube/Repository/Master/AgentProtocolRepository.php new file mode 100644 index 00000000000..d61f64cd6cc --- /dev/null +++ b/src/Eccube/Repository/Master/AgentProtocolRepository.php @@ -0,0 +1,29 @@ + + */ +class AgentProtocolRepository extends AbstractRepository +{ + public function __construct(RegistryInterface $registry) + { + parent::__construct($registry, AgentProtocol::class); + } +} diff --git a/src/Eccube/Repository/Master/CheckoutSessionStatusRepository.php b/src/Eccube/Repository/Master/CheckoutSessionStatusRepository.php new file mode 100644 index 00000000000..5cd6f8e6bbc --- /dev/null +++ b/src/Eccube/Repository/Master/CheckoutSessionStatusRepository.php @@ -0,0 +1,29 @@ + + */ +class CheckoutSessionStatusRepository extends AbstractRepository +{ + public function __construct(RegistryInterface $registry) + { + parent::__construct($registry, CheckoutSessionStatus::class); + } +} diff --git a/src/Eccube/Repository/Master/CountryIsoCodeRepository.php b/src/Eccube/Repository/Master/CountryIsoCodeRepository.php new file mode 100644 index 00000000000..149bc547b97 --- /dev/null +++ b/src/Eccube/Repository/Master/CountryIsoCodeRepository.php @@ -0,0 +1,31 @@ + + */ +class CountryIsoCodeRepository extends AbstractRepository +{ + public function __construct(RegistryInterface $registry) + { + parent::__construct($registry, CountryIsoCode::class); + } +} diff --git a/src/Eccube/Resource/doctrine/import_csv/en/definition.yml b/src/Eccube/Resource/doctrine/import_csv/en/definition.yml index bd8383c3485..588b3553fd8 100644 --- a/src/Eccube/Resource/doctrine/import_csv/en/definition.yml +++ b/src/Eccube/Resource/doctrine/import_csv/en/definition.yml @@ -1,5 +1,8 @@ +- mtb_agent_protocol.csv - mtb_authority.csv +- mtb_checkout_session_status.csv - mtb_country.csv +- mtb_country_iso_code.csv - mtb_csv_type.csv - mtb_customer_order_status.csv - mtb_customer_status.csv diff --git a/src/Eccube/Resource/doctrine/import_csv/en/dtb_base_info.csv b/src/Eccube/Resource/doctrine/import_csv/en/dtb_base_info.csv index 95a7ffd2662..2e993540906 100644 --- a/src/Eccube/Resource/doctrine/import_csv/en/dtb_base_info.csv +++ b/src/Eccube/Resource/doctrine/import_csv/en/dtb_base_info.csv @@ -1 +1 @@ -id,country_id,pref_id,company_name,company_kana,postal_code,addr01,addr02,phone_number,business_hour,email01,email02,email03,email04,shop_name,shop_kana,shop_name_eng,update_date,good_traded,message,delivery_free_amount,delivery_free_quantity,option_mypage_order_status_display,option_nostock_hidden,option_favorite_product,option_product_delivery_fee,option_product_tax_rule,option_customer_activate,option_guest_purchase,option_remember_me,option_mail_notifier,authentication_key,option_point,basic_point_rate,point_conversion_rate,discriminator_type +id,country_id,pref_id,company_name,company_kana,postal_code,addr01,addr02,phone_number,business_hour,email01,email02,email03,email04,shop_name,shop_kana,shop_name_eng,update_date,good_traded,message,delivery_free_amount,delivery_free_quantity,option_mypage_order_status_display,option_nostock_hidden,option_favorite_product,option_product_delivery_fee,option_product_tax_rule,option_customer_activate,option_guest_purchase,option_remember_me,option_mail_notifier,authentication_key,option_point,basic_point_rate,point_conversion_rate,discriminator_type,acp_checkout_enabled,ucp_checkout_enabled,ucp_catalog_requires_auth diff --git a/src/Eccube/Resource/doctrine/import_csv/en/mtb_agent_protocol.csv b/src/Eccube/Resource/doctrine/import_csv/en/mtb_agent_protocol.csv new file mode 100644 index 00000000000..575027df7fe --- /dev/null +++ b/src/Eccube/Resource/doctrine/import_csv/en/mtb_agent_protocol.csv @@ -0,0 +1,3 @@ +id,name,sort_no,discriminator_type +"1","acp","1","agentprotocol" +"2","ucp","2","agentprotocol" diff --git a/src/Eccube/Resource/doctrine/import_csv/en/mtb_checkout_session_status.csv b/src/Eccube/Resource/doctrine/import_csv/en/mtb_checkout_session_status.csv new file mode 100644 index 00000000000..5a90d4ad32d --- /dev/null +++ b/src/Eccube/Resource/doctrine/import_csv/en/mtb_checkout_session_status.csv @@ -0,0 +1,8 @@ +id,name,sort_no,discriminator_type +"1","incomplete","1","checkoutsessionstatus" +"2","ready","2","checkoutsessionstatus" +"3","completed","3","checkoutsessionstatus" +"4","canceled","4","checkoutsessionstatus" +"5","expired","5","checkoutsessionstatus" +"6","requires_action","6","checkoutsessionstatus" +"7","in_progress","7","checkoutsessionstatus" diff --git a/src/Eccube/Resource/doctrine/import_csv/en/mtb_country_iso_code.csv b/src/Eccube/Resource/doctrine/import_csv/en/mtb_country_iso_code.csv new file mode 100644 index 00000000000..941bd5c983f --- /dev/null +++ b/src/Eccube/Resource/doctrine/import_csv/en/mtb_country_iso_code.csv @@ -0,0 +1,250 @@ +id,name,sort_no,discriminator_type +"352","IS","1","countryisocode" +"372","IE","2","countryisocode" +"31","AZ","3","countryisocode" +"4","AF","4","countryisocode" +"840","US","5","countryisocode" +"850","VI","6","countryisocode" +"16","AS","7","countryisocode" +"784","AE","8","countryisocode" +"12","DZ","9","countryisocode" +"32","AR","10","countryisocode" +"533","AW","11","countryisocode" +"8","AL","12","countryisocode" +"51","AM","13","countryisocode" +"660","AI","14","countryisocode" +"24","AO","15","countryisocode" +"28","AG","16","countryisocode" +"20","AD","17","countryisocode" +"887","YE","18","countryisocode" +"826","GB","19","countryisocode" +"86","IO","20","countryisocode" +"92","VG","21","countryisocode" +"376","IL","22","countryisocode" +"380","IT","23","countryisocode" +"368","IQ","24","countryisocode" +"364","IR","25","countryisocode" +"356","IN","26","countryisocode" +"360","ID","27","countryisocode" +"876","WF","28","countryisocode" +"800","UG","29","countryisocode" +"804","UA","30","countryisocode" +"860","UZ","31","countryisocode" +"858","UY","32","countryisocode" +"218","EC","33","countryisocode" +"818","EG","34","countryisocode" +"233","EE","35","countryisocode" +"231","ET","36","countryisocode" +"232","ER","37","countryisocode" +"222","SV","38","countryisocode" +"36","AU","39","countryisocode" +"40","AT","40","countryisocode" +"248","AX","41","countryisocode" +"512","OM","42","countryisocode" +"528","NL","43","countryisocode" +"288","GH","44","countryisocode" +"132","CV","45","countryisocode" +"831","GG","46","countryisocode" +"328","GY","47","countryisocode" +"398","KZ","48","countryisocode" +"634","QA","49","countryisocode" +"581","UM","50","countryisocode" +"124","CA","51","countryisocode" +"266","GA","52","countryisocode" +"120","CM","53","countryisocode" +"270","GM","54","countryisocode" +"116","KH","55","countryisocode" +"580","MP","56","countryisocode" +"324","GN","57","countryisocode" +"624","GW","58","countryisocode" +"196","CY","59","countryisocode" +"192","CU","60","countryisocode" +"531","CW","61","countryisocode" +"300","GR","62","countryisocode" +"296","KI","63","countryisocode" +"417","KG","64","countryisocode" +"320","GT","65","countryisocode" +"312","GP","66","countryisocode" +"316","GU","67","countryisocode" +"414","KW","68","countryisocode" +"184","CK","69","countryisocode" +"304","GL","70","countryisocode" +"162","CX","71","countryisocode" +"268","GE","72","countryisocode" +"308","GD","73","countryisocode" +"191","HR","74","countryisocode" +"136","KY","75","countryisocode" +"404","KE","76","countryisocode" +"384","CI","77","countryisocode" +"166","CC","78","countryisocode" +"188","CR","79","countryisocode" +"174","KM","80","countryisocode" +"170","CO","81","countryisocode" +"178","CG","82","countryisocode" +"180","CD","83","countryisocode" +"682","SA","84","countryisocode" +"239","GS","85","countryisocode" +"882","WS","86","countryisocode" +"678","ST","87","countryisocode" +"652","BL","88","countryisocode" +"894","ZM","89","countryisocode" +"666","PM","90","countryisocode" +"674","SM","91","countryisocode" +"663","MF","92","countryisocode" +"694","SL","93","countryisocode" +"262","DJ","94","countryisocode" +"292","GI","95","countryisocode" +"832","JE","96","countryisocode" +"388","JM","97","countryisocode" +"760","SY","98","countryisocode" +"702","SG","99","countryisocode" +"534","SX","100","countryisocode" +"716","ZW","101","countryisocode" +"756","CH","102","countryisocode" +"752","SE","103","countryisocode" +"729","SD","104","countryisocode" +"744","SJ","105","countryisocode" +"724","ES","106","countryisocode" +"740","SR","107","countryisocode" +"144","LK","108","countryisocode" +"703","SK","109","countryisocode" +"705","SI","110","countryisocode" +"748","SZ","111","countryisocode" +"690","SC","112","countryisocode" +"226","GQ","113","countryisocode" +"686","SN","114","countryisocode" +"688","RS","115","countryisocode" +"659","KN","116","countryisocode" +"670","VC","117","countryisocode" +"426","LS","118","countryisocode" +"654","SH","119","countryisocode" +"662","LC","120","countryisocode" +"706","SO","121","countryisocode" +"90","SB","122","countryisocode" +"796","TC","123","countryisocode" +"764","TH","124","countryisocode" +"410","KR","125","countryisocode" +"158","TW","126","countryisocode" +"762","TJ","127","countryisocode" +"834","TZ","128","countryisocode" +"203","CZ","129","countryisocode" +"148","TD","130","countryisocode" +"140","CF","131","countryisocode" +"156","CN","132","countryisocode" +"788","TN","133","countryisocode" +"408","KP","134","countryisocode" +"152","CL","135","countryisocode" +"798","TV","136","countryisocode" +"208","DK","137","countryisocode" +"276","DE","138","countryisocode" +"768","TG","139","countryisocode" +"772","TK","140","countryisocode" +"214","DO","141","countryisocode" +"212","DM","142","countryisocode" +"780","TT","143","countryisocode" +"795","TM","144","countryisocode" +"792","TR","145","countryisocode" +"776","TO","146","countryisocode" +"566","NG","147","countryisocode" +"520","NR","148","countryisocode" +"516","NA","149","countryisocode" +"10","AQ","150","countryisocode" +"570","NU","151","countryisocode" +"558","NI","152","countryisocode" +"562","NE","153","countryisocode" +"392","JP","154","countryisocode" +"732","EH","155","countryisocode" +"540","NC","156","countryisocode" +"554","NZ","157","countryisocode" +"524","NP","158","countryisocode" +"574","NF","159","countryisocode" +"578","NO","160","countryisocode" +"334","HM","161","countryisocode" +"48","BH","162","countryisocode" +"332","HT","163","countryisocode" +"586","PK","164","countryisocode" +"336","VA","165","countryisocode" +"591","PA","166","countryisocode" +"548","VU","167","countryisocode" +"44","BS","168","countryisocode" +"598","PG","169","countryisocode" +"60","BM","170","countryisocode" +"585","PW","171","countryisocode" +"600","PY","172","countryisocode" +"52","BB","173","countryisocode" +"275","PS","174","countryisocode" +"348","HU","175","countryisocode" +"50","BD","176","countryisocode" +"626","TL","177","countryisocode" +"612","PN","178","countryisocode" +"242","FJ","179","countryisocode" +"608","PH","180","countryisocode" +"246","FI","181","countryisocode" +"64","BT","182","countryisocode" +"74","BV","183","countryisocode" +"630","PR","184","countryisocode" +"234","FO","185","countryisocode" +"238","FK","186","countryisocode" +"76","BR","187","countryisocode" +"250","FR","188","countryisocode" +"254","GF","189","countryisocode" +"258","PF","190","countryisocode" +"260","TF","191","countryisocode" +"100","BG","192","countryisocode" +"854","BF","193","countryisocode" +"96","BN","194","countryisocode" +"108","BI","195","countryisocode" +"704","VN","196","countryisocode" +"204","BJ","197","countryisocode" +"862","VE","198","countryisocode" +"112","BY","199","countryisocode" +"84","BZ","200","countryisocode" +"604","PE","201","countryisocode" +"56","BE","202","countryisocode" +"616","PL","203","countryisocode" +"70","BA","204","countryisocode" +"72","BW","205","countryisocode" +"535","BQ","206","countryisocode" +"68","BO","207","countryisocode" +"620","PT","208","countryisocode" +"344","HK","209","countryisocode" +"340","HN","210","countryisocode" +"584","MH","211","countryisocode" +"446","MO","212","countryisocode" +"807","MK","213","countryisocode" +"450","MG","214","countryisocode" +"175","YT","215","countryisocode" +"454","MW","216","countryisocode" +"466","ML","217","countryisocode" +"470","MT","218","countryisocode" +"474","MQ","219","countryisocode" +"458","MY","220","countryisocode" +"833","IM","221","countryisocode" +"583","FM","222","countryisocode" +"710","ZA","223","countryisocode" +"728","SS","224","countryisocode" +"104","MM","225","countryisocode" +"484","MX","226","countryisocode" +"480","MU","227","countryisocode" +"478","MR","228","countryisocode" +"508","MZ","229","countryisocode" +"492","MC","230","countryisocode" +"462","MV","231","countryisocode" +"498","MD","232","countryisocode" +"504","MA","233","countryisocode" +"496","MN","234","countryisocode" +"499","ME","235","countryisocode" +"500","MS","236","countryisocode" +"400","JO","237","countryisocode" +"418","LA","238","countryisocode" +"428","LV","239","countryisocode" +"440","LT","240","countryisocode" +"434","LY","241","countryisocode" +"438","LI","242","countryisocode" +"430","LR","243","countryisocode" +"642","RO","244","countryisocode" +"442","LU","245","countryisocode" +"646","RW","246","countryisocode" +"422","LB","247","countryisocode" +"638","RE","248","countryisocode" +"643","RU","249","countryisocode" diff --git a/src/Eccube/Resource/doctrine/import_csv/ja/definition.yml b/src/Eccube/Resource/doctrine/import_csv/ja/definition.yml index bd8383c3485..588b3553fd8 100644 --- a/src/Eccube/Resource/doctrine/import_csv/ja/definition.yml +++ b/src/Eccube/Resource/doctrine/import_csv/ja/definition.yml @@ -1,5 +1,8 @@ +- mtb_agent_protocol.csv - mtb_authority.csv +- mtb_checkout_session_status.csv - mtb_country.csv +- mtb_country_iso_code.csv - mtb_csv_type.csv - mtb_customer_order_status.csv - mtb_customer_status.csv diff --git a/src/Eccube/Resource/doctrine/import_csv/ja/dtb_base_info.csv b/src/Eccube/Resource/doctrine/import_csv/ja/dtb_base_info.csv index 95a7ffd2662..2e993540906 100644 --- a/src/Eccube/Resource/doctrine/import_csv/ja/dtb_base_info.csv +++ b/src/Eccube/Resource/doctrine/import_csv/ja/dtb_base_info.csv @@ -1 +1 @@ -id,country_id,pref_id,company_name,company_kana,postal_code,addr01,addr02,phone_number,business_hour,email01,email02,email03,email04,shop_name,shop_kana,shop_name_eng,update_date,good_traded,message,delivery_free_amount,delivery_free_quantity,option_mypage_order_status_display,option_nostock_hidden,option_favorite_product,option_product_delivery_fee,option_product_tax_rule,option_customer_activate,option_guest_purchase,option_remember_me,option_mail_notifier,authentication_key,option_point,basic_point_rate,point_conversion_rate,discriminator_type +id,country_id,pref_id,company_name,company_kana,postal_code,addr01,addr02,phone_number,business_hour,email01,email02,email03,email04,shop_name,shop_kana,shop_name_eng,update_date,good_traded,message,delivery_free_amount,delivery_free_quantity,option_mypage_order_status_display,option_nostock_hidden,option_favorite_product,option_product_delivery_fee,option_product_tax_rule,option_customer_activate,option_guest_purchase,option_remember_me,option_mail_notifier,authentication_key,option_point,basic_point_rate,point_conversion_rate,discriminator_type,acp_checkout_enabled,ucp_checkout_enabled,ucp_catalog_requires_auth diff --git a/src/Eccube/Resource/doctrine/import_csv/ja/mtb_agent_protocol.csv b/src/Eccube/Resource/doctrine/import_csv/ja/mtb_agent_protocol.csv new file mode 100644 index 00000000000..575027df7fe --- /dev/null +++ b/src/Eccube/Resource/doctrine/import_csv/ja/mtb_agent_protocol.csv @@ -0,0 +1,3 @@ +id,name,sort_no,discriminator_type +"1","acp","1","agentprotocol" +"2","ucp","2","agentprotocol" diff --git a/src/Eccube/Resource/doctrine/import_csv/ja/mtb_checkout_session_status.csv b/src/Eccube/Resource/doctrine/import_csv/ja/mtb_checkout_session_status.csv new file mode 100644 index 00000000000..5a90d4ad32d --- /dev/null +++ b/src/Eccube/Resource/doctrine/import_csv/ja/mtb_checkout_session_status.csv @@ -0,0 +1,8 @@ +id,name,sort_no,discriminator_type +"1","incomplete","1","checkoutsessionstatus" +"2","ready","2","checkoutsessionstatus" +"3","completed","3","checkoutsessionstatus" +"4","canceled","4","checkoutsessionstatus" +"5","expired","5","checkoutsessionstatus" +"6","requires_action","6","checkoutsessionstatus" +"7","in_progress","7","checkoutsessionstatus" diff --git a/src/Eccube/Resource/doctrine/import_csv/ja/mtb_country_iso_code.csv b/src/Eccube/Resource/doctrine/import_csv/ja/mtb_country_iso_code.csv new file mode 100644 index 00000000000..941bd5c983f --- /dev/null +++ b/src/Eccube/Resource/doctrine/import_csv/ja/mtb_country_iso_code.csv @@ -0,0 +1,250 @@ +id,name,sort_no,discriminator_type +"352","IS","1","countryisocode" +"372","IE","2","countryisocode" +"31","AZ","3","countryisocode" +"4","AF","4","countryisocode" +"840","US","5","countryisocode" +"850","VI","6","countryisocode" +"16","AS","7","countryisocode" +"784","AE","8","countryisocode" +"12","DZ","9","countryisocode" +"32","AR","10","countryisocode" +"533","AW","11","countryisocode" +"8","AL","12","countryisocode" +"51","AM","13","countryisocode" +"660","AI","14","countryisocode" +"24","AO","15","countryisocode" +"28","AG","16","countryisocode" +"20","AD","17","countryisocode" +"887","YE","18","countryisocode" +"826","GB","19","countryisocode" +"86","IO","20","countryisocode" +"92","VG","21","countryisocode" +"376","IL","22","countryisocode" +"380","IT","23","countryisocode" +"368","IQ","24","countryisocode" +"364","IR","25","countryisocode" +"356","IN","26","countryisocode" +"360","ID","27","countryisocode" +"876","WF","28","countryisocode" +"800","UG","29","countryisocode" +"804","UA","30","countryisocode" +"860","UZ","31","countryisocode" +"858","UY","32","countryisocode" +"218","EC","33","countryisocode" +"818","EG","34","countryisocode" +"233","EE","35","countryisocode" +"231","ET","36","countryisocode" +"232","ER","37","countryisocode" +"222","SV","38","countryisocode" +"36","AU","39","countryisocode" +"40","AT","40","countryisocode" +"248","AX","41","countryisocode" +"512","OM","42","countryisocode" +"528","NL","43","countryisocode" +"288","GH","44","countryisocode" +"132","CV","45","countryisocode" +"831","GG","46","countryisocode" +"328","GY","47","countryisocode" +"398","KZ","48","countryisocode" +"634","QA","49","countryisocode" +"581","UM","50","countryisocode" +"124","CA","51","countryisocode" +"266","GA","52","countryisocode" +"120","CM","53","countryisocode" +"270","GM","54","countryisocode" +"116","KH","55","countryisocode" +"580","MP","56","countryisocode" +"324","GN","57","countryisocode" +"624","GW","58","countryisocode" +"196","CY","59","countryisocode" +"192","CU","60","countryisocode" +"531","CW","61","countryisocode" +"300","GR","62","countryisocode" +"296","KI","63","countryisocode" +"417","KG","64","countryisocode" +"320","GT","65","countryisocode" +"312","GP","66","countryisocode" +"316","GU","67","countryisocode" +"414","KW","68","countryisocode" +"184","CK","69","countryisocode" +"304","GL","70","countryisocode" +"162","CX","71","countryisocode" +"268","GE","72","countryisocode" +"308","GD","73","countryisocode" +"191","HR","74","countryisocode" +"136","KY","75","countryisocode" +"404","KE","76","countryisocode" +"384","CI","77","countryisocode" +"166","CC","78","countryisocode" +"188","CR","79","countryisocode" +"174","KM","80","countryisocode" +"170","CO","81","countryisocode" +"178","CG","82","countryisocode" +"180","CD","83","countryisocode" +"682","SA","84","countryisocode" +"239","GS","85","countryisocode" +"882","WS","86","countryisocode" +"678","ST","87","countryisocode" +"652","BL","88","countryisocode" +"894","ZM","89","countryisocode" +"666","PM","90","countryisocode" +"674","SM","91","countryisocode" +"663","MF","92","countryisocode" +"694","SL","93","countryisocode" +"262","DJ","94","countryisocode" +"292","GI","95","countryisocode" +"832","JE","96","countryisocode" +"388","JM","97","countryisocode" +"760","SY","98","countryisocode" +"702","SG","99","countryisocode" +"534","SX","100","countryisocode" +"716","ZW","101","countryisocode" +"756","CH","102","countryisocode" +"752","SE","103","countryisocode" +"729","SD","104","countryisocode" +"744","SJ","105","countryisocode" +"724","ES","106","countryisocode" +"740","SR","107","countryisocode" +"144","LK","108","countryisocode" +"703","SK","109","countryisocode" +"705","SI","110","countryisocode" +"748","SZ","111","countryisocode" +"690","SC","112","countryisocode" +"226","GQ","113","countryisocode" +"686","SN","114","countryisocode" +"688","RS","115","countryisocode" +"659","KN","116","countryisocode" +"670","VC","117","countryisocode" +"426","LS","118","countryisocode" +"654","SH","119","countryisocode" +"662","LC","120","countryisocode" +"706","SO","121","countryisocode" +"90","SB","122","countryisocode" +"796","TC","123","countryisocode" +"764","TH","124","countryisocode" +"410","KR","125","countryisocode" +"158","TW","126","countryisocode" +"762","TJ","127","countryisocode" +"834","TZ","128","countryisocode" +"203","CZ","129","countryisocode" +"148","TD","130","countryisocode" +"140","CF","131","countryisocode" +"156","CN","132","countryisocode" +"788","TN","133","countryisocode" +"408","KP","134","countryisocode" +"152","CL","135","countryisocode" +"798","TV","136","countryisocode" +"208","DK","137","countryisocode" +"276","DE","138","countryisocode" +"768","TG","139","countryisocode" +"772","TK","140","countryisocode" +"214","DO","141","countryisocode" +"212","DM","142","countryisocode" +"780","TT","143","countryisocode" +"795","TM","144","countryisocode" +"792","TR","145","countryisocode" +"776","TO","146","countryisocode" +"566","NG","147","countryisocode" +"520","NR","148","countryisocode" +"516","NA","149","countryisocode" +"10","AQ","150","countryisocode" +"570","NU","151","countryisocode" +"558","NI","152","countryisocode" +"562","NE","153","countryisocode" +"392","JP","154","countryisocode" +"732","EH","155","countryisocode" +"540","NC","156","countryisocode" +"554","NZ","157","countryisocode" +"524","NP","158","countryisocode" +"574","NF","159","countryisocode" +"578","NO","160","countryisocode" +"334","HM","161","countryisocode" +"48","BH","162","countryisocode" +"332","HT","163","countryisocode" +"586","PK","164","countryisocode" +"336","VA","165","countryisocode" +"591","PA","166","countryisocode" +"548","VU","167","countryisocode" +"44","BS","168","countryisocode" +"598","PG","169","countryisocode" +"60","BM","170","countryisocode" +"585","PW","171","countryisocode" +"600","PY","172","countryisocode" +"52","BB","173","countryisocode" +"275","PS","174","countryisocode" +"348","HU","175","countryisocode" +"50","BD","176","countryisocode" +"626","TL","177","countryisocode" +"612","PN","178","countryisocode" +"242","FJ","179","countryisocode" +"608","PH","180","countryisocode" +"246","FI","181","countryisocode" +"64","BT","182","countryisocode" +"74","BV","183","countryisocode" +"630","PR","184","countryisocode" +"234","FO","185","countryisocode" +"238","FK","186","countryisocode" +"76","BR","187","countryisocode" +"250","FR","188","countryisocode" +"254","GF","189","countryisocode" +"258","PF","190","countryisocode" +"260","TF","191","countryisocode" +"100","BG","192","countryisocode" +"854","BF","193","countryisocode" +"96","BN","194","countryisocode" +"108","BI","195","countryisocode" +"704","VN","196","countryisocode" +"204","BJ","197","countryisocode" +"862","VE","198","countryisocode" +"112","BY","199","countryisocode" +"84","BZ","200","countryisocode" +"604","PE","201","countryisocode" +"56","BE","202","countryisocode" +"616","PL","203","countryisocode" +"70","BA","204","countryisocode" +"72","BW","205","countryisocode" +"535","BQ","206","countryisocode" +"68","BO","207","countryisocode" +"620","PT","208","countryisocode" +"344","HK","209","countryisocode" +"340","HN","210","countryisocode" +"584","MH","211","countryisocode" +"446","MO","212","countryisocode" +"807","MK","213","countryisocode" +"450","MG","214","countryisocode" +"175","YT","215","countryisocode" +"454","MW","216","countryisocode" +"466","ML","217","countryisocode" +"470","MT","218","countryisocode" +"474","MQ","219","countryisocode" +"458","MY","220","countryisocode" +"833","IM","221","countryisocode" +"583","FM","222","countryisocode" +"710","ZA","223","countryisocode" +"728","SS","224","countryisocode" +"104","MM","225","countryisocode" +"484","MX","226","countryisocode" +"480","MU","227","countryisocode" +"478","MR","228","countryisocode" +"508","MZ","229","countryisocode" +"492","MC","230","countryisocode" +"462","MV","231","countryisocode" +"498","MD","232","countryisocode" +"504","MA","233","countryisocode" +"496","MN","234","countryisocode" +"499","ME","235","countryisocode" +"500","MS","236","countryisocode" +"400","JO","237","countryisocode" +"418","LA","238","countryisocode" +"428","LV","239","countryisocode" +"440","LT","240","countryisocode" +"434","LY","241","countryisocode" +"438","LI","242","countryisocode" +"430","LR","243","countryisocode" +"642","RO","244","countryisocode" +"442","LU","245","countryisocode" +"646","RW","246","countryisocode" +"422","LB","247","countryisocode" +"638","RE","248","countryisocode" +"643","RU","249","countryisocode" diff --git a/src/Eccube/Resource/locale/messages.en.yaml b/src/Eccube/Resource/locale/messages.en.yaml index 3807b8b6380..22771af0744 100644 --- a/src/Eccube/Resource/locale/messages.en.yaml +++ b/src/Eccube/Resource/locale/messages.en.yaml @@ -1186,6 +1186,10 @@ admin.setting.shop.shop.option_point_rate: Point Return Rate admin.setting.shop.shop.option_point_conversion_rate: Point Conversion Rate admin.setting.shop.shop.ga: "Google Analytics" admin.setting.shop.shop.ga.tracking_id: "Tracking ID" +admin.setting.shop.shop.agent_commerce: "Agentic Commerce" +admin.setting.shop.shop.agent_commerce.description: "Enable checkout via AI agents (ChatGPT / Gemini, etc.). Discovery and catalog are always public; only checkout is controlled here. Checkout is not yet available in Japan, so normally leave these disabled." +admin.setting.shop.shop.agent_commerce.acp_checkout_enabled: "Enable ACP checkout" +admin.setting.shop.shop.agent_commerce.ucp_checkout_enabled: "Enable UCP checkout" #------------------------------------------------------------------------------------ # Settings:Store Settings:Trade Law Settings diff --git a/src/Eccube/Resource/locale/messages.ja.yaml b/src/Eccube/Resource/locale/messages.ja.yaml index 96445e787c0..fb4ec95a305 100644 --- a/src/Eccube/Resource/locale/messages.ja.yaml +++ b/src/Eccube/Resource/locale/messages.ja.yaml @@ -1185,6 +1185,10 @@ admin.setting.shop.shop.option_point_rate: ポイント付与率 admin.setting.shop.shop.option_point_conversion_rate: ポイント換算レート admin.setting.shop.shop.ga: "Googleアナリティクス設定" admin.setting.shop.shop.ga.tracking_id: "トラッキングID" +admin.setting.shop.shop.agent_commerce: "エージェントコマース" +admin.setting.shop.shop.agent_commerce.description: "AIエージェント (ChatGPT / Gemini 等) 経由のチェックアウトを有効化します。Discovery / カタログは常時公開され、ここでは checkout のみを制御します。checkout は日本未提供のため、通常は無効のままにしてください。" +admin.setting.shop.shop.agent_commerce.acp_checkout_enabled: "ACP チェックアウトを有効にする" +admin.setting.shop.shop.agent_commerce.ucp_checkout_enabled: "UCP チェックアウトを有効にする" #------------------------------------------------------------------------------------ # 設定:店舗設定:特定商取引法設定 diff --git a/src/Eccube/Resource/template/admin/Setting/Shop/shop_master.twig b/src/Eccube/Resource/template/admin/Setting/Shop/shop_master.twig index b30e83f8049..67ef3f96e25 100644 --- a/src/Eccube/Resource/template/admin/Setting/Shop/shop_master.twig +++ b/src/Eccube/Resource/template/admin/Setting/Shop/shop_master.twig @@ -373,6 +373,30 @@ file that was distributed with this source code. +
+
{{ 'admin.setting.shop.shop.agent_commerce'|trans }}
+
+

{{ 'admin.setting.shop.shop.agent_commerce.description'|trans }}

+
+
+ {{ 'admin.setting.shop.shop.agent_commerce.acp_checkout_enabled'|trans }} +
+
+ {{ form_widget(form.acp_checkout_enabled) }} + {{ form_errors(form.acp_checkout_enabled) }} +
+
+
+
+ {{ 'admin.setting.shop.shop.agent_commerce.ucp_checkout_enabled'|trans }} +
+
+ {{ form_widget(form.ucp_checkout_enabled) }} + {{ form_errors(form.ucp_checkout_enabled) }} +
+
+
+
diff --git a/src/Eccube/Service/AgentCommerce/AddressMappingService.php b/src/Eccube/Service/AgentCommerce/AddressMappingService.php new file mode 100644 index 00000000000..11c85d810aa --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/AddressMappingService.php @@ -0,0 +1,159 @@ +countryIsoCodeRepository->find($numericCountryId)?->getName(); + } + + /** + * Pref から region 文字列 (都道府県名) を返す。null は null を返す。 + */ + public function getRegionFromPref(?Pref $pref): ?string + { + if ($pref === null) { + return null; + } + + return $pref->getName(); + } + + /** + * region 文字列 (都道府県名) から Pref を逆引きする。 + * + * UCP の postal address `region` は EC-CUBE では Pref 名 (例: 東京都) に対応する。 + * ISO 3166-2:JP コード (JP-13 等) での指定は標準では解決せず、app/Customize での + * 拡張余地とする (本サービスを decoration して解決ロジックを差し替え可能)。 + */ + public function getPrefFromRegion(?string $region): ?Pref + { + if ($region === null || $region === '') { + return null; + } + + return $this->prefRepository->findOneBy(['name' => $region]); + } + + /** + * 住所系エンティティを ACP / UCP の住所 DTO 相当の配列へ写す。 + * + * @return array{ + * family_name: ?string, + * given_name: ?string, + * family_name_kana: ?string, + * given_name_kana: ?string, + * company: ?string, + * postal_code: ?string, + * region: ?string, + * address1: ?string, + * address2: ?string, + * country: ?string, + * phone: ?string + * } + */ + public function toAddressArray(Customer|CustomerAddress|Shipping $source): array + { + $country = $this->extractCountry($source); + $countryId = $country !== null ? $country->getId() : null; + + return [ + 'family_name' => $this->callIfExists($source, 'getName01'), + 'given_name' => $this->callIfExists($source, 'getName02'), + 'family_name_kana' => $this->callIfExists($source, 'getKana01'), + 'given_name_kana' => $this->callIfExists($source, 'getKana02'), + 'company' => $this->callIfExists($source, 'getCompanyName'), + 'postal_code' => $this->callIfExists($source, 'getPostalCode'), + 'region' => $this->getRegionFromPref($this->extractPref($source)), + 'address1' => $this->callIfExists($source, 'getAddr01'), + 'address2' => $this->callIfExists($source, 'getAddr02'), + 'country' => $this->getAlpha2FromCountryId($countryId), + 'phone' => $this->extractPhoneNumber($source), + ]; + } + + private function extractCountry(Customer|CustomerAddress|Shipping $source): ?Country + { + // Customer / CustomerAddress / Shipping いずれも getCountry() を持つ。 + return $source->getCountry(); + } + + private function extractPref(Customer|CustomerAddress|Shipping $source): ?Pref + { + // Customer / CustomerAddress / Shipping いずれも getPref() を持つ。 + return $source->getPref(); + } + + /** + * 電話番号を取得する。 + * Customer / CustomerAddress / Shipping いずれも getPhoneNumber() を持つ。 + */ + private function extractPhoneNumber(Customer|CustomerAddress|Shipping $source): ?string + { + return $source->getPhoneNumber(); + } + + /** + * 指定 getter が存在すれば呼び出して文字列(or null)を返す。存在しなければ null。 + */ + private function callIfExists(Customer|CustomerAddress|Shipping $source, string $method): ?string + { + if (!method_exists($source, $method)) { + return null; + } + + try { + $value = $source->$method(); + } catch (\TypeError) { + // 一部エンティティ (Shipping の getName01/getKana01 等) は getter の戻り型が + // 非 null の string だが、値が未設定だと TypeError になる。null 扱いにする。 + return null; + } + + return $value === null ? null : (string) $value; + } +} diff --git a/src/Eccube/Service/AgentCommerce/AgentCheckoutPurchaseFlowAdapter.php b/src/Eccube/Service/AgentCommerce/AgentCheckoutPurchaseFlowAdapter.php new file mode 100644 index 00000000000..2512e601303 --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/AgentCheckoutPurchaseFlowAdapter.php @@ -0,0 +1,246 @@ +lineItems === []) { + throw new AgentCheckoutException(AgentCheckoutErrorCode::EMPTY_LINE_ITEMS, 'line items must not be empty'); + } + + $Customer = $member ?? $this->buildGuestCustomer($request->buyer); + + $Cart = $this->buildCart($request); + + $Order = $this->orderHelper->createPurchaseProcessingOrder($Cart, $Customer); + $Cart->setPreOrderId($Order->getPreOrderId()); + + if ($request->protocolId !== null) { + $Order->setAgentProtocol($this->agentProtocolRepository->find($request->protocolId)); + } + if ($request->agentId !== null) { + $Order->setAgentId($request->agentId); + } + + $this->entityManager->flush(); + + $messages = $this->runFlow(fn (PurchaseContext $context) => $this->shoppingPurchaseFlow->validate($Order, $context), $Order, $member); + $this->entityManager->flush(); + + return new AgentCheckoutResult($Order, $messages, $Cart); + } + + /** + * 在庫・ポイントを引き当て、受注番号を採番する (PurchaseFlow::prepare). + * + * EC-CUBE 通常購入の `PaymentMethod::apply()` 相当で、**決済オーソリの「前」**に実行する。 + * ビジネス系エラー (在庫不足・販売制限等) は例外でなく messages[] に反映し、その場合は + * prepare を行わない。プロトコル系エラー (不正な商品参照等) は呼び出し前段で弾く。 + * + * 注意: PurchaseFlow の StockReduceProcessor が EntityManager::lock() (悲観ロック) を使うため、 + * **トランザクションがアクティブな状態で呼ぶこと**。トランザクション境界の管理 (および + * EMV-3DS 等の外部サイト遷移時の中断) は決済オーケストレーション層 (PaymentMethod 相当の + * 決済ハンドラ / controller) の責務とし、本アダプタ自身はトランザクションを開始しない + * (決済処理全体を 1 トランザクションで囲うと外部遷移型決済で破綻するため)。 + */ + public function prepare(Order $Order, ?Customer $member = null): AgentCheckoutResult + { + $messages = $this->runFlow(function (PurchaseContext $context) use ($Order): PurchaseFlowResult { + $result = $this->shoppingPurchaseFlow->validate($Order, $context); + if (!$result->hasError()) { + $this->shoppingPurchaseFlow->prepare($Order, $context); + } + + return $result; + }, $Order, $member); + + $this->entityManager->flush(); + + return new AgentCheckoutResult($Order, $messages); + } + + /** + * 注文を確定する (PurchaseFlow::commit). 決済オーソリ/売上の「成功後」に呼ぶ. + * + * EC-CUBE 通常購入の `PaymentMethod::checkout()` 成功時相当。{@link prepare()} 済みであること、 + * トランザクションがアクティブであることが前提。 + */ + public function commit(Order $Order, ?Customer $member = null): void + { + $context = new PurchaseContext(clone $Order, $member); + $this->shoppingPurchaseFlow->commit($Order, $context); + $this->entityManager->flush(); + } + + /** + * {@link prepare()} で引き当てた在庫・ポイントを戻す (PurchaseFlow::rollback). + * + * 決済オーソリ失敗・キャンセル時に呼ぶ (EC-CUBE 通常購入の `PaymentMethod::checkout()` 失敗時相当)。 + */ + public function rollback(Order $Order, ?Customer $member = null): void + { + $context = new PurchaseContext(clone $Order, $member); + $this->shoppingPurchaseFlow->rollback($Order, $context); + $this->entityManager->flush(); + } + + /** + * PurchaseFlow を実行し、warning/error を中立メッセージへ写し取る. + * + * @param callable(PurchaseContext): PurchaseFlowResult $flow + * + * @return array + */ + private function runFlow(callable $flow, Order $Order, ?Customer $member): array + { + $context = new PurchaseContext(clone $Order, $member); + $result = $flow($context); + + $messages = []; + foreach ($result->getErrors() as $error) { + $messages[] = new AgentCheckoutMessage(AgentCheckoutMessageLevel::ERROR, (string) $error->getMessage()); + } + foreach ($result->getWarning() as $warning) { + $messages[] = new AgentCheckoutMessage(AgentCheckoutMessageLevel::WARNING, (string) $warning->getMessage()); + } + + return $messages; + } + + /** + * 中立明細から永続化された `Cart` を構築する. + */ + private function buildCart(AgentCheckoutRequest $request): Cart + { + $Cart = new Cart(); + // 合計は Order 側の shopping flow で再計算されるため、ここでは NOT NULL 制約を満たす初期値のみ設定する。 + $Cart->setTotalPrice('0'); + $Cart->setDeliveryFeeTotal('0'); + // エージェント所有カートは Web ストアフロント (CartService) の解決対象から除外する。 + // 会員帰属は Order 側 (OrderHelper) に持たせ、Cart の customer_id は常に NULL に保つ + // ことで、ログイン会員の Web カートに混入・操作されないようにする。 + $Cart->setAgentOwned(true); + + foreach ($request->lineItems as $lineItem) { + if ($lineItem->quantity < 1) { + throw new AgentCheckoutException(AgentCheckoutErrorCode::INVALID_QUANTITY, 'quantity must be positive'); + } + + $ProductClass = $this->productClassRepository->find($lineItem->productClassId); + if ($ProductClass === null) { + throw new AgentCheckoutException(AgentCheckoutErrorCode::PRODUCT_NOT_FOUND, sprintf('ProductClass #%d not found', $lineItem->productClassId)); + } + + $CartItem = new CartItem(); + $CartItem + ->setQuantity((string) $lineItem->quantity) + ->setPrice((string) $ProductClass->getPrice02IncTax()) + ->setProductClass($ProductClass); + $Cart->addCartItem($CartItem); + $CartItem->setCart($Cart); + } + + $this->entityManager->persist($Cart); + + return $Cart; + } + + /** + * 住所 DTO から transient (非永続) な会員オブジェクトを構築する (ゲスト購入用). + * + * `OrderHelper::createPurchaseProcessingOrder()` は `Customer` のプロパティを `Order`/`Shipping` + * へコピーするため、id を持たない transient な `Customer` を渡すことでゲストの住所を反映できる。 + */ + private function buildGuestCustomer(?AgentCheckoutAddress $address): Customer + { + if ($address === null) { + throw new AgentCheckoutException(AgentCheckoutErrorCode::MISSING_ADDRESS, 'buyer address is required for guest checkout'); + } + + $Customer = new Customer(); + $Customer + ->setName01((string) $address->name01) + ->setName02((string) $address->name02) + ->setKana01($address->kana01) + ->setKana02($address->kana02) + ->setCompanyName($address->companyName) + ->setEmail($address->email) + ->setPhonenumber($address->phoneNumber) + ->setPostalcode($address->postalCode) + ->setAddr01($address->addr01) + ->setAddr02($address->addr02); + + if ($address->prefId !== null) { + $Pref = $this->prefRepository->find($address->prefId); + if ($Pref === null) { + throw new AgentCheckoutException(AgentCheckoutErrorCode::INVALID_PREF, sprintf('Pref #%d not found', $address->prefId)); + } + $Customer->setPref($Pref); + } + + return $Customer; + } +} diff --git a/src/Eccube/Service/AgentCommerce/CheckoutSession/AgentCheckoutAddress.php b/src/Eccube/Service/AgentCommerce/CheckoutSession/AgentCheckoutAddress.php new file mode 100644 index 00000000000..584e877bbcb --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/CheckoutSession/AgentCheckoutAddress.php @@ -0,0 +1,38 @@ + $messages ビジネス系メッセージ (在庫不足・決済拒否等) + * @param array $actionData REQUIRES_ACTION/PENDING 時の中立データ + */ + public function __construct( + public CheckoutSessionStatus $status, + public ?Order $order, + public array $messages = [], + public array $actionData = [], + ) { + } +} diff --git a/src/Eccube/Service/AgentCommerce/CheckoutSession/AgentCheckoutCompletionService.php b/src/Eccube/Service/AgentCommerce/CheckoutSession/AgentCheckoutCompletionService.php new file mode 100644 index 00000000000..394807c8335 --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/CheckoutSession/AgentCheckoutCompletionService.php @@ -0,0 +1,241 @@ + $paymentData 決済の中立表現 (再開時は authentication_result 等を含む) + * + * @throws AgentCheckoutException 確定不能な状態 (終端・Order 未生成等) + */ + public function complete(CheckoutSession $session, array $paymentData): AgentCheckoutCompletionResult + { + $statusId = $session->getStatus()?->getId(); + + // 冪等性: 既に完了済なら副作用を再実行せず既存 Order を返す。 + if ($statusId === CheckoutSessionStatus::COMPLETED) { + return new AgentCheckoutCompletionResult($this->requireStatus(CheckoutSessionStatus::COMPLETED), $session->getOrder()); + } + + // 終端ステータスは確定不能 (プロトコル系エラー)。 + if (in_array($statusId, [CheckoutSessionStatus::CANCELED, CheckoutSessionStatus::EXPIRED], true)) { + throw new AgentCheckoutException(AgentCheckoutErrorCode::INVALID_SESSION_STATE, 'The checkout session is in a terminal state and cannot be completed.'); + } + + $order = $session->getOrder(); + if ($order === null) { + throw new AgentCheckoutException(AgentCheckoutErrorCode::INVALID_SESSION_STATE, 'The checkout session has no order to complete.'); + } + + $member = $this->customerResolver->resolve($session); + $isResume = in_array($statusId, [CheckoutSessionStatus::REQUIRES_ACTION, CheckoutSessionStatus::IN_PROGRESS], true); + + $connection = $this->entityManager->getConnection(); + $connection->beginTransaction(); + try { + $result = $this->runStateMachine($session, $order, $member, $paymentData, $isResume); + $this->entityManager->flush(); + $connection->commit(); + + return $result; + } catch (\Throwable $e) { + $connection->rollBack(); + + throw $e; + } + } + + /** + * 状態機械の本体 (トランザクション内で実行される). + * + * @param array $paymentData + */ + private function runStateMachine(CheckoutSession $session, Order $order, ?Customer $member, array $paymentData, bool $isResume): AgentCheckoutCompletionResult + { + // 初回 complete: 在庫引当・受注番号採番 (PROCESSING のまま)。 + // 再開 complete: 引当は初回で済んでいるため再 prepare しない。 + if (!$isResume) { + $prepareResult = $this->purchaseFlowAdapter->prepare($order, $member); + if ($prepareResult->hasError()) { + // ビジネス系エラー (在庫不足等): 確定せず messages[] を返す (status 据置)。 + return new AgentCheckoutCompletionResult($this->requireCurrentStatus($session), null, $prepareResult->messages); + } + } + + $handler = $this->paymentHandlerRegistry->resolveForOrder($order); + + // 決済ハンドラ未登録 (代引・無償等) は与信不要のためそのまま確定する。 + if ($handler === null) { + return $this->commitOrder($session, $order, $member); + } + + $outcome = $handler->authorize($order, $paymentData); + + switch ($outcome->status) { + case PaymentOutcomeStatus::COMPLETED: + $capture = $handler->capture($order, $paymentData); + if ($capture->status !== PaymentOutcomeStatus::COMPLETED) { + return $this->failOrder($session, $order, $member, $capture); + } + $this->mergePaymentData($session, $capture->metadata); + + return $this->commitOrder($session, $order, $member); + + case PaymentOutcomeStatus::REQUIRES_ACTION: + return $this->holdForAction($session, $outcome, CheckoutSessionStatus::REQUIRES_ACTION); + + case PaymentOutcomeStatus::PENDING: + return $this->holdForAction($session, $outcome, CheckoutSessionStatus::IN_PROGRESS); + + case PaymentOutcomeStatus::FAILED: + default: + return $this->failOrder($session, $order, $member, $outcome); + } + } + + /** + * 注文を確定し completed へ遷移する. + */ + private function commitOrder(CheckoutSession $session, Order $order, ?Customer $member): AgentCheckoutCompletionResult + { + $this->purchaseFlowAdapter->commit($order, $member); + $session + ->setOrder($order) + ->setStatus($this->requireStatus(CheckoutSessionStatus::COMPLETED)); + + return new AgentCheckoutCompletionResult($session->getStatus() ?? $this->requireStatus(CheckoutSessionStatus::COMPLETED), $order); + } + + /** + * 追加対応待ち (REQUIRES_ACTION) / 非同期確定待ち (IN_PROGRESS) として在庫を引当のまま保持する. + * + * 在庫は rollback せず、payment_data と actionData を永続化してリクエスト跨ぎで再開可能にする。 + */ + private function holdForAction(CheckoutSession $session, PaymentOutcome $outcome, int $statusId): AgentCheckoutCompletionResult + { + $this->mergePaymentData($session, $outcome->metadata); + if ($outcome->actionData !== []) { + $metadata = $session->getMetadata() ?? []; + $metadata['payment_action'] = $outcome->actionData; + $session->setMetadata($metadata); + } + $this->extendExpiry($session); + $session->setStatus($this->requireStatus($statusId)); + + return new AgentCheckoutCompletionResult($session->getStatus() ?? $this->requireStatus($statusId), null, [], $outcome->actionData); + } + + /** + * 決済失敗時: 引当を rollback し、再試行可否でステータスを分岐する. + * + * retryable (card declined 等) は ready に戻して再 complete を許し、unrecoverable は canceled とする。 + */ + private function failOrder(CheckoutSession $session, Order $order, ?Customer $member, PaymentOutcome $outcome): AgentCheckoutCompletionResult + { + $this->purchaseFlowAdapter->rollback($order, $member); + + $statusId = $outcome->retryable ? CheckoutSessionStatus::READY : CheckoutSessionStatus::CANCELED; + $session->setStatus($this->requireStatus($statusId)); + + $message = new AgentCheckoutMessage( + AgentCheckoutMessageLevel::ERROR, + $outcome->errorMessage !== null && $outcome->errorMessage !== '' ? $outcome->errorMessage : (string) $outcome->errorCode, + ); + + return new AgentCheckoutCompletionResult($session->getStatus() ?? $this->requireStatus($statusId), null, [$message]); + } + + /** + * payment_data に PSP 参照等をマージする (token はハンドラ側でマスキング済). + * + * @param array $metadata + */ + private function mergePaymentData(CheckoutSession $session, array $metadata): void + { + if ($metadata === []) { + return; + } + $session->setPaymentData(array_merge($session->getPaymentData() ?? [], $metadata)); + } + + /** + * 在庫を確保したまま再開を待つ期限を再設定する (eccube.yaml の escalation 期限). + */ + private function extendExpiry(CheckoutSession $session): void + { + $session->setExpiresAt((new \DateTime())->modify(sprintf('+%d minutes', $this->escalationExpireMinutes))); + } + + private function requireCurrentStatus(CheckoutSession $session): CheckoutSessionStatus + { + $status = $session->getStatus(); + if ($status === null) { + throw new AgentCheckoutException(AgentCheckoutErrorCode::INVALID_SESSION_STATE, 'The checkout session has no status.'); + } + + return $status; + } + + private function requireStatus(int $id): CheckoutSessionStatus + { + $status = $this->statusRepository->find($id); + if ($status === null) { + throw new \RuntimeException(sprintf('CheckoutSessionStatus #%d is not found. Run the agent-commerce master data migration.', $id)); + } + + return $status; + } +} diff --git a/src/Eccube/Service/AgentCommerce/CheckoutSession/AgentCheckoutLineItem.php b/src/Eccube/Service/AgentCommerce/CheckoutSession/AgentCheckoutLineItem.php new file mode 100644 index 00000000000..16dfa3ab7c2 --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/CheckoutSession/AgentCheckoutLineItem.php @@ -0,0 +1,29 @@ + $lineItems + * @param int|null $protocolId プロトコル種別マスタ (`AgentProtocol::ACP` 等) の id + */ + public function __construct( + public array $lineItems, + public ?AgentCheckoutAddress $buyer = null, + public string $currencyCode = 'JPY', + public ?int $protocolId = null, + public ?string $agentId = null, + ) { + } +} diff --git a/src/Eccube/Service/AgentCommerce/CheckoutSession/AgentCheckoutResult.php b/src/Eccube/Service/AgentCommerce/CheckoutSession/AgentCheckoutResult.php new file mode 100644 index 00000000000..c36a3a69a1d --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/CheckoutSession/AgentCheckoutResult.php @@ -0,0 +1,60 @@ + $messages + */ + public function __construct( + public Order $order, + public array $messages = [], + public ?Cart $cart = null, + ) { + } + + public function hasError(): bool + { + foreach ($this->messages as $message) { + if ($message->level === AgentCheckoutMessageLevel::ERROR) { + return true; + } + } + + return false; + } + + public function hasWarning(): bool + { + foreach ($this->messages as $message) { + if ($message->level === AgentCheckoutMessageLevel::WARNING) { + return true; + } + } + + return false; + } +} diff --git a/src/Eccube/Service/AgentCommerce/CheckoutSession/CustomerResolverInterface.php b/src/Eccube/Service/AgentCommerce/CheckoutSession/CustomerResolverInterface.php new file mode 100644 index 00000000000..96f5cf6be9a --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/CheckoutSession/CustomerResolverInterface.php @@ -0,0 +1,35 @@ +value, 0, $previous); + } + + public function getErrorCode(): AgentCheckoutErrorCode + { + return $this->errorCode; + } +} diff --git a/src/Eccube/Service/AgentCommerce/Exception/IdempotencyConflictException.php b/src/Eccube/Service/AgentCommerce/Exception/IdempotencyConflictException.php new file mode 100644 index 00000000000..94a72f39d88 --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Exception/IdempotencyConflictException.php @@ -0,0 +1,26 @@ + $paymentOptions + */ + public function __construct( + public int $deliveryId, + public string $name, + public int $shippingFeeMinor, + public string $currencyCode, + public ?int $estimatedDeliveryDays, + public array $paymentOptions, + ) { + } +} diff --git a/src/Eccube/Service/AgentCommerce/Fulfillment/FulfillmentOptionMapperInterface.php b/src/Eccube/Service/AgentCommerce/Fulfillment/FulfillmentOptionMapperInterface.php new file mode 100644 index 00000000000..4809605c08a --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Fulfillment/FulfillmentOptionMapperInterface.php @@ -0,0 +1,34 @@ + $productClasses 対象明細の商品規格 + * + * @return array + */ + public function mapForDestination(iterable $productClasses, Pref $pref, string $currencyCode = 'JPY'): array; +} diff --git a/src/Eccube/Service/AgentCommerce/Fulfillment/FulfillmentPaymentOption.php b/src/Eccube/Service/AgentCommerce/Fulfillment/FulfillmentPaymentOption.php new file mode 100644 index 00000000000..3fd7b9bebcb --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Fulfillment/FulfillmentPaymentOption.php @@ -0,0 +1,30 @@ +extractSaleTypes($items); + if ($saleTypes === []) { + return []; + } + + $estimatedDeliveryDays = $this->resolveDeliveryDays($items); + + $options = []; + foreach ($this->deliveryRepository->getDeliveries($saleTypes) as $Delivery) { + $DeliveryFee = $this->deliveryFeeRepository->findOneBy([ + 'Delivery' => $Delivery, + 'Pref' => $pref, + ]); + if ($DeliveryFee === null) { + // 配送先に対する送料設定が無い = この配送方法では届けられない。 + continue; + } + + $options[] = new FulfillmentOption( + deliveryId: (int) $Delivery->getId(), + name: (string) $Delivery->getName(), + shippingFeeMinor: $this->minorUnitConverter->toMinorUnits($DeliveryFee->getFee() ?? '0', $currencyCode), + currencyCode: $currencyCode, + estimatedDeliveryDays: $estimatedDeliveryDays, + paymentOptions: $this->resolvePaymentOptions($Delivery, $currencyCode), + ); + } + + return $options; + } + + /** + * 明細から重複を除いた SaleType の一覧を取得する. + * + * @param array $items + * + * @return array + */ + private function extractSaleTypes(array $items): array + { + $saleTypes = []; + foreach ($items as $ProductClass) { + $SaleType = $ProductClass->getSaleType(); + if ($SaleType === null) { + continue; + } + $saleTypes[(int) $SaleType->getId()] = $SaleType; + } + + return array_values($saleTypes); + } + + /** + * 明細横断の配送日数 (最大値) を返す. + * + * お取り寄せ (duration < 0) が 1 件でもあれば未確定として null を返す。 + * 配送日数の設定が皆無の場合も null。 + * + * @param array $items + */ + private function resolveDeliveryDays(array $items): ?int + { + $maxDays = null; + foreach ($items as $ProductClass) { + $DeliveryDuration = $ProductClass->getDeliveryDuration(); + if ($DeliveryDuration === null) { + continue; + } + $duration = $DeliveryDuration->getDuration(); + if ($duration < 0) { + return null; + } + if ($maxDays === null || $duration > $maxDays) { + $maxDays = $duration; + } + } + + return $maxDays; + } + + /** + * 配送方法に紐づく (表示可能な) 支払方法の選択肢を返す. + * + * @return array + */ + private function resolvePaymentOptions(Delivery $Delivery, string $currencyCode): array + { + $paymentOptions = []; + foreach ($Delivery->getPaymentOptions() as $PaymentOption) { + $Payment = $PaymentOption->getPayment(); + if ($Payment === null || !$Payment->isVisible()) { + continue; + } + $paymentOptions[] = new FulfillmentPaymentOption( + paymentId: (int) $Payment->getId(), + method: (string) $Payment->getMethod(), + chargeMinor: $this->minorUnitConverter->toMinorUnits($Payment->getCharge() ?? '0', $currencyCode), + currencyCode: $currencyCode, + ); + } + + return $paymentOptions; + } +} diff --git a/src/Eccube/Service/AgentCommerce/Idempotency/AgentCheckoutIdempotencyStore.php b/src/Eccube/Service/AgentCommerce/Idempotency/AgentCheckoutIdempotencyStore.php new file mode 100644 index 00000000000..3b281222918 --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Idempotency/AgentCheckoutIdempotencyStore.php @@ -0,0 +1,169 @@ +} $compute 初回に実行するハンドラ + * @param string|null $subject 認証済み主体 (UCP-Agent profile 等)。未認証は null + * + * @return array{status: int, body: array, replayed: bool} + * + * @throws IdempotencyConflictException 同一キーの異パラメータ再利用、または処理中の並行リクエスト + */ + public function execute(?string $key, string $requestHash, callable $compute, ?string $subject = null): array + { + if ($key === null || $key === '') { + $result = $compute(); + + return ['status' => $result['status'], 'body' => $result['body'], 'replayed' => false]; + } + + $subjectKey = $subject ?? ''; + + // 既存記録があればリプレイ / 競合 / 処理中を判定する (逐次再送はここで完結)。 + $existing = $this->repository->findOneByKeyAndSubject($key, $subjectKey); + if ($existing !== null) { + return $this->replay($existing->getRequestHash(), $existing->hasResponse(), (int) $existing->getResponseStatus(), $existing->getResponseBody() ?? [], $requestHash); + } + + // 予約行を INSERT。一意制約で並行・越境の二重実行を直列化する。 + $reservation = (new AgentCheckoutIdempotency()) + ->setIdempotencyKey($key) + ->setSubject($subjectKey) + ->setRequestHash($requestHash); + try { + $this->entityManager->persist($reservation); + $this->entityManager->flush(); + } catch (UniqueConstraintViolationException) { + // 並行する別リクエストが先に予約した (flush 失敗で EM は閉じるため DBAL で直接読む)。 + return $this->replayFromDbal($key, $subjectKey, $requestHash); + } + + // 予約を獲得 → compute を実行。失敗時は予約を消して再試行可能にする。 + try { + $result = $compute(); + } catch (\Throwable $e) { + $this->deleteReservation($key, $subjectKey); + + throw $e; + } + + $reservation->setResponseStatus($result['status'])->setResponseBody($result['body']); + $this->entityManager->flush(); + + return ['status' => $result['status'], 'body' => $result['body'], 'replayed' => false]; + } + + /** + * リクエスト内容から安定したハッシュを生成する (キー再利用検知用). + * + * @param array $payload + */ + public function hashRequest(array $payload): string + { + // キーの並び順に依存しない安定したハッシュにする (同一内容・異キー順を同一リクエストと扱う)。 + ksort($payload); + + return hash('sha256', (string) json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); + } + + /** + * 既存記録から リプレイ結果を返す。異パラメータ再利用・処理中は競合例外. + * + * @param array $storedBody + * + * @return array{status: int, body: array, replayed: bool} + */ + private function replay(string $storedHash, bool $hasResponse, int $storedStatus, array $storedBody, string $requestHash): array + { + if ($storedHash !== $requestHash) { + throw new IdempotencyConflictException('Idempotency-Key was reused with different request parameters.'); + } + if (!$hasResponse) { + throw new IdempotencyConflictException('A request with the same Idempotency-Key is currently being processed.'); + } + + return ['status' => $storedStatus, 'body' => $storedBody, 'replayed' => true]; + } + + /** + * 一意制約違反 (EM が閉じた後) に DBAL で既存記録を読み、リプレイ結果を返す. + * + * @return array{status: int, body: array, replayed: bool} + */ + private function replayFromDbal(string $key, string $subject, string $requestHash): array + { + $row = $this->entityManager->getConnection()->fetchAssociative( + 'SELECT request_hash, response_status, response_body FROM dtb_agent_checkout_idempotency WHERE idempotency_key = :k AND subject = :s', + ['k' => $key, 's' => $subject], + ); + if ($row === false) { + // 競合相手がロールバック等で消えた場合は処理中扱い (再試行を促す)。 + throw new IdempotencyConflictException('A request with the same Idempotency-Key is currently being processed.'); + } + + $hasResponse = $row['response_status'] !== null; + $decoded = is_string($row['response_body'] ?? null) ? json_decode($row['response_body'], true) : null; + + return $this->replay( + (string) ($row['request_hash'] ?? ''), + $hasResponse, + (int) ($row['response_status'] ?? 0), + is_array($decoded) ? $decoded : [], + $requestHash, + ); + } + + /** + * 予約行を削除する (compute 失敗時の再試行を可能にするため。EM 状態に依存しない DBAL 経由). + */ + private function deleteReservation(string $key, string $subject): void + { + $this->entityManager->getConnection()->delete( + 'dtb_agent_checkout_idempotency', + ['idempotency_key' => $key, 'subject' => $subject], + ); + } +} diff --git a/src/Eccube/Service/AgentCommerce/MinorUnitConverter.php b/src/Eccube/Service/AgentCommerce/MinorUnitConverter.php new file mode 100644 index 00000000000..b95ad5677de --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/MinorUnitConverter.php @@ -0,0 +1,119 @@ + 1000 // 0 桁通貨: ×10^0 → 桁は増えない + * ("1000.00", "USD") => 100000 // 2 桁通貨: ×10^2 → 0 が 2 つ増える + * ("12.34", "USD") => 1234 + * ("-15.00", "USD") => -1500 // 割引・返金などの負数も可 + * + * @param string $amount major-unit の金額 (decimal 文字列). 負数可 + * @param string $currency ISO 4217 通貨コード (例: JPY, USD) + */ + public function toMinorUnits(string $amount, string $currency): int + { + $amount = trim($amount); + $digits = $this->getFractionDigits($currency); + + // 符号を分離して非負の値として丸め、最後に符号を戻す (round-half-up を絶対値で行うため)。 + $negative = false; + if (str_starts_with($amount, '-')) { + $negative = true; + $amount = substr($amount, 1); + } elseif (str_starts_with($amount, '+')) { + $amount = substr($amount, 1); + } + + // scale を桁数 + 1 にして、丸め用の補正を加える。 + $scale = $digits + 1; + $factor = bcpow('10', (string) $digits, 0); + + try { + // amount * 10^digits を計算 (まだ小数を含みうる)。 + $scaled = bcmul($amount, $factor, $scale); + + // round-half-up: 正の値に 0.5 を足してから切り捨て (bcmath は truncate)。 + $rounded = bcadd($scaled, '0.5', 0); + } catch (\ValueError) { + // 受け付ける数値文字列形式の判定は BCMath 自身に委譲する。 + // 不正な表記 (abc / 1,000 / 1e3 等) は ValueError になるため 0 とみなす。 + // (空文字 '' や '.' は BCMath が 0 として扱うのでここには来ない) + return 0; + } + + $result = (int) $rounded; + + return $negative ? -$result : $result; + } + + /** + * minor-unit の整数を major-unit の decimal 文字列へ変換する (toMinorUnits の逆変換). + * + * (1000, "JPY") => "1000" // 0 桁通貨: そのまま + * (100000, "USD") => "1000.00" // 2 桁通貨: 下 2 桁が小数部 + * (1234, "USD") => "12.34" + * (-1500, "USD") => "-15.00" + * + * @param int $minorUnits minor-unit の整数. 負数可 + * @param string $currency ISO 4217 通貨コード + */ + public function toAmountString(int $minorUnits, string $currency): string + { + $digits = $this->getFractionDigits($currency); + + if ($digits === 0) { + return (string) $minorUnits; + } + + $factor = bcpow('10', (string) $digits, 0); + + // bcdiv は scale 桁で丸めずに切り捨てるが、minor->major は割り切れるため scale=digits で正確。 + return bcdiv((string) $minorUnits, $factor, $digits); + } + + /** + * 通貨の小数桁数を ISO 4217 権威データから取得する. + * + * 未知の通貨コードの場合は安全側に倒して 2 桁を返す。 + */ + private function getFractionDigits(string $currency): int + { + $currency = strtoupper(trim($currency)); + + if ($currency !== '' && Currencies::exists($currency)) { + return Currencies::getFractionDigits($currency); + } + + return 2; + } +} diff --git a/src/Eccube/Service/AgentCommerce/Payment/AgentCheckoutPaymentHandlerInterface.php b/src/Eccube/Service/AgentCommerce/Payment/AgentCheckoutPaymentHandlerInterface.php new file mode 100644 index 00000000000..ef3f271e00a --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Payment/AgentCheckoutPaymentHandlerInterface.php @@ -0,0 +1,50 @@ + $paymentData 支払トークン等の中立表現 (再開時は authentication_result を含む) + */ + public function authorize(Order $order, array $paymentData): PaymentOutcome; + + /** + * 売上確定 (キャプチャ) を行う. {@link authorize()} が COMPLETED を返した後にのみ呼ぶ. + * + * @param array $paymentData 支払トークン等の中立表現 + */ + public function capture(Order $order, array $paymentData): PaymentOutcome; + + /** + * このハンドラが指定の支払方法 (Payment.method_class 等) を扱えるか. + */ + public function supports(Order $order): bool; +} diff --git a/src/Eccube/Service/AgentCommerce/Payment/AgentCheckoutPaymentHandlerRegistry.php b/src/Eccube/Service/AgentCommerce/Payment/AgentCheckoutPaymentHandlerRegistry.php new file mode 100644 index 00000000000..b07f49ab1b5 --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Payment/AgentCheckoutPaymentHandlerRegistry.php @@ -0,0 +1,90 @@ + + */ + private readonly array $handlers; + + /** + * @param iterable $handlers タグ付きハンドラ群 + */ + public function __construct(iterable $handlers) + { + $this->handlers = is_array($handlers) ? array_values($handlers) : iterator_to_array($handlers, false); + } + + /** + * Order の支払方法を扱えるハンドラを返す (なければ null). + */ + public function resolveForOrder(Order $order): ?AgentCheckoutPaymentHandlerInterface + { + foreach ($this->handlers as $handler) { + if ($handler->supports($order)) { + return $handler; + } + } + + return null; + } + + /** + * UCP の handler_id に一致する UCP ハンドラを返す (なければ null). + * + * 同一 handler_id を複数のハンドラが宣言している場合は、プラグイン競合による非決定的な + * 決済ハンドラ選択を避けるため、null を返さず明示的に例外を投げる。 + * + * @throws \RuntimeException 同一 handler_id が複数登録されている場合 + */ + public function resolveUcpByHandlerId(string $handlerId): ?UcpPaymentHandlerInterface + { + $matched = []; + foreach ($this->handlers as $handler) { + if ($handler instanceof UcpPaymentHandlerInterface && $handler->getHandlerId() === $handlerId) { + $matched[] = $handler; + } + } + + if (count($matched) > 1) { + throw new \RuntimeException(sprintf('Multiple UCP payment handlers are registered for handler_id "%s". Plugin payment handlers must declare a unique handler_id.', $handlerId)); + } + + return $matched[0] ?? null; + } + + /** + * 登録済みの UCP ハンドラを返す (discovery の payment_handlers 広告に使用). + * + * @return list + */ + public function ucpHandlers(): array + { + return array_values(array_filter( + $this->handlers, + static fn (AgentCheckoutPaymentHandlerInterface $handler): bool => $handler instanceof UcpPaymentHandlerInterface + )); + } +} diff --git a/src/Eccube/Service/AgentCommerce/Payment/PaymentOutcome.php b/src/Eccube/Service/AgentCommerce/Payment/PaymentOutcome.php new file mode 100644 index 00000000000..bdce9599af9 --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Payment/PaymentOutcome.php @@ -0,0 +1,84 @@ + $actionData REQUIRES_ACTION 時の中立データ。プロトコル層が + * continue_url (UCP) / authentication_metadata (ACP) へ写し取る + * @param array $metadata payment_data へ保持する PSP 参照等 (token はマスキング済) + */ + public function __construct( + public PaymentOutcomeStatus $status, + public ?string $transactionId = null, + public array $actionData = [], + public array $metadata = [], + public ?string $errorCode = null, + public ?string $errorMessage = null, + public bool $retryable = true, + ) { + } + + /** + * @param array $metadata + */ + public static function completed(?string $transactionId = null, array $metadata = []): self + { + return new self(PaymentOutcomeStatus::COMPLETED, $transactionId, [], $metadata); + } + + /** + * @param array $actionData continue_url / authentication_metadata の原資 + * @param array $metadata + */ + public static function requiresAction(array $actionData, array $metadata = []): self + { + return new self(PaymentOutcomeStatus::REQUIRES_ACTION, null, $actionData, $metadata); + } + + /** + * @param array $metadata + */ + public static function pending(array $metadata = []): self + { + return new self(PaymentOutcomeStatus::PENDING, null, [], $metadata); + } + + /** + * @param bool $retryable 再試行可能か (card declined 等は true=セッションを ready に戻す / + * fraud block 等の不可逆失敗は false=セッションを canceled にする) + */ + public static function failed(string $errorCode, string $errorMessage = '', bool $retryable = true): self + { + return new self(PaymentOutcomeStatus::FAILED, null, [], [], $errorCode, $errorMessage, $retryable); + } + + public function isSuccessful(): bool + { + return $this->status === PaymentOutcomeStatus::COMPLETED; + } + + public function needsAction(): bool + { + return $this->status === PaymentOutcomeStatus::REQUIRES_ACTION; + } +} diff --git a/src/Eccube/Service/AgentCommerce/Payment/PaymentOutcomeStatus.php b/src/Eccube/Service/AgentCommerce/Payment/PaymentOutcomeStatus.php new file mode 100644 index 00000000000..a39a77fb7e3 --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Payment/PaymentOutcomeStatus.php @@ -0,0 +1,38 @@ + $credential payment.instruments[].credential の中立表現 (type/token 等) + * + * @return array 後続の authorize()/capture() へ渡す中立な支払データ + */ + public function exchangePaymentToken(array $credential): array; +} diff --git a/src/Eccube/Service/AgentCommerce/Security/AgentCommerceMessageSignerInterface.php b/src/Eccube/Service/AgentCommerce/Security/AgentCommerceMessageSignerInterface.php new file mode 100644 index 00000000000..e97450e20ef --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Security/AgentCommerceMessageSignerInterface.php @@ -0,0 +1,58 @@ +> JWK の配列 + */ + public function getPublicJwks(): array; + + /** + * 現用鍵の Key ID (kid) を返す. + */ + public function getCurrentKid(): string; +} diff --git a/src/Eccube/Service/AgentCommerce/Security/AgentCommerceOAuth2Authenticator.php b/src/Eccube/Service/AgentCommerce/Security/AgentCommerceOAuth2Authenticator.php new file mode 100644 index 00000000000..c5c404026c0 --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Security/AgentCommerceOAuth2Authenticator.php @@ -0,0 +1,103 @@ + もしくは `scope`: 空白区切り string)。 + * + * 本クラスは検証ロジックの中核であり、firewall への配線 (Symfony の access_token + * authenticator 化) は checkout エンドポイントを持つ #6776/#6574 で行う。 + */ +class AgentCommerceOAuth2Authenticator +{ + public function __construct( + private readonly AgentCommerceScopeRegistry $scopeRegistry, + private readonly ?AccessTokenHandlerInterface $accessTokenHandler = null, + ) { + } + + /** + * eccube-api4 (リソースサーバー) が利用可能か. + */ + public function isAvailable(): bool + { + return $this->accessTokenHandler !== null; + } + + /** + * アクセストークンを検証し、protocol/capability に必要な scope を持つか確認する. + * + * @return UserBadge 検証済みユーザーバッジ (subject 識別子 + attributes) + * + * @throws ServiceUnavailableHttpException eccube-api4 未導入 (503 相当) + * @throws AuthenticationException トークン不正 (401 相当) + * @throws AccessDeniedException scope 不足・protocol 越境 (403 相当) + */ + public function authenticate(string $accessToken, string $protocol, string $capability): UserBadge + { + if ($this->accessTokenHandler === null) { + throw new ServiceUnavailableHttpException(null, 'OAuth2 resource server (eccube-api4) is not installed'); + } + + // 不正トークンは AuthenticationException (401 相当) が送出される。 + $userBadge = $this->accessTokenHandler->getUserBadgeFrom($accessToken); + + $grantedScopes = $this->extractScopes($userBadge); + if (!$this->scopeRegistry->supports($protocol, $capability, $grantedScopes)) { + throw new AccessDeniedException(sprintf('The token is not granted the required scope "%s:%s".', $protocol, $capability)); + } + + return $userBadge; + } + + /** + * UserBadge の attributes から付与 scope 一覧を取り出す. + * + * `scopes` (array) を優先し、無ければ `scope` (空白区切り string) を解釈する。 + * + * @return list + */ + private function extractScopes(UserBadge $userBadge): array + { + $attributes = $userBadge->getAttributes() ?? []; + + if (isset($attributes['scopes']) && is_array($attributes['scopes'])) { + return array_values(array_filter( + array_map(static fn ($scope): string => (string) $scope, $attributes['scopes']), + static fn (string $scope): bool => $scope !== '', + )); + } + + if (isset($attributes['scope']) && is_string($attributes['scope'])) { + return array_values(array_filter(explode(' ', $attributes['scope']), static fn (string $scope): bool => $scope !== '')); + } + + return []; + } +} diff --git a/src/Eccube/Service/AgentCommerce/Security/AgentCommerceScopeRegistry.php b/src/Eccube/Service/AgentCommerce/Security/AgentCommerceScopeRegistry.php new file mode 100644 index 00000000000..bfe7e27b4bf --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Security/AgentCommerceScopeRegistry.php @@ -0,0 +1,99 @@ +:" 形式で統一する (例: "ucp:checkout")。 + * "agent:catalog:read" のような旧形式は無効とする。protocol 越境 + * (例: acp トークンで ucp capability を要求) は許可しない。 + */ +class AgentCommerceScopeRegistry +{ + /** + * 正準 scope レジストリ. protocol => 許可する capability の配列. + * + * app/Customize で差し替えやすいようメソッド経由で参照する。 + * + * @var array> + */ + private const CANONICAL_SCOPES = [ + 'acp' => ['checkout', 'catalog'], + 'ucp' => ['checkout', 'cart', 'catalog', 'identity'], + ]; + + /** + * scope 文字列が正準レジストリに存在するか判定する. + * + * "ucp:checkout" などレジストリに定義された ":" のみ true。 + */ + public function isValidScope(string $scope): bool + { + $parts = explode(':', $scope); + + // 正確に protocol:capability の 2 要素でなければ無効 (例: "agent:catalog:read" は 3 要素)。 + if (count($parts) !== 2) { + return false; + } + + [$protocol, $capability] = $parts; + + if ($protocol === '' || $capability === '') { + return false; + } + + return isset(self::CANONICAL_SCOPES[$protocol]) + && in_array($capability, self::CANONICAL_SCOPES[$protocol], true); + } + + /** + * 指定 protocol が持つ全 scope 文字列を返す. + * + * 例: "ucp" => ["ucp:checkout", "ucp:cart", "ucp:catalog", "ucp:identity"] + * + * @return list + */ + public function scopesForProtocol(string $protocol): array + { + if (!isset(self::CANONICAL_SCOPES[$protocol])) { + return []; + } + + return array_map( + static fn (string $capability): string => $protocol.':'.$capability, + self::CANONICAL_SCOPES[$protocol] + ); + } + + /** + * 付与済み scope 群が、指定 protocol/capability の操作を許可しているか判定する. + * + * grantedScopes に ":" が含まれ、かつその scope が + * 正準であり protocol が一致するときのみ true。protocol 越境は false。 + * + * @param list $grantedScopes トークンに付与された scope 文字列の配列 + */ + public function supports(string $protocol, string $capability, array $grantedScopes): bool + { + $required = $protocol.':'.$capability; + + // 要求 scope 自体が正準でなければ許可しない (越境・未定義の capability を排除)。 + if (!$this->isValidScope($required)) { + return false; + } + + return in_array($required, $grantedScopes, true); + } +} diff --git a/src/Eccube/Service/AgentCommerce/Security/FilesystemKeyStore.php b/src/Eccube/Service/AgentCommerce/Security/FilesystemKeyStore.php new file mode 100644 index 00000000000..ad295a4f8ab --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Security/FilesystemKeyStore.php @@ -0,0 +1,95 @@ + $envPathOverrides purpose => 絶対パスの上書きマップ + */ + public function __construct( + private readonly string $projectDir, + private readonly array $envPathOverrides = [], + ) { + } + + /** + * {@inheritdoc} + */ + public function read(string $purpose): ?string + { + $path = $this->resolvePath($purpose); + + if (!is_file($path)) { + return null; + } + + $contents = file_get_contents($path); + + return $contents === false ? null : $contents; + } + + /** + * {@inheritdoc} + */ + public function write(string $purpose, string $pem): void + { + $path = $this->resolvePath($purpose); + $dir = \dirname($path); + + if (!is_dir($dir) && !mkdir($dir, 0700, true) && !is_dir($dir)) { + throw new \RuntimeException(sprintf('鍵格納ディレクトリ "%s" を作成できません.', $dir)); + } + + // file_put_contents は umask 既定 (通常 0644) でファイルを作成してから書き込むため、 + // chmod(0600) までの間に秘密鍵が group/other から読める瞬間が生じる。 + // 作成時点から 0600 になるよう、書き込みの間だけ umask(0077) に切り替える。 + $oldUmask = umask(0077); + try { + if (file_put_contents($path, $pem) === false) { + throw new \RuntimeException(sprintf('鍵ファイル "%s" への書き込みに失敗しました.', $path)); + } + if (!chmod($path, 0600)) { + throw new \RuntimeException(sprintf('鍵ファイル "%s" のパーミッション設定に失敗しました.', $path)); + } + } finally { + umask($oldUmask); + } + } + + /** + * purpose から鍵ファイルの絶対パスを解決する。 + * + * $envPathOverrides[$purpose] が非空文字ならそのパスを優先し、 + * それ以外は既定パスを使用する。 + */ + private function resolvePath(string $purpose): string + { + $override = $this->envPathOverrides[$purpose] ?? ''; + + if ($override !== '') { + return $override; + } + + return $this->projectDir.'/app/keystore/agent-commerce/'.$purpose.'.key'; + } +} diff --git a/src/Eccube/Service/AgentCommerce/Security/KeyStoreInterface.php b/src/Eccube/Service/AgentCommerce/Security/KeyStoreInterface.php new file mode 100644 index 00000000000..f6180661371 --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Security/KeyStoreInterface.php @@ -0,0 +1,40 @@ + grace period 中の旧公開鍵 PEM 群 + */ + private readonly array $gracePublicKeyPems; + + private ?PrivateKey $privateKey = null; + + /** + * @param KeyStoreInterface $keyStore 秘密鍵 PEM の読み書きストア + * @param string $purpose 鍵の用途識別子 (KeyStore のパス解決に使用) + * @param array $gracePublicKeyPems 旧公開鍵 PEM 群 (verify/JWK に含める) + */ + public function __construct( + private readonly KeyStoreInterface $keyStore, + private readonly string $purpose = 'ucp_signing', + array $gracePublicKeyPems = [], + ) { + $this->gracePublicKeyPems = array_values($gracePublicKeyPems); + } + + /** + * {@inheritdoc} + */ + public function sign(string $signatureBase): string + { + $raw = $this->getPrivateKey() + ->withSignatureFormat('IEEE') + ->withHash('sha256') + ->sign($signatureBase); + + return $this->base64urlEncode($raw); + } + + /** + * {@inheritdoc} + */ + public function verify(string $signatureBase, string $signature): bool + { + $raw = $this->base64urlDecode($signature); + if ($raw === '') { + return false; + } + + // 現用鍵 + grace period の旧公開鍵すべてに対して検証を試みる. + foreach ($this->collectPublicKeys() as $publicKey) { + try { + $verified = $publicKey + ->withSignatureFormat('IEEE') + ->withHash('sha256') + ->verify($signatureBase, $raw); + } catch (\Throwable) { + // 不正な署名長などで例外が出る実装差異を吸収し, 次の鍵へ. + continue; + } + + if ($verified) { + return true; + } + } + + return false; + } + + /** + * {@inheritdoc} + */ + public function getPublicJwks(): array + { + $jwks = []; + foreach ($this->collectPublicKeys() as $publicKey) { + $jwks[] = $this->toPublicJwk($publicKey); + } + + return $jwks; + } + + /** + * {@inheritdoc} + */ + public function getCurrentKid(): string + { + return $this->thumbprint($this->getCurrentPublicKey()); + } + + /** + * 現用秘密鍵を取得する. 鍵ストアに無ければ EC P-256 を生成して永続化する. + */ + private function getPrivateKey(): PrivateKey + { + if ($this->privateKey instanceof PrivateKey) { + return $this->privateKey; + } + + $pem = $this->keyStore->read($this->purpose); + if ($pem === null || trim($pem) === '') { + /** @var PrivateKey $generated */ + $generated = EC::createKey('secp256r1'); + $pem = $generated->toString('PKCS8'); + $this->keyStore->write($this->purpose, $pem); + $this->privateKey = $generated; + + return $this->privateKey; + } + + $loaded = PublicKeyLoader::load($pem); + if (!$loaded instanceof PrivateKey) { + throw new \RuntimeException(sprintf('鍵ストアの "%s" は EC 秘密鍵ではありません.', $this->purpose)); + } + + $this->privateKey = $loaded; + + return $this->privateKey; + } + + private function getCurrentPublicKey(): PublicKey + { + return $this->getPrivateKey()->getPublicKey(); + } + + /** + * 現用 + grace の公開鍵を返す. + * + * @return array + */ + private function collectPublicKeys(): array + { + $keys = [$this->getCurrentPublicKey()]; + + foreach ($this->gracePublicKeyPems as $pem) { + if (trim($pem) === '') { + continue; + } + $loaded = PublicKeyLoader::load($pem); + if ($loaded instanceof PrivateKey) { + $keys[] = $loaded->getPublicKey(); + } elseif ($loaded instanceof PublicKey) { + $keys[] = $loaded; + } + } + + return $keys; + } + + /** + * 公開鍵を EC JWK (連想配列) に変換する. 秘密鍵パラメータ d は含めない. + * + * @return array + */ + private function toPublicJwk(PublicKey $publicKey): array + { + $coords = $this->extractCoordinates($publicKey); + + $jwk = [ + 'kty' => 'EC', + 'crv' => 'P-256', + 'x' => $coords['x'], + 'y' => $coords['y'], + 'use' => 'sig', + 'alg' => 'ES256', + ]; + $jwk['kid'] = $this->thumbprintFromCoordinates($coords['x'], $coords['y']); + + return $jwk; + } + + /** + * 公開鍵から JWK の座標 (x, y; base64url) を抽出する. + * + * 主: phpseclib の toString('JWK') を利用. + * フォールバック (コメント): phpseclib のバージョン差で 'JWK' 出力のキー名が + * 異なる / 取得できない場合は, getEncodedCoordinates() 等で得た + * uncompressed point (0x04 || X(32) || Y(32)) を 32byte ずつに分割し, + * それぞれ base64url する実装に切り替えること. ここではまず JWK 出力を解析し, + * 取得不能な場合に uncompressed point から座標を復元する. + * + * @return array{x: string, y: string} + */ + private function extractCoordinates(PublicKey $publicKey): array + { + $x = null; + $y = null; + + try { + $jwkJson = $publicKey->toString('JWK'); + /** @var array|null $decoded */ + $decoded = json_decode($jwkJson, true); + if (is_array($decoded)) { + // JWK は {keys:[{...}]} か単体 {x,y} のいずれもあり得るため両対応. + if (isset($decoded['keys'][0]) && is_array($decoded['keys'][0])) { + $decoded = $decoded['keys'][0]; + } + if (isset($decoded['x']) && is_string($decoded['x'])) { + $x = $decoded['x']; + } + if (isset($decoded['y']) && is_string($decoded['y'])) { + $y = $decoded['y']; + } + } + } catch (\Throwable) { + // 下のフォールバックで座標を復元する. + } + + if ($x === null || $y === null) { + // phpseclib3 の EC 公開鍵は toString('JWK') で必ず x/y を返すため通常到達しない。 + throw new \RuntimeException('EC 公開鍵から JWK 座標を取得できませんでした.'); + } + + return ['x' => $x, 'y' => $y]; + } + + /** + * RFC 7638 JWK Thumbprint を kid として算出する. + */ + private function thumbprint(PublicKey $publicKey): string + { + $coords = $this->extractCoordinates($publicKey); + + return $this->thumbprintFromCoordinates($coords['x'], $coords['y']); + } + + /** + * RFC 7638: 必須メンバ (crv, kty, x, y) を辞書順・余白なし JSON にして SHA-256 する. + */ + private function thumbprintFromCoordinates(string $x, string $y): string + { + // キー昇順 (crv, kty, x, y), 余白なし. + $canonical = json_encode([ + 'crv' => 'P-256', + 'kty' => 'EC', + 'x' => $x, + 'y' => $y, + ], JSON_UNESCAPED_SLASHES); + + return $this->base64urlEncode(hash('sha256', (string) $canonical, true)); + } + + private function base64urlEncode(string $binary): string + { + return rtrim(strtr(base64_encode($binary), '+/', '-_'), '='); + } + + private function base64urlDecode(string $value): string + { + $decoded = base64_decode(strtr($value, '-_', '+/'), true); + + return $decoded === false ? '' : $decoded; + } +} diff --git a/src/Eccube/Service/AgentCommerce/StorefrontUrlResolver.php b/src/Eccube/Service/AgentCommerce/StorefrontUrlResolver.php new file mode 100644 index 00000000000..ea56695b35e --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/StorefrontUrlResolver.php @@ -0,0 +1,60 @@ +urlGenerator->generate('help_privacy', [], UrlGeneratorInterface::ABSOLUTE_URL); + } + + /** + * 利用規約ページの絶対 URL. + */ + public function termsOfServiceUrl(): string + { + return $this->urlGenerator->generate('help_agreement', [], UrlGeneratorInterface::ABSOLUTE_URL); + } + + /** + * 注文の参照先 (permalink) 絶対 URL. + * + * UCP complete レスポンスの `order.permalink_url` に用いる。標準ではマイページの注文履歴 + * (`mypage_history`) を指す。ゲスト注文は会員ログインへ誘導されるが URL 自体は絶対 URL として有効。 + * 独自の注文確認ページへ差し替えたい場合は本サービスを decoration する。 + */ + public function orderPermalinkUrl(string $orderNo): string + { + return $this->urlGenerator->generate('mypage_history', ['order_no' => $orderNo], UrlGeneratorInterface::ABSOLUTE_URL); + } +} diff --git a/src/Eccube/Service/AgentCommerce/Ucp/Signature/Rfc9421SignatureBaseBuilder.php b/src/Eccube/Service/AgentCommerce/Ucp/Signature/Rfc9421SignatureBaseBuilder.php new file mode 100644 index 00000000000..ea29e5781ca --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Ucp/Signature/Rfc9421SignatureBaseBuilder.php @@ -0,0 +1,97 @@ +": ` 行へ展開し、最後に `"@signature-params": ` 行を付す。 + * UCP checkout が署名対象とするコンポーネント (@method/@authority/@path、および + * ucp-agent/idempotency-key/content-digest/content-type ヘッダ) を解決できる。 + * + * @see https://www.rfc-editor.org/rfc/rfc9421 RFC 9421 Section 2.5 (Creating the Signature Base) + * @see https://github.com/Universal-Commerce-Protocol/ucp UCP signatures.md + */ +class Rfc9421SignatureBaseBuilder +{ + /** + * signature base を構築する. + * + * @param Request $request 署名対象のリクエスト + * @param list $coveredComponents covered component 識別子 (小文字・派生は先頭 @) + * @param string $signatureParams `@signature-params` の値 (例: `("@method" "@path");created=...;keyid="..."`) + * + * @throws \InvalidArgumentException 解決できない component が含まれる場合 + */ + public function build(Request $request, array $coveredComponents, string $signatureParams): string + { + $lines = []; + foreach ($coveredComponents as $component) { + $value = $this->resolveComponent($request, $component); + $lines[] = sprintf('"%s": %s', $component, $value); + } + + $lines[] = sprintf('"@signature-params": %s', $signatureParams); + + return implode("\n", $lines); + } + + /** + * covered component の値を解決する. + */ + private function resolveComponent(Request $request, string $component): string + { + return match ($component) { + '@method' => strtoupper($request->getMethod()), + '@authority' => strtolower($request->getHttpHost()), + '@path' => $this->path($request), + '@query' => '?'.($request->getQueryString() ?? ''), + '@scheme' => strtolower($request->getScheme()), + '@target-uri' => $request->getUri(), + default => $this->resolveHeader($request, $component), + }; + } + + /** + * 派生コンポーネント以外はヘッダ値として解決する (RFC 9421 では小文字のフィールド名). + */ + private function resolveHeader(Request $request, string $component): string + { + if (str_starts_with($component, '@')) { + throw new \InvalidArgumentException(sprintf('Unsupported derived component "%s".', $component)); + } + + $value = $request->headers->get($component); + if ($value === null) { + throw new \InvalidArgumentException(sprintf('Signed header "%s" is missing from the request.', $component)); + } + + // RFC 9421 Section 2.1: 先頭末尾の OWS を除去する (obs-fold は HTTP/1.1 で既に除去済み想定)。 + return trim($value); + } + + /** + * リクエストターゲットの絶対パス部分 (@path) を返す. + */ + private function path(Request $request): string + { + $requestUri = $request->getRequestUri(); + $queryPos = strpos($requestUri, '?'); + + return $queryPos === false ? $requestUri : substr($requestUri, 0, $queryPos); + } +} diff --git a/src/Eccube/Service/AgentCommerce/Ucp/Signature/UcpAgentHeader.php b/src/Eccube/Service/AgentCommerce/Ucp/Signature/UcpAgentHeader.php new file mode 100644 index 00000000000..c3210eba305 --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Ucp/Signature/UcpAgentHeader.php @@ -0,0 +1,63 @@ +profileUrl, PHP_URL_HOST); + + return is_string($host) ? strtolower($host) : ''; + } +} diff --git a/src/Eccube/Service/AgentCommerce/Ucp/Signature/UcpProfileFetcher.php b/src/Eccube/Service/AgentCommerce/Ucp/Signature/UcpProfileFetcher.php new file mode 100644 index 00000000000..5f13ace5cc4 --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Ucp/Signature/UcpProfileFetcher.php @@ -0,0 +1,154 @@ +|false) ホスト名 -> 解決 IP 群を返すリゾルバ + */ + private readonly \Closure $hostResolver; + + /** + * @param HttpClientInterface $httpClient profile 取得クライアント + * @param (callable(string): (list|false))|null $hostResolver A レコード解決器 (既定は gethostbynamel、テストで差し替え可) + */ + public function __construct( + private readonly HttpClientInterface $httpClient, + ?callable $hostResolver = null, + ) { + $this->hostResolver = $hostResolver !== null + ? \Closure::fromCallable($hostResolver) + : gethostbynamel(...); + } + + /** + * profile URL から signing_keys[] (JWK 配列) を取得する. + * + * @return list> 公開鍵 JWK の配列 + * + * @throws UcpSignatureException 取得失敗 / 文書不正 / 鍵が無い場合 + */ + public function fetchSigningKeys(string $profileUrl): array + { + if (parse_url($profileUrl, PHP_URL_SCHEME) !== 'https') { + throw new UcpSignatureException('Agent profile URL must be HTTPS.'); + } + + // SSRF 対策: profile URL は UCP-Agent ヘッダ由来 (エージェント供給) のため、 + // 内部アドレスへの到達を HTTP リクエスト前に遮断する。許可ドメインリスト + // (UcpRequestSignatureVerifier 側) と併用する多層防御。 + $host = parse_url($profileUrl, PHP_URL_HOST); + if (!is_string($host) || $host === '') { + throw new UcpSignatureException('Agent profile URL has no host.'); + } + $this->assertPublicHost($host); + + try { + $response = $this->httpClient->request('GET', $profileUrl, [ + // 鍵探索ではリダイレクトを追従しない (なりすまし防止)。 + 'max_redirects' => 0, + 'headers' => ['Accept' => 'application/json'], + // 応答が無いエージェント profile で署名検証がハングしないよう上限を設ける + // (timeout=接続/アイドル, max_duration=総時間)。 + 'timeout' => 5, + 'max_duration' => 10, + ]); + + if ($response->getStatusCode() !== 200) { + // 上流ステータスコードはそのまま返さない (内部探索のオラクルを避ける)。 + throw new UcpSignatureException('Agent profile fetch did not return a successful response.'); + } + + /** @var array $document */ + $document = $response->toArray(false); + } catch (UcpSignatureException $e) { + throw $e; + } catch (HttpClientExceptionInterface|\JsonException $e) { + throw new UcpSignatureException('Failed to fetch or parse the agent profile.', 0, $e); + } + + return $this->extractSigningKeys($document); + } + + /** + * ホストが公開アドレスへ解決されることを確認する (loopback/RFC1918/link-local/reserved を拒否). + * + * SSRF 対策の中核。IP リテラルはそのまま、ドメインは A レコード解決後の全 IP を検査する。 + * 注: 解決と HttpClient の名前解決の間の DNS rebinding は残存リスクであり、厳格な運用では + * UcpRequestSignatureVerifier のドメイン許可リスト併用を推奨する。IPv6 のみのホストは + * gethostbynamel() が解決できず fail-closed (拒否) となる。 + */ + private function assertPublicHost(string $host): void + { + // IP リテラル (v4/v6) はそのまま、ホスト名は A レコードを解決する。 + $ips = filter_var($host, FILTER_VALIDATE_IP) !== false ? [$host] : (($this->hostResolver)($host) ?: []); + if ($ips === []) { + throw new UcpSignatureException('Agent profile host could not be resolved to a public address.'); + } + + foreach ($ips as $ip) { + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) { + throw new UcpSignatureException('Agent profile host resolves to a non-public address.'); + } + } + } + + /** + * プロファイル文書から signing_keys[] を抽出する. + * + * @param array $document + * + * @return list> + */ + private function extractSigningKeys(array $document): array + { + // signing_keys はラッパー直下、または ucp オブジェクト配下のいずれもあり得るため両対応。 + $signingKeys = $document['signing_keys'] ?? null; + if (!is_array($signingKeys) && isset($document['ucp']) && is_array($document['ucp'])) { + $signingKeys = $document['ucp']['signing_keys'] ?? null; + } + + if (!is_array($signingKeys) || $signingKeys === []) { + throw new UcpSignatureException('Agent profile does not advertise any signing_keys.'); + } + + $keys = []; + foreach ($signingKeys as $key) { + if (is_array($key) && isset($key['kty']) && $key['kty'] === 'EC') { + /** @var array $key */ + $keys[] = $key; + } + } + + if ($keys === []) { + throw new UcpSignatureException('Agent profile has no EC public keys in signing_keys.'); + } + + return $keys; + } +} diff --git a/src/Eccube/Service/AgentCommerce/Ucp/Signature/UcpRequestSignatureVerifier.php b/src/Eccube/Service/AgentCommerce/Ucp/Signature/UcpRequestSignatureVerifier.php new file mode 100644 index 00000000000..945feca0f90 --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Ucp/Signature/UcpRequestSignatureVerifier.php @@ -0,0 +1,266 @@ + 許可するエージェント profile ドメイン (空なら HTTPS である限り制限しない) + */ + private readonly array $allowedDomains; + + /** + * @param UcpProfileFetcher $profileFetcher エージェント公開鍵の取得元 + * @param Rfc9421SignatureBaseBuilder $signatureBaseBuilder signature base 構築 + * @param list $allowedDomains 許可ドメインのホスト名リスト + */ + public function __construct( + private readonly UcpProfileFetcher $profileFetcher, + private readonly Rfc9421SignatureBaseBuilder $signatureBaseBuilder, + array $allowedDomains = [], + ) { + $this->allowedDomains = array_map(strtolower(...), $allowedDomains); + } + + /** + * リクエストが RFC 9421 署名を提示しているか. + */ + public function isSigned(Request $request): bool + { + return $request->headers->has('Signature') && $request->headers->has('Signature-Input'); + } + + /** + * リクエストの署名を検証する. 失敗時は例外を投げる. + * + * @return UcpAgentHeader 検証済みのエージェント情報 + * + * @throws UcpSignatureException 署名欠落・不正・鍵不一致・Content-Digest 不一致・許可外ドメイン + */ + public function verify(Request $request): UcpAgentHeader + { + $ucpAgentValue = $request->headers->get('UCP-Agent'); + if ($ucpAgentValue === null || $ucpAgentValue === '') { + throw new UcpSignatureException('UCP-Agent header is required.'); + } + + try { + $agent = UcpAgentHeader::parse($ucpAgentValue); + } catch (\InvalidArgumentException $e) { + throw new UcpSignatureException($e->getMessage(), 0, $e); + } + + $this->assertDomainAllowed($agent->host()); + + if (!$this->isSigned($request)) { + throw new UcpSignatureException('Signature and Signature-Input headers are required.'); + } + + $this->verifyContentDigest($request); + + [$components, $signatureParams, $keyId] = $this->parseSignatureInput((string) $request->headers->get('Signature-Input')); + $rawSignature = $this->parseSignature((string) $request->headers->get('Signature')); + + try { + $signatureBase = $this->signatureBaseBuilder->build($request, $components, $signatureParams); + } catch (\InvalidArgumentException $e) { + throw new UcpSignatureException($e->getMessage(), 0, $e); + } + + $jwks = $this->profileFetcher->fetchSigningKeys($agent->profileUrl); + if (!$this->verifyAgainstKeys($signatureBase, $rawSignature, $jwks, $keyId)) { + throw new UcpSignatureException('RFC 9421 signature verification failed.'); + } + + return $agent; + } + + private function assertDomainAllowed(string $host): void + { + if ($this->allowedDomains === []) { + return; + } + + if (!in_array($host, $this->allowedDomains, true)) { + throw new UcpSignatureException(sprintf('Agent domain "%s" is not allowed.', $host)); + } + } + + /** + * Content-Digest を生ボディの SHA-256 と照合する (ボディが無ければスキップ). + */ + private function verifyContentDigest(Request $request): void + { + $body = $request->getContent(); + $header = $request->headers->get('Content-Digest'); + + if ($body === '' && $header === null) { + return; + } + + if ($header === null) { + throw new UcpSignatureException('Content-Digest header is required for requests with a body.'); + } + + // 形式: sha-256=:: + if (!preg_match('/sha-256=:([^:]+):/', $header, $matches)) { + throw new UcpSignatureException('Content-Digest must use the sha-256 algorithm.'); + } + + $expected = base64_encode(hash('sha256', $body, true)); + if (!hash_equals($expected, $matches[1])) { + throw new UcpSignatureException('Content-Digest does not match the request body.'); + } + } + + /** + * Signature-Input をパースして covered components / signature-params / keyid を返す. + * + * @return array{0: list, 1: string, 2: ?string} + */ + private function parseSignatureInput(string $headerValue): array + { + // 形式: