Browse Source

评论迁移脚本

chengwl 1 ngày trước cách đây
mục cha
commit
25769a9862

+ 384 - 0
app/Console/Commands/MigrateAsteriaReviews.php

@@ -0,0 +1,384 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Jobs\MigrateReviewJob;
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Schema;
+
+/**
+ * Migrates product reviews from Asteria (Magento 1.x) into this Bagisto store.
+ *
+ * Prerequisites
+ * ─────────────
+ * 1. Add the `asteria` database connection to config/database.php (see the
+ *    comment block at the bottom of that file or the README).
+ * 2. Add the `migrated_from_asteria_id` column to product_reviews:
+ *
+ *      php artisan migrate  (after creating the migration below)
+ *
+ *    Or run this one-off SQL directly if you prefer:
+ *
+ *      ALTER TABLE product_reviews
+ *        ADD COLUMN migrated_from_asteria_id BIGINT UNSIGNED NULL DEFAULT NULL,
+ *        ADD UNIQUE INDEX uq_migrated_asteria_id (migrated_from_asteria_id);
+ *
+ * 3. Ensure a queue worker is running:
+ *
+ *      php artisan queue:work --queue=review-migration
+ *
+ * Usage
+ * ─────
+ * php artisan reviews:migrate-asteria
+ * php artisan reviews:migrate-asteria --batch-size=200
+ * php artisan reviews:migrate-asteria --status=1          # approved only
+ * php artisan reviews:migrate-asteria --reset-progress    # start from scratch
+ * php artisan reviews:migrate-asteria --dry-run           # dispatch nothing, just show stats
+ * php artisan reviews:migrate-asteria --sync              # run synchronously (no queue)
+ */
+class MigrateAsteriaReviews extends Command
+{
+    protected $signature = 'reviews:migrate-asteria
+        {--batch-size=100     : Number of reviews per queue job}
+        {--status=            : Filter by Magento status_id (1=approved,2=pending,3=not-approved)}
+        {--reset-progress     : Ignore saved progress and start from review_id=0}
+        {--dry-run            : Count records without dispatching any jobs}
+        {--sync               : Process synchronously instead of via the queue}
+        {--connection=asteria : Name of the Laravel DB connection for the Asteria database}';
+
+    protected $description = 'Migrate Asteria (Magento 1.x) product reviews into Bagisto asynchronously';
+
+    /** Cache key used to store the last successfully enqueued review_id. */
+    private const PROGRESS_KEY = 'migrate_asteria_reviews_last_id';
+
+    /** Default queue name for the migration jobs. */
+    private const QUEUE_NAME = 'review-migration';
+
+    public function handle(): int
+    {
+        $connection  = (string) $this->option('connection');
+        $batchSize   = max(1, (int) $this->option('batch-size'));
+        $statusId    = $this->option('status') !== null ? (int) $this->option('status') : null;
+        $resetProgress = (bool) $this->option('reset-progress');
+        $dryRun      = (bool) $this->option('dry-run');
+        $sync        = (bool) $this->option('sync');
+
+        // ── Pre-flight: make sure the connection and source tables exist ─────────
+        try {
+            DB::connection($connection)->getPdo();
+        } catch (\Throwable $e) {
+            $this->error("Cannot connect to Asteria DB (connection='{$connection}'): ".$e->getMessage());
+            $this->warn('Add an "asteria" entry to config/database.php (see the comment at the end of that file).');
+
+            return self::FAILURE;
+        }
+
+        if (! $this->ensureMigrationColumnExists()) {
+            return self::FAILURE;
+        }
+
+        // ── Build product and customer lookup maps ────────────────────────────────
+        $this->info('Building product SKU map…');
+        $productIdMap = $this->buildProductIdMap($connection);
+        $this->line('  '.count($productIdMap).' Magento products matched to Bagisto products by SKU.');
+
+        if (empty($productIdMap)) {
+            $this->warn('No matching products found. Are the SKUs consistent between the two stores?');
+        }
+
+        $this->info('Building customer e-mail map…');
+        $customerEmailMap = $this->buildCustomerEmailMap($connection);
+        $this->line('  '.count($customerEmailMap).' Magento customers matched to Bagisto customers by e-mail.');
+
+        // ── Determine starting review_id ──────────────────────────────────────────
+        $lastId = $resetProgress ? 0 : (int) Cache::get(self::PROGRESS_KEY, 0);
+
+        if ($lastId > 0) {
+            $this->line("Resuming from Asteria review_id > {$lastId} (use --reset-progress to restart).");
+        }
+
+        // ── Dispatch batches ──────────────────────────────────────────────────────
+        $totalDispatched = 0;
+        $totalSkipped    = 0;
+        $batchNumber     = 0;
+
+        $this->info($dryRun ? '[DRY RUN] Scanning reviews…' : 'Dispatching migration jobs…');
+
+        do {
+            $rows = $this->fetchBatch($connection, $lastId, $batchSize, $statusId);
+
+            if ($rows->isEmpty()) {
+                break;
+            }
+
+            $batchNumber++;
+            $batchRows = $rows->toArray();
+
+            // Collect unique Magento product IDs in this batch, filter unknown ones.
+            $unknownProducts = array_filter(
+                array_unique(array_column($batchRows, 'magento_product_id')),
+                fn ($id) => ! isset($productIdMap[$id])
+            );
+
+            if (! empty($unknownProducts)) {
+                $this->warn("  Batch #{$batchNumber}: ".count($unknownProducts).
+                    ' Magento product(s) not found in Bagisto: '.implode(', ', array_slice($unknownProducts, 0, 10)));
+                $totalSkipped += count(array_filter($batchRows, fn ($r) => ! isset($productIdMap[$r->magento_product_id])));
+            }
+
+            $lastId = max(array_column($batchRows, 'review_id'));
+
+            if (! $dryRun) {
+                if ($sync) {
+                    (new MigrateReviewJob(
+                        array_map(fn ($r) => (array) $r, $batchRows),
+                        $productIdMap,
+                        $customerEmailMap,
+                    ))->handle();
+                } else {
+                    MigrateReviewJob::dispatch(
+                        array_map(fn ($r) => (array) $r, $batchRows),
+                        $productIdMap,
+                        $customerEmailMap,
+                    )->onQueue(self::QUEUE_NAME);
+                }
+
+                Cache::put(self::PROGRESS_KEY, $lastId, now()->addDays(30));
+            }
+
+            $totalDispatched += count($batchRows);
+
+            $this->line(sprintf(
+                '  Batch #%d: %d reviews (last review_id=%d)%s',
+                $batchNumber,
+                count($batchRows),
+                $lastId,
+                $dryRun ? ' [skipped – dry-run]' : ($sync ? ' [processed]' : ' [enqueued]'),
+            ));
+
+            Log::info('MigrateAsteriaReviews: batch '.$batchNumber.', '.count($batchRows).' rows, last_id='.$lastId);
+
+        } while ($rows->count() === $batchSize);
+
+        // ── Summary ───────────────────────────────────────────────────────────────
+        $this->newLine();
+        $this->info("Done. Batches: {$batchNumber}, reviews processed/enqueued: {$totalDispatched}, unmatched: {$totalSkipped}.");
+
+        if (! $dryRun && ! $sync) {
+            $this->line('Run  <comment>php artisan queue:work --queue=review-migration</comment>  to process the queue.');
+        }
+
+        return self::SUCCESS;
+    }
+
+    // ─────────────────────────────────────────────────────────────────────────────
+    // Private helpers
+    // ─────────────────────────────────────────────────────────────────────────────
+
+    /**
+     * Fetch one page of Magento reviews, enriched with rating and customer e-mail.
+     *
+     * Magento table names use the default prefix (none).  If your Magento
+     * installation uses a prefix (e.g. `mag_`), set ASTERIA_DB_PREFIX in .env and
+     * pass it via the `prefix` key in the database connection config.
+     *
+     * @return \Illuminate\Support\Collection<int, object>
+     */
+    private function fetchBatch(
+        string $connection,
+        int $lastId,
+        int $limit,
+        ?int $statusId
+    ): \Illuminate\Support\Collection {
+        $query = DB::connection($connection)
+            ->table('review AS r')
+            ->join('review_detail AS rd', 'rd.review_id', '=', 'r.review_id')
+            ->leftJoin('customer_entity AS ce', 'ce.entity_id', '=', 'rd.customer_id')
+            ->leftJoinSub(
+                // Average all rating dimensions for each review (Quality, Value, Price…)
+                DB::connection($connection)
+                    ->table('rating_option_vote')
+                    ->select('review_id', DB::raw('AVG(`value`) AS avg_rating'))
+                    ->groupBy('review_id'),
+                'rv',
+                'rv.review_id',
+                '=',
+                'r.review_id'
+            )
+            ->select([
+                'r.review_id',
+                'r.created_at',
+                'r.entity_pk_value AS magento_product_id',
+                'r.status_id',
+                'rd.title',
+                'rd.detail AS comment',
+                'rd.nickname AS name',
+                'rd.customer_id AS magento_customer_id',
+                'ce.email AS customer_email',
+                'rv.avg_rating',
+            ])
+            ->where('r.entity_id', 1)   // entity_id=1 means "product" in Magento
+            ->where('r.review_id', '>', $lastId)
+            ->orderBy('r.review_id')
+            ->limit($limit);
+
+        if ($statusId !== null) {
+            $query->where('r.status_id', $statusId);
+        }
+
+        $reviews = $query->get();
+
+        if ($reviews->isEmpty()) {
+            return $reviews;
+        }
+
+        // Attach image URLs from Senje_Review's review_media_image table (status_id=1 = approved).
+        $reviewIds = $reviews->pluck('review_id')->toArray();
+
+        $imageRows = DB::connection($connection)
+            ->table('review_media_image')
+            ->select('review_id', 'image_id', 'url')
+            ->whereIn('review_id', $reviewIds)
+            ->where('status_id', 1)
+            ->orderBy('review_id')
+            ->orderBy('image_id')
+            ->get()
+            ->groupBy('review_id');
+
+        return $reviews->map(function ($row) use ($imageRows) {
+            $row->images = $imageRows->get($row->review_id, collect())
+                ->pluck('url')
+                ->toArray();
+
+            return $row;
+        });
+    }
+
+    /**
+     * Build a map of  magento_product_id → bagisto_product_id  by joining
+     * Magento's catalog_product_entity.sku against Bagisto's products.sku.
+     *
+     * @return array<int, int>
+     */
+    private function buildProductIdMap(string $connection): array
+    {
+        // Fetch all Magento SKUs.
+        $magentoPairs = DB::connection($connection)
+            ->table('catalog_product_entity')
+            ->select('entity_id', 'sku')
+            ->get()
+            ->pluck('entity_id', 'sku')  // sku → magento_id
+            ->toArray();
+
+        if (empty($magentoPairs)) {
+            return [];
+        }
+
+        // Match against Bagisto's products table.
+        $skus = array_keys($magentoPairs);
+        $map  = [];
+
+        foreach (array_chunk($skus, 500) as $chunk) {
+            $rows = DB::table('products')
+                ->select('id', 'sku')
+                ->whereIn('sku', $chunk)
+                ->get();
+
+            foreach ($rows as $row) {
+                $magentoId = $magentoPairs[$row->sku] ?? null;
+
+                if ($magentoId !== null) {
+                    $map[(int) $magentoId] = (int) $row->id;
+                }
+            }
+        }
+
+        return $map;
+    }
+
+    /**
+     * Build a map of  lowercase_email → bagisto_customer_id.
+     *
+     * @return array<string, int>
+     */
+    private function buildCustomerEmailMap(string $connection): array
+    {
+        // Fetch Magento customer e-mails.
+        $magentoEmails = DB::connection($connection)
+            ->table('customer_entity')
+            ->select('email')
+            ->get()
+            ->pluck('email')
+            ->map(fn ($e) => strtolower((string) $e))
+            ->unique()
+            ->toArray();
+
+        if (empty($magentoEmails)) {
+            return [];
+        }
+
+        $map = [];
+
+        foreach (array_chunk($magentoEmails, 500) as $chunk) {
+            $rows = DB::table('customers')
+                ->select('id', 'email')
+                ->whereIn(DB::raw('LOWER(email)'), $chunk)
+                ->get();
+
+            foreach ($rows as $row) {
+                $map[strtolower($row->email)] = (int) $row->id;
+            }
+        }
+
+        return $map;
+    }
+
+    /**
+     * Ensure product_reviews has the deduplication column.
+     * Prints instructions and returns false if the column is absent and
+     * cannot be auto-created (requires ALTER TABLE permission).
+     */
+    private function ensureMigrationColumnExists(): bool
+    {
+        $hasColumn = Schema::hasColumn('product_reviews', 'migrated_from_asteria_id');
+
+        if ($hasColumn) {
+            return true;
+        }
+
+        $this->warn('Column product_reviews.migrated_from_asteria_id is missing.');
+        $this->line('Run the following migration or SQL:');
+        $this->line('');
+        $this->line('  <comment>php artisan make:migration add_migrated_from_asteria_id_to_product_reviews</comment>');
+        $this->line('  Then add to the up() method:');
+        $this->line('  <comment>$table->bigInteger(\'migrated_from_asteria_id\')->unsigned()->nullable()->unique();</comment>');
+        $this->line('');
+        $this->line('  Or directly in MySQL:');
+        $this->line('  <comment>ALTER TABLE product_reviews');
+        $this->line('    ADD COLUMN migrated_from_asteria_id BIGINT UNSIGNED NULL DEFAULT NULL,');
+        $this->line('    ADD UNIQUE INDEX uq_migrated_asteria_id (migrated_from_asteria_id);</comment>');
+        $this->line('');
+
+        if (! $this->confirm('Attempt to add the column automatically now?', false)) {
+            return false;
+        }
+
+        try {
+            DB::statement('
+                ALTER TABLE product_reviews
+                    ADD COLUMN migrated_from_asteria_id BIGINT UNSIGNED NULL DEFAULT NULL,
+                    ADD UNIQUE INDEX uq_migrated_asteria_id (migrated_from_asteria_id)
+            ');
+            $this->info('Column added successfully.');
+
+            return true;
+        } catch (\Throwable $e) {
+            $this->error('Could not add column automatically: '.$e->getMessage());
+
+            return false;
+        }
+    }
+}

+ 153 - 0
app/Jobs/MigrateReviewJob.php

@@ -0,0 +1,153 @@
+<?php
+
+namespace App\Jobs;
+
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
+
+/**
+ * Processes a single batch of Magento reviews and writes them into the
+ * Bagisto product_reviews table.
+ *
+ * Each job receives an array of pre-fetched review rows so no remote DB
+ * connection is held across queue serialization/unserialization.
+ */
+class MigrateReviewJob implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    /** Retry up to 3 times before marking as failed. */
+    public int $tries = 3;
+
+    /** Allow 5 minutes per job for large batches. */
+    public int $timeout = 300;
+
+    /**
+     * @param  array<int, array<string, mixed>>  $reviews
+     *        Each element contains:
+     *          review_id, created_at, magento_product_id, status_id,
+     *          title, comment, name, customer_email, avg_rating
+     * @param  array<int, int>  $productIdMap   magento_product_id → bagisto product_id
+     * @param  array<string, int>  $customerEmailMap  email → bagisto customer_id
+     */
+    public function __construct(
+        private readonly array $reviews,
+        private readonly array $productIdMap,
+        private readonly array $customerEmailMap,
+    ) {}
+
+    public function handle(): void
+    {
+        $statusMap = [
+            1 => 'approved',
+            2 => 'pending',
+            3 => 'disapproved',
+        ];
+
+        $now = now();
+        $inserted = 0;
+        $skipped = 0;
+
+        foreach ($this->reviews as $row) {
+            $bagistoProductId = $this->productIdMap[$row['magento_product_id']] ?? null;
+
+            if ($bagistoProductId === null) {
+                Log::warning('MigrateReviewJob: no Bagisto product for Magento product_id='.$row['magento_product_id'].', review_id='.$row['review_id'].'. Skipping.');
+                $skipped++;
+                continue;
+            }
+
+            // Deduplicate: skip if already migrated (keyed on migrated_from_asteria_id).
+            $exists = DB::table('product_reviews')
+                ->where('migrated_from_asteria_id', $row['review_id'])
+                ->exists();
+
+            if ($exists) {
+                $skipped++;
+                continue;
+            }
+
+            $rating = $this->normaliseRating($row['avg_rating']);
+            $status = $statusMap[$row['status_id']] ?? 'pending';
+            $customerId = $row['customer_email']
+                ? ($this->customerEmailMap[strtolower($row['customer_email'])] ?? null)
+                : null;
+
+            $title   = $this->truncate((string) ($row['title'] ?? ''), 255) ?: '(no title)';
+            $comment = $this->truncate((string) ($row['comment'] ?? ''), 65535);
+            $name    = $this->truncate((string) ($row['name'] ?? ''), 255) ?: 'Guest';
+
+            DB::table('product_reviews')->insert([
+                'title'                   => $title,
+                'comment'                 => $comment,
+                'rating'                  => $rating,
+                'status'                  => $status,
+                'product_id'              => $bagistoProductId,
+                'customer_id'             => $customerId,
+                'name'                    => $name,
+                'migrated_from_asteria_id' => $row['review_id'],
+                'created_at'              => $row['created_at'] ?? $now,
+                'updated_at'              => $now,
+            ]);
+
+            $newReviewId = DB::getPdo()->lastInsertId();
+
+            // Download and store review images from review_media_image.url
+            if (! empty($row['images'])) {
+                $this->migrateImages((int) $newReviewId, $row['images']);
+            }
+
+            $inserted++;
+        }
+
+        Log::info("MigrateReviewJob: inserted={$inserted}, skipped={$skipped}");
+    }
+
+    /**
+     * Store image URLs from Asteria directly as attachment paths.
+     * Files will be uploaded to S3 in bulk separately.
+     *
+     * @param  int           $bagistoReviewId
+     * @param  list<string>  $urls
+     */
+    private function migrateImages(int $bagistoReviewId, array $urls): void
+    {
+        foreach ($urls as $url) {
+            if (empty($url)) {
+                continue;
+            }
+
+            DB::table('product_review_attachments')->insert([
+                'review_id' => $bagistoReviewId,
+                'type'      => 'image',
+                'mime_type' => null,
+                'path'      => $url,
+            ]);
+        }
+    }
+
+    /**
+     * Map Magento's average star value (1–5 float) to a Bagisto integer 1–5.
+     * Falls back to 5 when null/zero (treat missing rating as best-case).
+     */
+    private function normaliseRating(mixed $avgRating): int
+    {
+        if ($avgRating === null || $avgRating == 0) {
+            return 5;
+        }
+
+        return (int) max(1, min(5, round((float) $avgRating)));
+    }
+
+    private function truncate(string $value, int $maxLength): string
+    {
+        return mb_strlen($value) > $maxLength
+            ? mb_substr($value, 0, $maxLength)
+            : $value;
+    }
+}

