HEX
Server: Apache/2.4.37 (CentOS Stream) OpenSSL/1.1.1k
System: Linux ysnet.com.tw 4.18.0-553.5.1.el8.x86_64 #1 SMP Tue May 21 05:46:01 UTC 2024 x86_64
User: test (521)
PHP: 7.4.33
Disabled: NONE
Upload Files
File: /var/www/test/Installationlist/fm_integration/fm_helper.php
<?php
// ============================================================
// fm_helper.php  ─  FileMaker Data API 輔助類別
// ------------------------------------------------------------
// 用途:將裝機單資料 + PDF 寫入 FileMaker 指定資料庫/版面
// 設計原則:
//   1. 任何步驟失敗都不丟出例外中斷整個流程,而是回傳結構化結果,
//      讓呼叫端(submit.php)自行決定要不要記錄 log、要不要通知。
//   2. 所有設定(主機、帳密、layout、欄位對應)集中在 fm_config.php,
//      方便未來調整不需要動到邏輯本體。
// ============================================================

class FMHelper
{
    private string $host;       // 例: https://fm.yourdomain.com.tw
    private string $database;   // 資料庫檔名(不含 .fmp12)
    private string $username;
    private string $password;
    private bool   $verifySSL;  // 正式環境建議 true;若用自簽憑證測試可設 false
    private int    $timeout;    // 秒

    private ?string $token = null;

    public function __construct(array $config)
    {
        $this->host      = rtrim($config['host'], '/');
        $this->database  = $config['database'];
        $this->username   = $config['username'];
        $this->password   = $config['password'];
        $this->verifySSL  = $config['verify_ssl']  ?? true;
        $this->timeout    = $config['timeout_sec'] ?? 15;
    }

    // ── 內部:組出 Data API 基本 URL ──────────────────
    private function apiBase(): string
    {
        // FileMaker Data API 版本,目前主流為 vLatest(FMS 19.3+ 支援)
        // 若您的 FileMaker Server 版本較舊,可能需要改成固定版本號,例如 v1
        return $this->host . '/fmi/data/vLatest/databases/' . rawurlencode($this->database);
    }

