File: //var/www/test/Installationlist/fm_test.php
<?php
// ============================================================
// fm_test.php ─ FileMaker Data API 串接測試工具
// ------------------------------------------------------------
// 修正版(v2):
// * 修正容器上傳 multipart Content-Type 自動 boundary 問題
// * URL 中的中文容器欄位名做 rawurlencode
// * 加上 verbose log 方便除錯
// * 使用結構較完整的測試 PDF
// ============================================================
session_start();
ini_set('display_errors', 1);
error_reporting(E_ALL);
// ── FileMaker 連線設定 ───────────────────────────────────
define('FM_HOST', 'https://61.216.173.217:8443');
define('FM_DB', 'YSNET_MIS');
define('FM_LAYOUT', 'api-申請人資料寫入');
define('FM_USER', 'webapi');
define('FM_PASS', '@Andrejiao9527');
// 測試專用客戶編號
define('TEST_USER_ID', '2025022410');
// ── 共用 cURL 函式 ───────────────────────────────────────
function fmCurl($method, $url, $token = null, $bodyArr = null, $isMultipart = false, $multipartFile = null, $multipartFieldName = 'upload') {
$ch = curl_init($url);
$headers = [];
if ($token) $headers[] = 'Authorization: Bearer '.$token;
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
curl_setopt($ch, CURLOPT_TIMEOUT, 60);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
if ($isMultipart) {
// 重要:不要手動設 Content-Type!讓 cURL 自動產生帶 boundary 的 header
// CURLFile 第二參數 = MIME type,第三參數 = 顯示檔名
curl_setopt($ch, CURLOPT_POSTFIELDS, [
$multipartFieldName => new CURLFile($multipartFile, 'application/pdf', basename($multipartFile))
]);
} elseif ($bodyArr !== null) {
$headers[] = 'Content-Type: application/json';
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($bodyArr, JSON_UNESCAPED_UNICODE));
}
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
$resp = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlErr = curl_error($ch);
$curlErrno = curl_errno($ch);
curl_close($ch);
if ($resp === false) {
return ['ok'=>false, 'http'=>0, 'error'=>"cURL錯誤(#{$curlErrno}): {$curlErr}", 'raw'=>null, 'data'=>null];
}
$json = json_decode($resp, true);
$fmCode = $json['messages'][0]['code'] ?? null;
$fmMsg = $json['messages'][0]['message'] ?? '';
$ok = ($httpCode >= 200 && $httpCode < 300) && ($fmCode === '0');
return ['ok'=>$ok, 'http'=>$httpCode, 'fmCode'=>$fmCode, 'fmMsg'=>$fmMsg, 'raw'=>$resp, 'data'=>$json];
}
// ============================================================
// AJAX 動作處理
// ============================================================
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) {
header('Content-Type: application/json; charset=utf-8');
$action = $_POST['action'];
$result = ['ok'=>false, 'message'=>'未知動作'];
switch ($action) {
// ── Step 1:登入 ──────────────────────────────
case 'login':
$loginUrl = FM_HOST.'/fmi/data/v1/databases/'.rawurlencode(FM_DB).'/sessions';
// 手動組 Basic Auth header,避開 CURLOPT_USERPWD 在某些情境下的字元編碼問題
$basicAuth = 'Basic ' . base64_encode(FM_USER . ':' . FM_PASS);
$ch = curl_init($loginUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode((object)[]));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'Authorization: '.$basicAuth,
]);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
$resp = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlErr = curl_error($ch);
$curlErrno = curl_errno($ch);
curl_close($ch);
// 額外 debug 資訊(檢查帳密是否混到全形字元)
$debug = [
'url' => $loginUrl,
'user_len' => strlen(FM_USER),
'pass_len' => strlen(FM_PASS),
'user_hex' => bin2hex(FM_USER),
'pass_hex' => bin2hex(FM_PASS),
'basic_auth_preview' => substr($basicAuth, 0, 20).'...(len='.strlen($basicAuth).')',
];
if ($resp === false) {
$result = ['ok'=>false, 'http'=>0, 'message'=>"cURL連線錯誤(#{$curlErrno}): {$curlErr}", 'debug'=>$debug, 'raw'=>null];
break;
}
$json = json_decode($resp, true);
$token = $json['response']['token'] ?? null;
if ($token) {
$_SESSION['fm_test_token'] = $token;
$result = ['ok'=>true, 'http'=>$httpCode, 'message'=>'登入成功,Token 已取得並存入 PHP Session', 'token_preview'=>substr($token,0,12).'...', 'debug'=>$debug, 'raw'=>$json];
} else {
$msg = $json['messages'][0]['message'] ?? '未知錯誤';
$result = ['ok'=>false, 'http'=>$httpCode, 'message'=>"登入失敗: {$msg}", 'debug'=>$debug, 'raw'=>$json];
}
break;
// ── Step 2:查詢(依測試客戶編號 _find)─────────
case 'find':
$token = $_SESSION['fm_test_token'] ?? null;
if (!$token) { $result = ['ok'=>false, 'message'=>'尚未登入,請先執行 Step 1'];break; }
$baseUrl = FM_HOST.'/fmi/data/v1/databases/'.rawurlencode(FM_DB).'/layouts/'.rawurlencode(FM_LAYOUT);
$r = fmCurl('POST', $baseUrl.'/_find', $token, [
'query' => [[ '客戶編號' => '=='.TEST_USER_ID ]]
]);
if ($r['ok']) {
$records = $r['data']['response']['data'] ?? [];
$_SESSION['fm_test_recordId'] = $records[0]['recordId'] ?? null;
$result = [
'ok'=>true, 'http'=>$r['http'],
'message'=>'查詢成功,找到 '.count($records).' 筆紀錄',
'recordId'=>$_SESSION['fm_test_recordId'],
'fieldData'=>$records[0]['fieldData'] ?? null,
'raw'=>$r['data']
];
} elseif ($r['fmCode'] === '401') {
$_SESSION['fm_test_recordId'] = null;
$result = ['ok'=>true, 'http'=>$r['http'], 'message'=>'查無此客戶編號紀錄(FM code 401,這代表測試編號尚未建立,請先在 FileMaker 手動建立一筆空白紀錄,或讓 Step 3 自動新增)', 'raw'=>$r['data']];
} else {
$result = ['ok'=>false, 'http'=>$r['http'], 'message'=>"查詢失敗: {$r['fmMsg']} (FM code {$r['fmCode']})", 'raw'=>$r['data']];
}
break;
// ── Step 3:寫入測試資料(PATCH 或 POST)────────
case 'write':
$token = $_SESSION['fm_test_token'] ?? null;
if (!$token) { $result = ['ok'=>false, 'message'=>'尚未登入,請先執行 Step 1'];break; }
$baseUrl = FM_HOST.'/fmi/data/v1/databases/'.rawurlencode(FM_DB).'/layouts/'.rawurlencode(FM_LAYOUT);
$recordId = $_SESSION['fm_test_recordId'] ?? null;
$stamp = date('Y-m-d H:i:s');
$fieldData = [
'客戶編號' => TEST_USER_ID,
'用戶名稱' => '測試用戶_'.$stamp,
'身分證字號' => 'A123456789',
'住家電話' => '03-1234567',
'行動電話' => '0912345678',
'外部郵址' => 'test_'.time().'@example.com',
'資訊箱內設備' => '光貓 ×1、1000M無線分享器 ×1',
'家中設備情況' => '1000M無線分享器 ×1(測試寫入於 '.$stamp.')',
];
if ($recordId) {
$r = fmCurl('PATCH', $baseUrl.'/records/'.$recordId, $token, ['fieldData'=>$fieldData]);
$mode = 'PATCH(更新既有紀錄 recordId='.$recordId.')';
} else {
$r = fmCurl('POST', $baseUrl.'/records', $token, ['fieldData'=>$fieldData]);
$mode = 'POST(新增紀錄,因為 Step 2 查無資料)';
if ($r['ok']) {
$_SESSION['fm_test_recordId'] = $r['data']['response']['recordId'] ?? null;
}
}
if ($r['ok']) {
$result = ['ok'=>true, 'http'=>$r['http'], 'message'=>"寫入成功!模式:{$mode}", 'fieldData_sent'=>$fieldData, 'raw'=>$r['data']];
} else {
$result = ['ok'=>false, 'http'=>$r['http'], 'message'=>"寫入失敗: {$r['fmMsg']} (FM code {$r['fmCode']})。模式:{$mode}", 'fieldData_sent'=>$fieldData, 'raw'=>$r['data']];
}
break;
// ── Step 4:上傳測試 PDF 到容器欄位(v2 修正版)──
case 'upload':
$token = $_SESSION['fm_test_token'] ?? null;
$recordId = $_SESSION['fm_test_recordId'] ?? null;
if (!$token) { $result = ['ok'=>false, 'message'=>'尚未登入,請先執行 Step 1'];break; }
if (!$recordId) { $result = ['ok'=>false, 'message'=>'尚無 recordId,請先執行 Step 2(查詢)或 Step 3(寫入)'];break; }
// 產生結構較完整的測試 PDF(含二進位標記、xref 偏移表)
$testPdfPath = sys_get_temp_dir().'/fm_test_'.time().'.pdf';
$pdfContent = "%PDF-1.4\n%\xE2\xE3\xCF\xD3\n"
."1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n"
."2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n"
."3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>\nendobj\n"
."4 0 obj\n<< /Length 55 >>\nstream\nBT /F1 18 Tf 50 750 Td (FileMaker Container Upload Test) Tj ET\nendstream\nendobj\n"
."5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n"
."xref\n0 6\n0000000000 65535 f \n0000000018 00000 n \n0000000068 00000 n \n0000000119 00000 n \n0000000236 00000 n \n0000000337 00000 n \n"
."trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n405\n%%EOF\n";
file_put_contents($testPdfPath, $pdfContent);
// 重要:容器欄位名稱含中文,URL 必須做 rawurlencode
$baseUrl = FM_HOST.'/fmi/data/v1/databases/'.rawurlencode(FM_DB).'/layouts/'.rawurlencode(FM_LAYOUT);
$uploadUrl = $baseUrl.'/records/'.$recordId.'/containers/'.rawurlencode('裝機單掃描').'/1';
// 直接寫 cURL 並開啟 verbose 取得詳細通訊紀錄
$verbose = fopen('php://temp', 'w+');
$ch = curl_init($uploadUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 60);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
curl_setopt($ch, CURLOPT_VERBOSE, true);
curl_setopt($ch, CURLOPT_STDERR, $verbose);
// 重要:只設 Authorization,不要手動設 Content-Type,讓 cURL 自動生成帶 boundary 的 header
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Bearer '.$token
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, [
'upload' => new CURLFile($testPdfPath, 'application/pdf', basename($testPdfPath))
]);
$resp = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlErr = curl_error($ch);
curl_close($ch);
rewind($verbose);
$verboseLog = stream_get_contents($verbose);
fclose($verbose);
@unlink($testPdfPath);
$json = $resp ? json_decode($resp, true) : null;
$fmCode = $json['messages'][0]['code'] ?? null;
$fmMsg = $json['messages'][0]['message'] ?? '';
$ok = ($httpCode >= 200 && $httpCode < 300) && ($fmCode === '0');
if ($ok) {
$result = [
'ok'=>true,
'http'=>$httpCode,
'message'=>'容器上傳成功!請到 FileMaker 後台檢查「裝機單掃描」欄位是否出現測試 PDF',
'url'=>$uploadUrl,
'raw'=>$json
];
} else {
$result = [
'ok'=>false,
'http'=>$httpCode,
'message'=>"容器上傳失敗 HTTP {$httpCode}".($fmMsg ? " (FM code {$fmCode}: {$fmMsg})" : ' (無 FM 回應,可能 IIS Reverse Proxy 直接擋掉)'),
'url'=>$uploadUrl,
'curl_error'=>$curlErr,
'raw_response'=>$resp,
'verbose_log'=>$verboseLog,
];
}
break;
// ── Step 5:登出 ──────────────────────────────
case 'logout':
$token = $_SESSION['fm_test_token'] ?? null;
if (!$token) { $result = ['ok'=>false, 'message'=>'尚未登入,無需登出'];break; }
$r = fmCurl('DELETE', FM_HOST.'/fmi/data/v1/databases/'.rawurlencode(FM_DB).'/sessions/'.$token);
unset($_SESSION['fm_test_token'], $_SESSION['fm_test_recordId']);
$result = ['ok'=>$r['ok'], 'http'=>$r['http'], 'message'=>$r['ok'] ? '登出成功,Session 已清除' : "登出時發生錯誤(仍會清除本地Token): {$r['fmMsg']}", 'raw'=>$r['data']];
break;
// ── 額外:顯示目前測試狀態 ────────────────────
case 'status':
$result = [
'ok'=>true,
'message'=>'目前測試狀態',
'has_token'=> !empty($_SESSION['fm_test_token']),
'token_preview'=> !empty($_SESSION['fm_test_token']) ? substr($_SESSION['fm_test_token'],0,12).'...' : null,
'recordId'=> $_SESSION['fm_test_recordId'] ?? null,
];
break;
}
echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
exit;
}
?>
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FileMaker Data API 串接測試工具 v2</title>
<style>
* { box-sizing: border-box; }
body { font-family: "Microsoft JhengHei", "PingFang TC", sans-serif; background:#f3f4f6; color:#1a1a1a; padding:20px; max-width:920px; margin:0 auto; }
h1 { font-size:20px; margin-bottom:4px; }
.sub { color:#666; font-size:13px; margin-bottom:20px; }
.config-box { background:#fff; border:1px solid #ddd; border-radius:8px; padding:14px 18px; margin-bottom:20px; font-size:13px; line-height:1.8; }
.config-box b { color:#0F6E56; }
.warn { background:#FFF3CD; border:1px solid #F0C36D; border-radius:8px; padding:10px 14px; font-size:13px; color:#7A5C00; margin-bottom:20px; }
.step { background:#fff; border:1px solid #ddd; border-radius:10px; margin-bottom:14px; overflow:hidden; }
.step-hdr { display:flex; align-items:center; gap:10px; padding:14px 18px; border-bottom:1px solid #eee; }
.step-num { width:28px; height:28px; border-radius:50%; background:#1D9E75; color:#fff; display:flex; align-items:center; justify-content:center; font-weight:bold; font-size:13px; flex-shrink:0; }
.step-title { font-size:15px; font-weight:500; flex:1; }
.step-desc { font-size:12px; color:#888; }
.step-body { padding:14px 18px; }
button { background:#1D9E75; color:#fff; border:none; padding:9px 18px; border-radius:6px; font-size:14px; cursor:pointer; font-family:inherit; }
button:hover { background:#0F6E56; }
button:disabled { background:#ccc; cursor:not-allowed; }
.result-box { margin-top:10px; padding:10px 14px; border-radius:6px; font-size:12px; white-space:pre-wrap; word-break:break-all; max-height:420px; overflow:auto; font-family:monospace; display:none; }
.result-box.show { display:block; }
.result-box.ok { background:#E1F5EE; border:1px solid #9FE1CB; color:#085041; }
.result-box.fail { background:#FCEBEB; border:1px solid #F0A8A8; color:#A32D2D; }
.status-bar { display:flex; gap:16px; font-size:12px; color:#666; background:#fff; border:1px solid #ddd; border-radius:8px; padding:10px 16px; margin-bottom:20px; }
.status-bar span b { color:#1D9E75; }
.ver { display:inline-block; font-size:11px; padding:2px 8px; border-radius:10px; background:#1D9E75; color:#fff; margin-left:6px; vertical-align:middle; }
</style>
</head>
<body>
<h1>🔧 FileMaker Data API 串接測試工具<span class="ver">v2</span></h1>
<div class="sub">修正容器上傳的 Content-Type boundary 與 URL 中文編碼問題</div>
<div class="config-box">
主機:<b><?= htmlspecialchars(FM_HOST) ?></b>
資料庫:<b><?= htmlspecialchars(FM_DB) ?></b>
布局:<b><?= htmlspecialchars(FM_LAYOUT) ?></b><br>
測試客戶編號:<b><?= htmlspecialchars(TEST_USER_ID) ?></b>(請確認此編號已在 FileMaker 手動建立,或讓 Step 3 自動新增)
</div>
<div class="warn">
⚠️ 此工具內含 API 帳密,僅供測試期間使用。測試完成並確認 submit.php 可正常運作後,請刪除此檔案或將其搬出公開存取的網頁目錄。
</div>
<div class="status-bar" id="status-bar">
目前狀態:<span id="status-text">尚未查詢</span>
<button onclick="checkStatus()" style="padding:4px 12px;font-size:12px">重新整理狀態</button>
</div>
<!-- Step 1 -->
<div class="step">
<div class="step-hdr">
<div class="step-num">1</div>
<div class="step-title">登入 FileMaker(取得 Token)</div>
<div class="step-desc">POST /sessions</div>
</div>
<div class="step-body">
<button onclick="runStep('login', this)">執行登入</button>
<div class="result-box" id="result-login"></div>
</div>
</div>
<!-- Step 2 -->
<div class="step">
<div class="step-hdr">
<div class="step-num">2</div>
<div class="step-title">查詢測試客戶編號是否已有紀錄</div>
<div class="step-desc">POST /_find(依客戶編號)</div>
</div>
<div class="step-body">
<button onclick="runStep('find', this)">執行查詢</button>
<div class="result-box" id="result-find"></div>
</div>
</div>
<!-- Step 3 -->
<div class="step">
<div class="step-hdr">
<div class="step-num">3</div>
<div class="step-title">寫入測試資料(自動 PATCH 或 POST)</div>
<div class="step-desc">若 Step 2 找到紀錄則 PATCH 更新;找不到則 POST 新增</div>
</div>
<div class="step-body">
<button onclick="runStep('write', this)">執行寫入</button>
<div class="result-box" id="result-write"></div>
</div>
</div>
<!-- Step 4 -->
<div class="step">
<div class="step-hdr">
<div class="step-num">4</div>
<div class="step-title">上傳測試 PDF 到「裝機單掃描」容器欄位 <span class="ver">已修正</span></div>
<div class="step-desc">POST /records/{id}/containers/裝機單掃描/1</div>
</div>
<div class="step-body">
<button onclick="runStep('upload', this)">執行上傳</button>
<div class="result-box" id="result-upload"></div>
</div>
</div>
<!-- Step 5 -->
<div class="step">
<div class="step-hdr">
<div class="step-num">5</div>
<div class="step-title">登出(釋放 Session)</div>
<div class="step-desc">DELETE /sessions/{token}</div>
</div>
<div class="step-body">
<button onclick="runStep('logout', this)">執行登出</button>
<div class="result-box" id="result-logout"></div>
</div>
</div>
<script>
function runStep(action, btn) {
btn.disabled = true;
const origText = btn.textContent;
btn.textContent = '執行中…';
const box = document.getElementById('result-' + action);
box.className = 'result-box show';
box.textContent = '請求送出中…';
const fd = new FormData();
fd.append('action', action);
fetch(window.location.href, { method: 'POST', body: fd })
.then(r => r.text())
.then(text => {
let data;
try { data = JSON.parse(text); }
catch(e) {
box.className = 'result-box show fail';
box.textContent = '回應非 JSON,可能是 PHP 錯誤:\n\n' + text;
return;
}
box.className = 'result-box show ' + (data.ok ? 'ok' : 'fail');
box.textContent = (data.ok ? '✅ ' : '❌ ') + data.message + '\n\n' + JSON.stringify(data, null, 2);
checkStatus();
})
.catch(err => {
box.className = 'result-box show fail';
box.textContent = '請求失敗:' + err.message;
})
.finally(() => {
btn.disabled = false;
btn.textContent = origText;
});
}
function checkStatus() {
const fd = new FormData();
fd.append('action', 'status');
fetch(window.location.href, { method: 'POST', body: fd })
.then(r => r.json())
.then(data => {
const txt = (data.has_token ? 'Token: ' + data.token_preview : '未登入')
+ ' | recordId: ' + (data.recordId || '尚未取得');
document.getElementById('status-text').textContent = txt;
});
}
// 頁面載入時先查一次狀態
checkStatus();
</script>
</body>
</html>