+ 32 - 0
config/database.php

@@ -42,6 +42,38 @@ return [
             'synchronous'             => null,
             'synchronous'             => null,
         ],
         ],
 
 
+        /*
+         | ──────────────────────────────────────────────────────────────────────
+         | Asteria (Magento 1.x) source database – used by reviews:migrate-asteria
+         | ──────────────────────────────────────────────────────────────────────
+         | Add the following variables to your .env file:
+         |
+         |   ASTERIA_DB_HOST=127.0.0.1
+         |   ASTERIA_DB_PORT=3306
+         |   ASTERIA_DB_DATABASE=asteria_db
+         |   ASTERIA_DB_USERNAME=root
+         |   ASTERIA_DB_PASSWORD=
+         |   ASTERIA_DB_PREFIX=          # leave blank if Magento has no table prefix
+         |   ASTERIA_DB_CHARSET=utf8
+         |
+         | This connection is READ-ONLY at the application level; the migration
+         | command only executes SELECT queries against it.
+         */
+        'asteria' => [
+            'driver'      => 'mysql',
+            'host'        => env('ASTERIA_DB_HOST', '127.0.0.1'),
+            'port'        => env('ASTERIA_DB_PORT', '3306'),
+            'database'    => env('ASTERIA_DB_DATABASE', ''),
+            'username'    => env('ASTERIA_DB_USERNAME', 'root'),
+            'password'    => env('ASTERIA_DB_PASSWORD', ''),
+            'unix_socket' => env('ASTERIA_DB_SOCKET', ''),
+            'charset'     => env('ASTERIA_DB_CHARSET', 'utf8'),
+            'collation'   => env('ASTERIA_DB_COLLATION', 'utf8_unicode_ci'),
+            'prefix'      => env('ASTERIA_DB_PREFIX', ''),
+            'strict'      => false,
+            'engine'      => null,
+        ],
+
         'mysql' => [
         'mysql' => [
             'driver'         => 'mysql',
             'driver'         => 'mysql',
             'url'            => env('DB_URL'),
             'url'            => env('DB_URL'),