    // ── 內部:統一的 cURL 執行器 ──────────────────────
    private function curlRequest(string $method, string $url, $body = null, array $extraHeaders = [], bool $isMultipart = false): array
    {
        $ch = curl_init($url);

        $headers = $extraHeaders;
        if (!$isMultipart) {
            $headers[] = 'Content-Type: application/json';
        }

        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CUSTOMREQUEST  => $method,
            CURLOPT_HTTPHEADER     => $headers,
            CURLOPT_SSL_VERIFYPEER => $this->verifySSL,
            CURLOPT_SSL_VERIFYHOST => $this->verifySSL ? 2 : 0,
            CURLOPT_TIMEOUT        => $this->timeout,
            CURLOPT_CONNECTTIMEOUT => 8,
        ]);

        if ($body !== null) {
            curl_setopt($ch, CURLOPT_POSTFIELDS, $isMultipart ? $body : json_encode($body, JSON_UNESCAPED_UNICODE));
        }

        $raw   = curl_exec($ch);
        $errNo = curl_errno($ch);
        $errMsg = curl_error($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);

        if ($errNo !== 0) {
            return ['ok' => false, 'http_code' => 0, 'error' => "cURL錯誤({$errNo}): {$errMsg}", 'data' => null];
        }

        $json = json_decode($raw, true);
        if ($json === null && $raw !== '') {
            return ['ok' => false, 'http_code' => $httpCode, 'error' => '回應非JSON:' . substr($raw, 0, 300), 'data' => null];
        }

        $fmErrorCode = $json['messages'][0]['code'] ?? null;
        $fmErrorMsg  = $json['messages'][0]['message'] ?? '';
        $ok = ($httpCode >= 200 && $httpCode < 300) && ($fmErrorCode === '0' || $fmErrorCode === null);

        return [
            'ok'        => $ok,
            'http_code' => $httpCode,
            'error'     => $ok ? null : "FM錯誤 code={$fmErrorCode} msg={$fmErrorMsg}",
            'data'      => $json,
        ];
    }

    // ── 1. 登入取得 token ─────────────────────────────
    public function login(): array
    {
        $url = $this->apiBase() . '/sessions';
        $authHeader = 'Authorization: Basic ' . base64_encode($this->username . ':' . $this->password);
        $res = $this->curlRequest('POST', $url, new stdClass(), [$authHeader]);

        if ($res['ok']) {
            $this->token = $res['data']['response']['token'] ?? null;
            if (!$this->token) {
                return ['ok' => false, 'error' => '登入成功但未取得 token'];
            }
            return ['ok' => true];
        }
        return ['ok' => false, 'error' => $res['error'] ?? '登入失敗', 'http_code' => $res['http_code']];
    }

    // ── 2. 登出(釋放 session)─────────────────────────
    public function logout(): void
    {
        if (!$this->token) return;
        $url = $this->apiBase() . '/sessions/' . $this->token;
        $this->curlRequest('DELETE', $url, null, []);
        $this->token = null;
    }

    private function authHeader(): string
    {
        return 'Authorization: Bearer ' . $this->token;
    }

    // ── 3. 用條件查詢記錄(find)────────────────────────
    // $layout: 版面名稱
    // $query: 例如 ['user_id' => '12345'](單一條件即可,FM find 語法用 = 開頭代表完全比對)
    public function findRecord(string $layout, array $query): array
    {
        $url = $this->apiBase() . '/layouts/' . rawurlencode($layout) . '/_find';

        // FileMaker find 語法:欄位值前加 "==" 代表完全相符(避免局部比對找錯記錄)
        $exactQuery = [];
        foreach ($query as $field => $value) {
            $exactQuery[$field] = '==' . $value;
        }

        $body = ['query' => [$exactQuery], 'limit' => 1];
        $res = $this->curlRequest('POST', $url, $body, [$this->authHeader()]);

        // FM 找不到符合的記錄時,會回傳 code 401(非HTTP狀態碼,是FM自訂錯誤碼),這不算「失敗」,是「查無資料」
        if (!$res['ok']) {
            $fmCode = $res['data']['messages'][0]['code'] ?? null;
            if ($fmCode === '401') {
                return ['ok' => true, 'found' => false, 'record_id' => null];
            }
            return ['ok' => false, 'found' => false, 'error' => $res['error']];
        }

        $recordId = $res['data']['response']['data'][0]['recordId'] ?? null;
        return ['ok' => true, 'found' => $recordId !== null, 'record_id' => $recordId];
    }

    // ── 4. 新增記錄 ───────────────────────────────────
    // $fieldData: 關聯陣列,key 為 FileMaker 欄位名稱
    public function createRecord(string $layout, array $fieldData): array
    {
        $url = $this->apiBase() . '/layouts/' . rawurlencode($layout) . '/records';
        $res = $this->curlRequest('POST', $url, ['fieldData' => $fieldData], [$this->authHeader()]);

        if (!$res['ok']) {
            return ['ok' => false, 'error' => $res['error']];
        }
        $recordId = $res['data']['response']['recordId'] ?? null;
        return ['ok' => true, 'record_id' => $recordId];
    }

    // ── 5. 更新既有記錄 ────────────────────────────────
    public function updateRecord(string $layout, string $recordId, array $fieldData): array
    {
        $url = $this->apiBase() . '/layouts/' . rawurlencode($layout) . '/records/' . rawurlencode($recordId);
        $res = $this->curlRequest('PATCH', $url, ['fieldData' => $fieldData], [$this->authHeader()]);

        if (!$res['ok']) {
            return ['ok' => false, 'error' => $res['error']];
        }
        return ['ok' => true];
    }

    // ── 6. 上傳檔案到容器欄位 ──────────────────────────
    // $containerField: 容器欄位名稱(若該欄位是重複欄位,可用 fieldName(repetition) 格式,一般不需要)
    public function uploadToContainer(string $layout, string $recordId, string $containerField, string $filePath, string $uploadFilename = ''): array
    {
        if (!file_exists($filePath)) {
            return ['ok' => false, 'error' => "檔案不存在: {$filePath}"];
        }
        $uploadFilename = $uploadFilename ?: basename($filePath);

        $url = $this->apiBase() . '/layouts/' . rawurlencode($layout)
             . '/records/' . rawurlencode($recordId)
             . '/containers/' . rawurlencode($containerField);

        $cfile = new CURLFile($filePath, 'application/pdf', $uploadFilename);
        $multipartBody = ['upload' => $cfile];

        $res = $this->curlRequest('POST', $url, $multipartBody, [$this->authHeader()], true);

        if (!$res['ok']) {
            return ['ok' => false, 'error' => $res['error']];
        }
        return ['ok' => true];
    }

    // ── 整合方法:find-or-create,再寫入欄位+容器 ────────
    // 這是 submit.php 會呼叫的主要進入點
    public function upsertWithPdf(
        string $layout,
        string $matchField,
        string $matchValue,
        array  $fieldData,
        ?string $pdfPath = null,
        string $containerField = ''
    ): array {
        $loginRes = $this->login();
        if (!$loginRes['ok']) {
            return ['ok' => false, 'step' => 'login', 'error' => $loginRes['error']];
        }

        try {
            // 1) 查詢是否已有該 user_id 的記錄
            $findRes = $this->findRecord($layout, [$matchField => $matchValue]);
            if (!$findRes['ok']) {
                return ['ok' => false, 'step' => 'find', 'error' => $findRes['error']];
            }

            if ($findRes['found']) {
                // 2a) 更新既有記錄
                $recordId = $findRes['record_id'];
                $writeRes = $this->updateRecord($layout, $recordId, $fieldData);
                if (!$writeRes['ok']) {
                    return ['ok' => false, 'step' => 'update', 'error' => $writeRes['error']];
                }
                $action = 'updated';
            } else {
                // 2b) 新增記錄(記得把 matchField 也放進 fieldData,否則新記錄會找不到這個值)
                $fieldData[$matchField] = $matchValue;
                $createRes = $this->createRecord($layout, $fieldData);
                if (!$createRes['ok']) {
                    return ['ok' => false, 'step' => 'create', 'error' => $createRes['error']];
                }
                $recordId = $createRes['record_id'];
                $action = 'created';
            }

            // 3) 上傳 PDF 到容器欄位(若有提供)
            if ($pdfPath && $containerField) {
                $uploadRes = $this->uploadToContainer($layout, $recordId, $containerField, $pdfPath);
                if (!$uploadRes['ok']) {
                    // PDF上傳失敗不影響欄位寫入已成功的事實,但要回報
                    return ['ok' => false, 'step' => 'upload_pdf', 'error' => $uploadRes['error'],
                             'record_id' => $recordId, 'action' => $action, 'fields_written' => true];
                }
            }

            return ['ok' => true, 'record_id' => $recordId, 'action' => $action];

        } finally {
            // 無論成功或失敗,一定要登出釋放 session(FileMaker 同時session數有限制)
            $this->logout();
        }
    }
}