首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
您现在的位置: 程式碼之家 >> 編程語言 >  >> 網頁程式設計 >> PHP 程式設計 >> PHP 綜合知識

PHP 貨幣兌換器:整合歐洲央行匯率、資料庫緩存與自動更新

     編輯:PHP 綜合知識

前言

跨境電商、金融應用、旅遊平台常需即時匯率兌換。本文實現一個生產級 CurrencyConverter 類,特性包括:

  • 資料來源:歐洲央行 (ECB) 每日發布的 XML 匯率 (eurofxref-daily.xml)
  • 基準貨幣:EUR (歐元),所有匯率相對 EUR 報價
  • 本地緩存:MySQL 資料表,避免每次請求遠端
  • 自動更新:檢測資料表更新時間,超過 12 小時自動拉取最新
  • 線程安全:使用資料庫鎖防止併發更新
  • 錯誤容錯:網路失敗時回退本地緩存,記錄詳細日誌

一、資料表結構

CREATE TABLE `currency_rates` (
  `currency` CHAR(3) NOT NULL COMMENT 'ISO 4217 貨幣代碼',
  `rate`     DECIMAL(18, 8) NOT NULL COMMENT '相對 EUR 匯率 (1 EUR = X currency)',
  `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`currency`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='匯率緩存表 (基準 EUR)';

-- 初始化基準貨幣
INSERT INTO `currency_rates` (`currency`, `rate`) VALUES ('EUR', 1.00000000)
ON DUPLICATE KEY UPDATE `rate` = 1.00000000;

二、核心類實現 (PDO + 嚴格類型)

<?php
namespace App\Services;

use PDO;
use PDOException;
use RuntimeException;

class CurrencyConverter {
    private const ECB_XML_URL = 'https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml';
    private const CACHE_TTL_SECONDS = 43200; // 12 小時
    private const DEFAULT_DECIMALS = 2;

    private PDO $pdo;
    private string $table;
    private array $rates = []; // 記憶體緩存

    public function __construct(PDO $pdo, string $table = 'currency_rates') {
        $this->pdo   = $pdo;
        $this->table = $table;
        $this->ensureTableExists();
        $this->loadRatesFromCache();
        $this->maybeUpdateRates();
    }

    /** 確保資料表存在 */
    private function ensureTableExists(): void {
        $sql = "CREATE TABLE IF NOT EXISTS `{$this->table}` (
            `currency` CHAR(3) NOT NULL,
            `rate` DECIMAL(18,8) NOT NULL,
            `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
            PRIMARY KEY (`currency`)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4";
        $this->pdo->exec($sql);
        // 確保 EUR 存在
        $this->pdo->exec("INSERT IGNORE INTO `{$this->table}` (`currency`, `rate`) VALUES ('EUR', 1.00000000)");
    }

    /** 從資料庫載入匯率到記憶體 */
    private function loadRatesFromCache(): void {
        $stmt = $this->pdo->query("SELECT `currency`, `rate` FROM `{$this->table}`");
        while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
            $this->rates[$row['currency']] = (float)$row['rate'];
        }
        if (!isset($this->rates['EUR'])) $this->rates['EUR'] = 1.0;
    }

    /** 檢查是否需要更新 (超過 12 小時) */
    private function maybeUpdateRates(): void {
        $stmt = $this->pdo->query("SHOW TABLE STATUS LIKE '{$this->table}'");
        $status = $stmt->fetch(PDO::FETCH_ASSOC);
        if (!$status) return; // 表不存在

        $lastUpdate = strtotime($status['Update_time'] ?? '1970-01-01');
        if (time() - $lastUpdate > self::CACHE_TTL_SECONDS) {
            $this->fetchAndStoreRates();
        }
    }

    /** 從 ECB 獲取 XML 並解析存入資料庫 */
    private function fetchAndStoreRates(): void {
        // 使用併發鎖防止多進程同時更新
        $lockAcquired = $this->pdo->exec("SELECT GET_LOCK('currency_rates_update', 10)") === 1;
        if (!$lockAcquired) {
            // 獲取鎖失敗,說明有其他進程在更新,直接返回使用舊緩存
            error_log('[CurrencyConverter] 無法獲取更新鎖,跳過本次更新');
            return;
        }

        try {
            $xml = $this->fetchEcbXml();
            $rates = $this->parseEcbXml($xml);
            if (empty($rates)) {
                throw new RuntimeException('ECB XML 解析結果為空');
            }
            $this->upsertRates($rates);
            $this->rates = $rates; // 更新記憶體緩存
            $this->rates['EUR'] = 1.0;
            error_log('[CurrencyConverter] 匯率更新成功,共 ' . count($rates) . ' 種貨幣');
        } catch (\Throwable $e) {
            error_log('[CurrencyConverter] 匯率更新失敗: ' . $e->getMessage());
        } finally {
            $this->pdo->exec("SELECT RELEASE_LOCK('currency_rates_update')");
        }
    }

    /** 獲取 ECB XML 內容 */
    private function fetchEcbXml(): string {
        $ctx = stream_context_create([
            'http' => [
                'timeout' => 15,
                'user_agent' => 'CurrencyConverter/1.0 (+https://example.com)',
                'follow_location' => 1,
                'max_redirects' => 3,
            ],
            'ssl' => [
                'verify_peer' => true,
                'verify_peer_name' => true,
            ],
        ]);
        $content = @file_get_contents(self::ECB_XML_URL, false, $ctx);
        if ($content === false) {
            throw new RuntimeException('無法獲取 ECB XML: ' . error_get_last()['message'] ?? '未知錯誤');
        }
        return $content;
    }

    /** 解析 ECB XML (使用 XMLReader 節省記憶體) */
    private function parseEcbXml(string $xml): array {
        $reader = new \XMLReader();
        $reader->xml($xml);
        $rates = [];
        
        while ($reader->read()) {
            if ($reader->nodeType === XMLReader::ELEMENT && $reader->localName === 'Cube') {
                $currency = $reader->getAttribute('currency');
                $rateStr  = $reader->getAttribute('rate');
                if ($currency && $rateStr !== null) {
                    $rate = (float)$rateStr;
                    if ($rate > 0) {
                        $rates[$currency] = $rate;
                    }
                }
            }
        }
        $reader->close();
        return $rates;
    }

    /** 批量 UPSERT 匯率 */
    private function upsertRates(array $rates): void {
        $sql = "INSERT INTO `{$this->table}` (`currency`, `rate`) VALUES (:currency, :rate)
                ON DUPLICATE KEY UPDATE `rate` = VALUES(`rate`), `updated_at` = CURRENT_TIMESTAMP";
        $stmt = $this->pdo->prepare($sql);
        $this->pdo->beginTransaction();
        try {
            foreach ($rates as $currency => $rate) {
                $stmt->execute([':currency' => $currency, ':rate' => $rate]);
            }
            $this->pdo->commit();
        } catch (\Throwable $e) {
            $this->pdo->rollBack();
            throw $e;
        }
    }

    // ==================== 公開 API ====================

    /**
     * 貨幣兌換
     * @param float $amount 金額
     * @param string $from 來源貨幣 (ISO 4217, 如 USD, CNY, JPY)
     * @param string $to 目標貨幣
     * @param int $decimals 保留小數位 (預設 2)
     * @return string 格式化後的金額字串
     * @throws RuntimeException 貨幣不支援
     */
    public function convert(float $amount, string $from, string $to, int $decimals = self::DEFAULT_DECIMALS): string {
        $from = strtoupper($from);
        $to   = strtoupper($to);

        if (!isset($this->rates[$from])) {
            throw new RuntimeException("不支援的來源貨幣: {$from}");
        }
        if (!isset($this->rates[$to])) {
            throw new RuntimeException("不支援的目標貨幣: {$to}");
        }

        // 公式: amount * (rate_to / rate_from)  (因為兩者都是相對 EUR)
        $rateFrom = $this->rates[$from];
        $rateTo   = $this->rates[$to];
        $result   = $amount * ($rateTo / $rateFrom);

        return number_format($result, $decimals, '.', ',');
    }

    /** 獲取當前所有匯率 (相對 EUR) */
    public function getAllRates(): array {
        return $this->rates;
    }

    /** 獲取特定貨幣對 EUR 匯率 */
    public function getRate(string $currency): ?float {
        $currency = strtoupper($currency);
        return $this->rates[$currency] ?? null;
    }

    /** 手動觸發更新 (管理後台用) */
    public function forceUpdate(): bool {
        try {
            $this->fetchAndStoreRates();
            return true;
        } catch (\Throwable $e) {
            error_log('[CurrencyConverter] 手動更新失敗: ' . $e->getMessage());
            return false;
        }
    }
}

