diff --git a/Controller/Admin/ConfigController.php b/Controller/Admin/ConfigController.php index 33f2872..557d8f9 100644 --- a/Controller/Admin/ConfigController.php +++ b/Controller/Admin/ConfigController.php @@ -94,7 +94,18 @@ public function index(Request $request, Connection $em) $formFile = $form['import_file']->getData(); - $tmpFile = $formFile->getClientOriginalName(); + $originalName = $formFile->getClientOriginalName(); + // 許可された拡張子のホワイトリスト(二重拡張子を先に判定) + $allowedExtensions = ['tar.gz', 'tar.bz2', 'tgz', 'tar', 'zip']; + $originalExtension = pathinfo($originalName, PATHINFO_EXTENSION); + $lowerName = strtolower($originalName); + foreach ($allowedExtensions as $ext) { + if (substr($lowerName, -strlen('.' . $ext)) === '.' . $ext) { + $originalExtension = $ext; + break; + } + } + $tmpFile = 'import_' . bin2hex(random_bytes(8)) . '.' . $originalExtension; $tmpDir = $this->pluginService->createTempDir(); $formFile->move($tmpDir, $tmpFile); @@ -159,8 +170,11 @@ public function index(Request $request, Connection $em) // 全データ移行 $this->saveCustomer($em, $csvDir); + $this->releaseCustomerPhaseMemory(); $this->saveProduct($em, $csvDir); + $this->releaseProductPhaseMemory(); $this->saveOrder($em, $csvDir); + $this->releaseOrderPhaseMemory(); } // plg_customerplusの移行処理を作る @@ -1056,8 +1070,14 @@ protected function upsertAuthorityAndMember($em, $dir) ]); } if ($hasMember) { - // 既存メンバーを一旦非稼働化 - $em->exec('UPDATE dtb_member SET work_id = 0'); + // 2.x系の場合はDELETE+INSERTで全置換(MySQL saveToC と同等) + $is2xData = !$this->dataMigrationService->isVersion('4.0/4.1'); + if ($is2xData) { + $this->dataMigrationService->resetTable($em, 'dtb_member'); + } else { + // 4.0/4.1系:既存メンバーを一旦非稼働化してUPSERT + $em->exec('UPDATE dtb_member SET work_id = 0'); + } // CSV を読み取り UPSERT $file = $memberCsv; if (($handle = fopen($file, 'r')) === false) { @@ -1085,7 +1105,25 @@ protected function upsertAuthorityAndMember($em, $dir) $insertValues[$col] = $data[$col] ?? 'member'; continue; } - // 4.0系のカラム名マッピング + // 2.x系: member_id → id + if ($col === 'id' && !array_key_exists('id', $data) && array_key_exists('member_id', $data)) { + $insertValues[$col] = $data['member_id']; + continue; + } + // 2.x系: authority → authority_id, work → work_id + if ($col === 'authority_id' && !array_key_exists($col, $data) && array_key_exists('authority', $data)) { + $insertValues[$col] = $data['authority']; + continue; + } + if ($col === 'work_id' && !array_key_exists($col, $data) && array_key_exists('work', $data)) { + $insertValues[$col] = ($data['del_flg'] ?? '0') === '1' ? 0 : $data['work']; + continue; + } + if ($col === 'sort_no' && !array_key_exists($col, $data) && array_key_exists('rank', $data)) { + $insertValues[$col] = $data['rank']; + continue; + } + // 4.0系: work_id → work, authority_id → authority if ($col === 'work' && !array_key_exists($col, $data) && array_key_exists('work_id', $data)) { $insertValues[$col] = $data['work_id']; continue; @@ -1119,8 +1157,14 @@ protected function upsertAuthorityAndMember($em, $dir) $insertValues['two_factor_auth_enabled'] = 0; } if (array_key_exists('two_factor_auth_key', $insertValues) && $insertValues['two_factor_auth_key'] === null) { - $insertValues['two_factor_auth_key'] = null; // NULL許可(この行は冗長だが明示的に残す) + $insertValues['two_factor_auth_key'] = null; } + // saveToC と同等: creator_id のデフォルト値 + if (array_key_exists('creator_id', $insertValues) && empty($insertValues['creator_id'])) { + $insertValues['creator_id'] = 1; + } + // PostgreSQL用の型変換 + $insertValues = $this->dataMigrationService->convertDataTypesForPostgreSQL($em, 'dtb_member', $insertValues); $colsSql = implode(',', array_map(fn($c) => '"' . $c . '"', array_keys($insertValues))); $placeholders = implode(',', array_fill(0, count($insertValues), '?')); $updateSql = implode(', ', array_map(fn($c) => '"' . $c . '" = EXCLUDED."' . $c . '"', $updateCols)); @@ -1128,6 +1172,8 @@ protected function upsertAuthorityAndMember($em, $dir) $em->prepare($sql)->executeStatement(array_values($insertValues)); } fclose($handle); + // PostgreSQL: シーケンスを最大ID+1にリセット + $this->dataMigrationService->setIdSeq($em, 'dtb_member'); } // メンバーIDキャッシュ try { @@ -1832,8 +1878,10 @@ private function saveToO($em, $tmpDir, $csvName, $tableName = null, $allow_zero } } - // shippingに紐付けるデータを保持 - $this->shipping_order[$data['id']] = $data; + // shippingに紐付けるデータを保持(必要なフィールドのみ) + $this->shipping_order[$data['id']] = [ + 'commit_date' => $data['commit_date'] ?? null, + ]; break; @@ -2343,4 +2391,30 @@ private function fix4x($em, $tmpDir, $csvName) return $i; // indexを返す } } + + private function releaseCustomerPhaseMemory(): void + { + $this->customer_point = []; + gc_collect_cycles(); + } + + private function releaseProductPhaseMemory(): void + { + $this->stock = []; + $this->product_images = []; + $this->dtb_class_combination = []; + $this->delivery_id = []; + $this->product_class_id = []; + gc_collect_cycles(); + } + + private function releaseOrderPhaseMemory(): void + { + $this->order_item = []; + $this->shipping_id = []; + $this->shipping_order = []; + $this->tax_rule = []; + $this->delivery_time = []; + gc_collect_cycles(); + } } diff --git a/Service/DataMigrationService.php b/Service/DataMigrationService.php index 8493e03..04e61ce 100644 --- a/Service/DataMigrationService.php +++ b/Service/DataMigrationService.php @@ -44,6 +44,12 @@ class DataMigrationService */ private $mappingCache = []; + /** + * テーブルカラム情報のキャッシュ(テーブル名 => カラム配列) + * @var array + */ + private $tableColumnsCache = []; + /** * Customer item option data for migration * @var array @@ -74,6 +80,15 @@ public function setMigrationVersion($em, $tmpDir, $tmpFile) { $archive = UnifiedArchive::open($tmpDir . '/' . $tmpFile); $fileNames = $archive->getFileNames(); + + // パストラバーサル防御: アーカイブ内のエントリ名を検証 + foreach ($fileNames as $entry) { + $normalized = str_replace('\\', '/', $entry); + if (strpos($normalized, '..') !== false || strpos($normalized, '/') === 0) { + throw new \RuntimeException('アーカイブに不正なパスが含まれています: ' . basename($entry)); + } + } + // 解凍 $archive->extractFiles($tmpDir, $fileNames); @@ -178,7 +193,10 @@ public function convertDataTypesForPostgreSQL($em, $tableName, $data) try { - $columns = $em->getSchemaManager()->listTableColumns($tableName); + if (!isset($this->tableColumnsCache[$tableName])) { + $this->tableColumnsCache[$tableName] = $em->getSchemaManager()->listTableColumns($tableName); + } + $columns = $this->tableColumnsCache[$tableName]; $hasConversion = false; foreach ($data as $key => &$value) { diff --git a/Tests/Fixtures/member_test.tar.gz b/Tests/Fixtures/member_test.tar.gz index b2bbbd3..4c899b0 100644 Binary files a/Tests/Fixtures/member_test.tar.gz and b/Tests/Fixtures/member_test.tar.gz differ diff --git a/Tests/Web/Admin/ConfigControllerTest.php b/Tests/Web/Admin/ConfigControllerTest.php index b8fe2f4..f3c3dcd 100644 --- a/Tests/Web/Admin/ConfigControllerTest.php +++ b/Tests/Web/Admin/ConfigControllerTest.php @@ -107,6 +107,307 @@ public function testバックアップファイルをアップロードできる } } + /** + * 移行を実行するヘルパー。フィクスチャ名を指定して POST → DBAL Connection を返す。 + */ + private function performMigration(string $fixture, array $extraPost = []): \Doctrine\DBAL\Connection + { + $container = self::getContainer(); + $project_dir = $container->getParameter('kernel.project_dir'); + + $file = $project_dir . '/app/Plugin/DataMigration43/Tests/Fixtures/' . $fixture . '.tar.gz'; + $testFile = $project_dir . '/app/Plugin/DataMigration43/Tests/Fixtures/test.tar.gz'; + + $fs = new Filesystem(); + $fs->copy($file, $testFile); + + $file = new UploadedFile($testFile, 'test.tar.gz', 'application/x-tar', null, true); + + $post = [ + 'config' => array_merge([ + Constant::TOKEN_NAME => 'dummy', + 'import_file' => $file, + 'auth_magic' => 'dummy', + ], $extraPost), + ]; + + $this->client->request( + 'POST', + $this->generateUrl('data_migration43_admin_config'), + $post, + ['config' => ['import_file' => $file]] + ); + + $response = $this->client->getResponse(); + $statusCode = $response->getStatusCode(); + if ($statusCode === Response::HTTP_INTERNAL_SERVER_ERROR) { + // 500エラー時はレスポンスボディからエラー情報を抽出 + $body = $response->getContent(); + // HTMLからエラーメッセージを抽出 + $errorMsg = ''; + if (preg_match('/]*class="exception-message[^"]*"[^>]*>(.*?)<\/h1>/s', $body, $m)) { + $errorMsg = strip_tags($m[1]); + } elseif (preg_match('/(.*?)<\/title>/s', $body, $m)) { + $errorMsg = strip_tags($m[1]); + } + self::fail("移行リクエストが500エラーを返しました: " . $errorMsg); + } + self::assertTrue( + $statusCode === Response::HTTP_FOUND || $statusCode === Response::HTTP_OK, + "移行リクエストが予期しないステータス {$statusCode} を返しました" + ); + + // EntityManagerのIDマップをクリアし、最新のDB状態を取得可能にする + $this->entityManager->clear(); + + return $this->entityManager->getConnection(); + } + + public function test2系のデータ移行内容が正しいこと() + { + try { + $conn = $this->performMigration('2_13_5'); + + // --- 会員 --- + $customer = $conn->fetchAssociative('SELECT * FROM dtb_customer WHERE id = ?', [1]); + self::assertNotFalse($customer, '会員id=1が存在すること'); + self::assertSame('てすと', $customer['name01']); + self::assertSame('たろう', $customer['name02']); + self::assertSame('テスト', $customer['kana01']); + self::assertSame('タロウ', $customer['kana02']); + self::assertSame('hoge@example.com', $customer['email']); + self::assertSame('7772222', $customer['postal_code']); + self::assertEquals(2, (int) $customer['sex_id']); + self::assertEquals(12, (int) $customer['job_id']); + self::assertEquals(6, (int) $customer['pref_id']); + // status=2, del_flg=0 → customer_status_id=2 + self::assertEquals(2, (int) $customer['customer_status_id']); + self::assertSame('customer', $customer['discriminator_type']); + + // --- 商品 --- + $products = $conn->fetchAllAssociative('SELECT * FROM dtb_product ORDER BY id'); + self::assertCount(3, $products); + self::assertSame('アイスクリーム', $products[0]['name']); + self::assertSame('おなべ', $products[1]['name']); + self::assertSame('おなべレシピ', $products[2]['name']); + + // product_status_id: status=1, del_flg=0 → 1 + foreach ($products as $p) { + self::assertEquals(1, (int) $p['product_status_id'], $p['name'] . 'のproduct_status_idが1であること'); + } + + // product_class: 各商品にvisible=trueのレコードが存在 + foreach ([1, 2, 3] as $pid) { + $visibleCount = (int) $conn->fetchOne( + 'SELECT COUNT(*) FROM dtb_product_class WHERE product_id = ? AND visible = true', + [$pid] + ); + self::assertGreaterThan(0, $visibleCount, "product_id={$pid}にvisible=trueのproduct_classが存在すること"); + } + + // product_stock: 在庫レコードが存在 + $stockCount = (int) $conn->fetchOne('SELECT COUNT(*) FROM dtb_product_stock'); + self::assertGreaterThan(0, $stockCount, '在庫レコードが存在すること'); + + // product_image: 画像レコードが存在(2.xはインラインカラムから変換) + $imageCount = (int) $conn->fetchOne('SELECT COUNT(*) FROM dtb_product_image'); + self::assertGreaterThan(0, $imageCount, '商品画像レコードが存在すること'); + + // --- 受注 --- + $orders = $conn->fetchAllAssociative('SELECT * FROM dtb_order ORDER BY id'); + self::assertCount(2, $orders); + // order_id=1: 会員注文 + self::assertEquals(1, (int) $orders[0]['customer_id'], '受注1はcustomer_id=1であること'); + // order_id=2: ゲスト (customer_id=0→NULL) + self::assertNull($orders[1]['customer_id'], '受注2はゲスト注文のためcustomer_id=NULLであること'); + + foreach ($orders as $order) { + self::assertSame('JPY', $order['currency_code'], 'currency_codeがJPYであること'); + self::assertNotNull($order['order_date'], 'order_dateが設定されていること'); + } + + // order_item: 商品明細(type=1)が存在 + $productItems = (int) $conn->fetchOne( + 'SELECT COUNT(*) FROM dtb_order_item WHERE order_item_type_id = 1' + ); + self::assertGreaterThan(0, $productItems, '商品明細(type=1)が存在すること'); + + // order_item: 送料(type=2)が生成されること + $delivFeeItems = (int) $conn->fetchOne( + 'SELECT COUNT(*) FROM dtb_order_item WHERE order_item_type_id = 2' + ); + self::assertGreaterThan(0, $delivFeeItems, '送料(type=2)のorder_itemが生成されること'); + + // tax計算が行われていること (tax > 0) + $taxItems = (int) $conn->fetchOne( + 'SELECT COUNT(*) FROM dtb_order_item WHERE tax > 0' + ); + self::assertGreaterThan(0, $taxItems, 'taxが計算されていること'); + + // --- 配送 --- + $shippings = $conn->fetchAllAssociative('SELECT * FROM dtb_shipping ORDER BY id'); + self::assertCount(2, $shippings); + // delivery_idがorderのdeliv_idから正しく設定されていること + foreach ($shippings as $shipping) { + self::assertNotNull($shipping['delivery_id'], 'delivery_idが設定されていること'); + } + + // --- カテゴリ --- + $categoryCount = (int) $conn->fetchOne('SELECT COUNT(*) FROM dtb_category'); + self::assertGreaterThanOrEqual(6, $categoryCount, 'カテゴリが6件以上存在すること'); + + // --- 税率 --- + $taxRate = $conn->fetchOne( + 'SELECT tax_rate FROM dtb_tax_rule WHERE product_id IS NULL AND product_class_id IS NULL LIMIT 1' + ); + self::assertEquals(8, (int) $taxRate, '税率が8%であること'); + + } catch (\Exception $e) { + if ($this->entityManager->getConnection()->isTransactionActive()) { + $this->entityManager->getConnection()->rollBack(); + $this->entityManager->getConnection()->beginTransaction(); + } + throw $e; + } + } + + public function test3系のデータ移行内容が正しいこと() + { + try { + $conn = $this->performMigration('3_0_18'); + + // --- 会員 --- + $customer = $conn->fetchAssociative('SELECT * FROM dtb_customer WHERE id = ?', [1]); + self::assertNotFalse($customer, '会員id=1が存在すること'); + self::assertSame('足立', $customer['name01']); + self::assertSame('智広', $customer['name02']); + self::assertSame('chihiro_adachi@ec-cube.co.jp', $customer['email']); + self::assertEquals(1, (int) $customer['pref_id']); + + // --- 商品 --- + $products = $conn->fetchAllAssociative('SELECT * FROM dtb_product ORDER BY id'); + self::assertCount(2, $products); + self::assertSame('ディナーフォーク', $products[0]['name']); + self::assertSame('パーコレーター', $products[1]['name']); + + // product_classが存在 + foreach ([1, 2] as $pid) { + $pcCount = (int) $conn->fetchOne( + 'SELECT COUNT(*) FROM dtb_product_class WHERE product_id = ?', + [$pid] + ); + self::assertGreaterThan(0, $pcCount, "product_id={$pid}にproduct_classが存在すること"); + } + + // --- 受注 --- + $orderCount = (int) $conn->fetchOne('SELECT COUNT(*) FROM dtb_order'); + self::assertEquals(4, $orderCount, '受注が4件であること'); + + // order_item: 商品明細が存在 + $productItems = (int) $conn->fetchOne( + 'SELECT COUNT(*) FROM dtb_order_item WHERE order_item_type_id = 1' + ); + self::assertGreaterThan(0, $productItems, '商品明細(type=1)が存在すること'); + + // --- 配送 --- + $shippingCount = (int) $conn->fetchOne('SELECT COUNT(*) FROM dtb_shipping'); + self::assertGreaterThanOrEqual(5, $shippingCount, '配送が5件以上であること(order 4がマルチ配送)'); + + // order 4のshippingが2件であること + $order4ShippingCount = (int) $conn->fetchOne( + 'SELECT COUNT(*) FROM dtb_shipping WHERE order_id = ?', + [4] + ); + self::assertEquals(2, $order4ShippingCount, 'order 4のshippingが2件であること(マルチ配送)'); + + // delivery_idが設定されていること + $nullDeliveryCount = (int) $conn->fetchOne( + 'SELECT COUNT(*) FROM dtb_shipping WHERE delivery_id IS NULL' + ); + self::assertEquals(0, $nullDeliveryCount, '全配送にdelivery_idが設定されていること'); + + // --- 税率 --- + $taxRate = $conn->fetchOne( + 'SELECT tax_rate FROM dtb_tax_rule WHERE product_id IS NULL AND product_class_id IS NULL LIMIT 1' + ); + self::assertEquals(8, (int) $taxRate, '税率が8%であること'); + + } catch (\Exception $e) { + if ($this->entityManager->getConnection()->isTransactionActive()) { + $this->entityManager->getConnection()->rollBack(); + $this->entityManager->getConnection()->beginTransaction(); + } + throw $e; + } + } + + public function test4系のデータ移行内容が正しいこと() + { + try { + $conn = $this->performMigration('4_1_2'); + + // --- 会員 --- + $customer = $conn->fetchAssociative('SELECT * FROM dtb_customer WHERE id = ?', [1]); + self::assertNotFalse($customer, '会員id=1が存在すること'); + self::assertSame('大垣', $customer['name01']); + self::assertSame('翼', $customer['name02']); + self::assertEquals(55784, (int) $customer['point'], 'pointが55784であること'); + + // --- 商品 --- + $productCount = (int) $conn->fetchOne('SELECT COUNT(*) FROM dtb_product'); + self::assertEquals(12, $productCount, '商品が12件であること'); + + // --- 受注 --- + $orderCount = (int) $conn->fetchOne('SELECT COUNT(*) FROM dtb_order'); + self::assertEquals(20, $orderCount, '受注が20件であること'); + + // --- 受注明細: 各種order_item_type_idが存在 --- + foreach ([1, 2, 3, 4] as $typeId) { + $count = (int) $conn->fetchOne( + 'SELECT COUNT(*) FROM dtb_order_item WHERE order_item_type_id = ?', + [$typeId] + ); + self::assertGreaterThan(0, $count, "order_item_type_id={$typeId}のレコードが存在すること"); + } + + } catch (\Exception $e) { + if ($this->entityManager->getConnection()->isTransactionActive()) { + $this->entityManager->getConnection()->rollBack(); + $this->entityManager->getConnection()->beginTransaction(); + } + throw $e; + } + } + + public function testメンバーのデータ移行内容が正しいこと() + { + try { + $conn = $this->performMigration('member_test'); + + // 全メンバーを確認 + $allMembers = $conn->fetchAllAssociative('SELECT id, name, login_id, authority_id FROM dtb_member ORDER BY id'); + self::assertGreaterThanOrEqual(2, count($allMembers), 'メンバーが2件以上存在すること (実際: ' . count($allMembers) . '件, IDs: ' . implode(',', array_column($allMembers, 'id')) . ')'); + + // login_idでメンバーを検索(IDはDB環境依存の可能性があるため) + $admin = $conn->fetchAssociative('SELECT * FROM dtb_member WHERE login_id = ?', ['testadmin']); + self::assertNotFalse($admin, 'login_id=testadminのメンバーが存在すること (全メンバーIDs: ' . implode(',', array_column($allMembers, 'id')) . ')'); + self::assertSame('テスト管理者', $admin['name']); + self::assertEquals(0, (int) $admin['authority_id'], 'testadminのauthority_idが0(システム管理者)であること'); + + $owner = $conn->fetchAssociative('SELECT * FROM dtb_member WHERE login_id = ?', ['testowner']); + self::assertNotFalse($owner, 'login_id=testownerのメンバーが存在すること'); + self::assertSame('テスト店舗オーナー', $owner['name']); + self::assertEquals(1, (int) $owner['authority_id'], 'testownerのauthority_idが1(店舗オーナー)であること'); + + } catch (\Exception $e) { + if ($this->entityManager->getConnection()->isTransactionActive()) { + $this->entityManager->getConnection()->rollBack(); + $this->entityManager->getConnection()->beginTransaction(); + } + throw $e; + } + } + /** * ECCUBE2Downloadsプラグインがインストール済みの場合、 * ダウンロード商品のsale_type_idが222に書き換わることをテスト