三、依賴注入與使用範例

1. 容器配置

<?php
// config/container.php
return [
    \App\Services\CurrencyConverter::class => function($container) {
        $pdo = new PDO(
            "mysql:host={$container->get('config')['db_host']};dbname={$container->get('config')['db_name']};charset=utf8mb4",
            $container->get('config')['db_user'],
            $container->get('config')['db_pass'],
            [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
        );
        return new \App\Services\CurrencyConverter($pdo);
    },
];

2. 控制器/服務中使用

<?php
class OrderService {
    public function __construct(private \App\Services\CurrencyConverter $converter) {}

    public function getOrderTotalInCurrency(int $orderId, string $targetCurrency): string {
        $order = Order::find($orderId);
        // 假設訂單儲存為 USD
        return $this->converter->convert($order->total_amount, 'USD', $targetCurrency);
    }
}

// 直接使用
$converter = app(\App\Services\CurrencyConverter::class);
echo $converter->convert(100, 'USD', 'CNY'); // 輸出如: 724.50
echo $converter->convert(50, 'EUR', 'JPY');  // 輸出如: 7,845.00

3. 命令列手動更新

<?php
// bin/update_rates.php
require 'vendor/autoload.php';
$container = require 'bootstrap/container.php';
$converter = $container->get(\App\Services\CurrencyConverter::class);
if ($converter->forceUpdate()) {
    echo "匯率更新成功\n";
} else {
    echo "匯率更新失敗\n";
    exit(1);
}

加入 Crontab:0 6 * * * /usr/bin/php /path/to/bin/update_rates.php >> /var/log/currency_update.log 2>&1 (每天早上 6 點更新,ECB 通常在 CET 16:00 發布)

四、關鍵設計決策解析

決策點選擇理由
基準貨幣EUR (歐元)ECB 官方數據基準即為 EUR,避免雙重轉換誤差
資料類型DECIMAL(18,8)精確表示匯率,避免浮點數精度丟失 (如 JPY 匯率 ~160)
併發控制MySQL GET_LOCK跨進程/分布式安全,無需 Redis 等外部依賴
XML 解析XMLReader (流式)記憶體常數級,適用於大型 XML (ECB 約 35KB,但良好習慣)
錯誤策略失敗靜默+日誌+回退緩存不阻塞主業務,保證可用性優於一致性
更新頻率12 小時 / 每日定時ECB 每工作日發布一次,12 小時檢查平衡及時性與流量

五、擴展建議

  1. 多資料源備援:整合 exchangerate.host、fixer.io、Open Exchange Rates 作為備選,實現 RateProviderInterface 策略模式。
  2. 歷史匯率查詢:儲存每日快照表 currency_rates_history (date, currency, rate),支援歷史訂單金額換算。
  3. 加密貨幣支援:整合 CoinGecko / CoinMarketCap API,擴展貨幣代碼集合 (BTC, ETH, USDT 等)。
  4. 前端即時顯示:提供 /api/rates?base=USD 介面,前端定時輪詢更新顯示價格。
  5. 精度控制:不同貨幣小數位不同 (JPY 0 位, USD 2 位, BTC 8 位),引入 CurrencyMetadata 表自動格式化。

結語

貨幣兌換看似簡單,實則涉及資料源可靠性、精度控制、併發安全、緩存策略、錯誤容錯等工程課題。本實現採用 ECB 官方權威數據源、MySQL 本地緩存、XMLReader 流式解析、GET_LOCK 分布式鎖、嚴格類型與異常處理,構建出可直接投入生產的 CurrencyConverter 服務。根據業務增長,可平滑擴展多資料源、歷史匯率、加密貨幣等能力。

  1. 上一篇文章:
  2. 下一篇文章:
Copyright © 程式碼之家 All Rights Reserved