請求
建議:收到就先回應,處理放到之後
收到請求就立刻回 2xx(例如 204),寫入資料庫、計算這類處理放到回應之後再做。App 不等回應就送下一筆,最快每秒一筆;伺服器回得慢,還沒回應的請求就會累積,累積到 5 個時 App 只保留最新的一點,中間的點不會送出。
- 網址
- 只能是 https://。
- 方法
- POST(預設)或 PUT,每台伺服器各自設定。
- Header
- Content-Type: application/json,再加上你在 App 裡設定的 Header。Content-Type、Content-Length、Host、Connection、Transfer-Encoding 由 App 決定,設定了同名的 Header 也不會生效。作業系統另外會自動加上 User-Agent,內容由系統決定(見隱私權政策)。
- Body
- 一行 JSON,不縮排,不用 gzip。大約 750 bytes。
- 時機
- 每採集一個點送一次。設定了好幾台伺服器時(最多勾選 5 台),同一個點送到每一台,內容完全一樣。最快的情況:時間模式每 1 秒一筆;距離模式設 0 公尺時,系統每更新一次定位就送一筆。
- 順序
- 不等上一筆回應就送下一筆,請求可能亂序到達。
- 同時請求
- 每台伺服器最多 5 個請求還沒回應。滿了的話新的點先等著,有空位就送;等待中只留最新的一點,被換掉的點不送,算失敗。伺服器回應越慢,掉的點越多。
- 逾時
- 10 秒。
- 成功
- 建議直接回 204,不帶 body。HTTP 2xx 算成功,body 空白也算。例外:回應的 Content-Type 是 JSON(application/json 或 +json 結尾,看分號前面)但 body 解析不了,算失敗。JSON 的內容不會檢查;不是 JSON 的回應不看 body。
- 回應大小
- 最多讀 1 MB,超過就斷線,算失敗。
- 失敗
- 不重送,也沒有離線佇列,伺服器不會再收到那一點(手機上的紀錄仍然有,可以匯出)。伺服器可以從 pt.id 不連續看出掉了幾點。
通用規則
- 結構
- 外層分成 dev、perm、bat、net、rule、pt 六組,組內扁平,最多兩層。
- 沒有值
- 平台沒有的欄位、這一次拿不到的值,一律是 null,key 一定保留。不會用 -1、0 或空字串代替。
- 時間
- epoch 毫秒,UTC 的整數。
- 列舉
- 整數,從 1 開始,不用 0。新的值往後接,舊的號碼不會改。
- 小數
- 送出前四捨五入:lat、lng 7 位(約 1 公分),spd、spdAcc 2 位,其他公尺、度、°C 1 位。
- 排序
- 依 pt.id。pt.time 可能和上一點相同,手機改時間時也可能倒退。
- 平台
- 看 dev.os。同一個 key 在兩個平台意思相同,差別只在有沒有值。
- 新欄位
- 之後的版本可能在各組加新的 key。不認得的 key 請略過,不要整筆拒收;還沒更新的舊版 App 不會送較新的 key,請當成 null。格式版本看 ver 的大、中版號。
沒有另外標示平台的欄位,iOS 與 Android 都有值。
接收範例
收下一筆定位的關鍵步驟:檢查 Method 與 Header、解析 JSON、存起來、回 2xx。範例用 X-Api-Key: change-me 當權杖,請換成你在 App 裡設定的 Header。正式使用還要加上 HTTPS、資料庫與錯誤處理。
<?php$apiKey = 'change-me'; // 預設是 POST,App 裡設定成 PUT 就是 PUT$method = $_SERVER['REQUEST_METHOD'];if ($method !== 'POST' && $method !== 'PUT') { http_response_code(405); exit;} // 你在 App 裡加的 Header(名稱和值自己決定)if (!hash_equals($apiKey, $_SERVER['HTTP_X_API_KEY'] ?? '')) { http_response_code(401); exit;} // Body 是一行 JSON,所以 $_POST 是空的$body = file_get_contents('php://input');$payload = json_decode($body, true);if (!is_array($payload['dev'] ?? null) || !is_array($payload['pt'] ?? null)) { http_response_code(400); exit;} $dev = $payload['dev'];$pt = $payload['pt']; // pt.trig 是 4 的是「發送測試」:pt.id 是 0,lat、lng 可能是 nullif ($pt['trig'] !== 4) { file_put_contents('points.jsonl', $body . "\n", FILE_APPEND | LOCK_EX); error_log("{$dev['id']} {$payload['startAt']} #{$pt['id']} {$pt['lat']},{$pt['lng']}");} // 回任何 2xx 都算成功(10 秒逾時,不重送)http_response_code(204);不用手機也能測
正式版 App 只送 https://,本機開發時收不到。可以先用 curl 送一筆範例資料到本機的接收範例(port 8080),內容和 iOS 範例相同。
curl -i -X POST http://localhost:8080/ \ -H 'Content-Type: application/json' \ -H 'X-Api-Key: change-me' \ -d '{"ver":"1.0.0","sentAt":1759631403120,"startAt":1759630000000,"dev":{"id":"3F2A9C1E-7B4D-4E8A-9C21-5D6E7F809A1B","os":"ios","osVer":"15.8.8","appVer":"1.0.0","build":"1","tz":"Asia/Taipei","locale":"zh-TW","mfr":"Apple","model":"iPhone9,2","isSim":false},"perm":{"loc":2,"precise":true,"noBatOpt":null},"bat":{"lvl":85,"state":1,"plug":null,"temp":null,"volt":null,"lowPower":false,"thermal":1},"net":{"type":2,"metered":true,"lowData":false,"gen":4},"rule":{"name":"Survey","mode":1,"interval":30,"distance":null,"heartbeat":null,"power":1},"pt":{"id":1024,"time":1759631398000,"trig":1,"lat":23.5678901,"lng":120.3045672,"acc":4.8,"alt":12.3,"ellAlt":31.5,"altAcc":3.0,"spd":1.2,"spdAcc":0.5,"crs":87.5,"crsAcc":12.0,"satUsed":null,"satView":null,"isSim":false,"isExt":false,"held":false,"suspect":false}}'交給 AI 寫
我們把這份規格、四個接收範例和測試腳本包成 Agent Skill。裝好之後跟 AI 說「幫我寫定位郵差的接收端」,它就會照規格寫,連容易漏掉的細節都會處理:發送測試、亂序、null、回 204。Skill 和規格一律是最新版,App 更新格式時重新下載即可。
一行指令安裝
解壓縮到兩個地方:~/.claude/skills 給 Claude Code,~/.agents/skills 給 Codex、GitHub Copilot、Cursor。要更新時再執行一次。
curl -fsSL https://tracker.iwi.tw/skill/geocourier-receiver.zip -o /tmp/geocourier-receiver.zipmkdir -p ~/.claude/skills ~/.agents/skillsunzip -oq /tmp/geocourier-receiver.zip -d ~/.claude/skillsunzip -oq /tmp/geocourier-receiver.zip -d ~/.agents/skills各工具放在哪裡
| 工具 | 個人(所有專案) | 專案(跟著版控) |
|---|---|---|
| Claude Code | ~/.claude/skills/ | .claude/skills/ |
| Codex | ~/.agents/skills/ | .agents/skills/ |
| GitHub Copilot | ~/.copilot/skills/ | .github/skills/ |
| Cursor | ~/.cursor/skills/ | .cursor/skills/ |
路徑依各工具 2026 年 10 月的官方文件。其他支援 Agent Skills 的工具,把解壓縮出來的 geocourier-receiver 資料夾放進它的 skills 目錄即可。
Claude.ai、Claude Desktop
到「設定 → Capabilities」開啟 Code execution,再到 Skills 區塊按 Upload skill,上傳下載的 zip。
不支援 Skill 的 AI
把 https://tracker.iwi.tw/spec.md 貼給它,或下載後上傳。這是同一份規格的純文字版;這一頁是用 JavaScript 畫的,多數 AI 讀網頁時看不到內容。
可以這樣問
- 幫我用 Laravel 寫定位郵差的接收端,存進 MySQL
- 我的 Express 收不到定位郵差送來的資料,幫我找原因
- 定位郵差的 rule.heartbeat 是什麼?要怎麼判斷手機斷線?
- 幫我設計存定位點的資料表,之後要畫出每一次定位的路線
外層
verversionstring格式版本,跟 App 版本走,值和 dev.appVer 相同,例如 1.0.0。看大、中版號判斷格式:回拋的結構有變動時,App 一定升大版號或中版號(例如 1.0 → 1.1);只有小版號不同(1.0.0、1.0.3)表示格式相同,只是 App 本身的修正。
sentAtsent atint(ms)送出這個請求的時間。
startAtstart atint(ms)發送測試時可能 null這次按下「開始定位」的時間。pt.id 不會因為重新開始而歸零,要分出是哪一次定位就看它。沒在定位時按發送測試,這裡是 null。
devdevice裝置,安裝後大致不變
idstring安裝 App 時產生的 UUID。不跟著系統備份,刪 App 重裝、從備份還原到新手機都會換新的。不是硬體識別碼,也不是廣告 ID。
osoperating systemstring平台。
iosiOSandroidAndroid
osVerOS versionstring系統版本,例如 15.8.8(iOS)、15(Android)。
appVerapp versionstringApp 版本,例如 1.0.0。
buildstringApp 的 build 號。
tztime zonestringIANA 時區,例如 Asia/Taipei。
localestring手機的語言與地區,固定是「語言-地區」,例如 zh-TW、en-US、ja-JP;沒有地區時只有語言。語言是使用者為這個 App 選的語言,沒有另外選就是手機的第一個語言。不分繁體、簡體:簡體中文、地區香港的手機是 zh-HK。
mfrmanufacturerstring廠牌。
- iOS
- 固定 Apple
- Android
- 例如 OPPO
modelstring型號代號,不是行銷名稱。
- iOS
- 例如 iPhone9,2
- Android
- 例如 CPH2581
isSimis simulatorboolApp 是不是跑在模擬器上。
- iOS
- 準確
- Android
- 沒有官方 API,依系統特徵判斷,官方模擬器判斷得出來
permpermission權限,使用者隨時會改
loclocationint定位權限。定位中 1 和 2 都是正常的:兩種都能在背景持續定位。沒給權限時也能按「發送測試」,這時是 3。
1使用 App 期間2永遠3沒有權限(還沒問過、拒絕或受限)
precisebool是否允許精確位置。false 時位置只精確到幾公里;loc 是 3 時一律 false。
noBatOptno battery optimizationbool可能 nulltrue 表示不受電池最佳化限制,在背景比較不會被系統停掉。
- iOS
- 沒有,固定 null
batbattery電池與電源
lvllevelint可能 null電量 0~100。
- iOS
- iOS 17 起以 5% 為一階
- Android
- 以 1% 為一階
stateint可能 null充電狀態。系統回報未知時是 null。
1未插電2充電中3已充滿4插著電但沒在充電
- iOS
- 只會出現 1~3
plugint可能 null電源種類。沒插電時是 null。
1AC2USB3無線4底座
- iOS
- 沒有,固定 null
- Android
- 底座要 Android 13 以上
temptemperaturenumber可能 null電池溫度,°C。
- iOS
- 沒有,固定 null
voltvoltageint可能 null電池電壓,mV。
- iOS
- 沒有,固定 null
- Android
- 照系統的原值送出。部分手機給的是伏特(整數,例如 3、4),請自行判斷
lowPowerbool低耗電模式(iOS)、省電模式(Android)。
thermalint可能 null裝置的溫度等級,兩個平台用同一把 1~7 的尺。
1正常2微熱3偏熱4高溫,效能受限5過熱,需要降溫6嚴重過熱,部分功能關閉7即將關機
- iOS
- 只會出現 1、2、4、5
- Android
- Android 10 以上
netnetwork網路
typeint可能 null網路類型。沒有連線時是 null。
1Wi-Fi2行動網路3有線4其他
meteredbool可能 null計量網路,例如行動網路、手機熱點。
lowDatalow data modebool可能 null開了「低數據模式」。
- Android
- 沒有,固定 null
gengenerationint可能 null行動網路第幾代,2~5。只有 net.type 是 2 時才有值。
- iOS
- 5G NSA(和 4G 共用基地台的 5G)記成 5
- Android
- Android 13 以上。5G NSA 系統回報的是 4G,記成 4
rule目前的採集規則
使用者可以存好幾組採集方式,定位中也能切換或修改,所以每一點都附上當下的規則,伺服器可以推算多久該收到一筆。
namestring採集方式的名稱,只是給人看的。App 內建的幾組會跟著 App 的語言變(例如中文是「精準」、英文是「Precise」),使用者自己取的照原樣。要判斷規則請看 mode、interval、distance、heartbeat,不要用名稱判斷。
modeint採集依據。
1時間2距離
intervalint(秒)可能 null時間模式的間隔。距離模式是 null。
distanceint(公尺)可能 null距離模式的距離,可以是 0(每收到一筆定位就採集)。時間模式是 null。
heartbeatint(秒)可能 null距離模式靜止時的強制採集間隔。時間模式或關閉時是 null。
powerintApp 的耗電設定,決定靜止時送出什麼位置。
1耗電:全程最高精度,只有超過 2 分鐘沒有新定位時才送保留的位置2省電:靜止時少定位(iOS 降低精度、Android 降低頻率),改送保留的位置(pt.held 是 true)
ptpoint這次的定位點
Android 只允許大約位置(perm.precise 是 false)時,系統會把位置模糊化:acc 約 2000 公尺,ellAlt、spd、crs、satUsed、satView 是 null(模擬器實測,實機待確認)。
idint流水號。安裝後從 1 開始(和 dev.id 一樣不跟著系統備份),每採集一點加 1,停止再開始不會歸零。送出前先寫進手機,App 當掉也不會重複。發送測試固定是 0。
timeint(ms)發送測試時可能 null這個位置定到的時間,不是送出的時間。GPS 沒更新、或靜止時送保留的位置(pt.held 是 true)時,會和上一點相同。
trigtriggerint為什麼採集這一點。
1時間到2移動超過距離3靜止時的強制採集4發送測試
latlatitudenumber發送測試時可能 nullWGS84 緯度,小數 7 位。
lnglongitudenumber發送測試時可能 nullWGS84 經度,小數 7 位。
accaccuracynumber可能 null水平準度,公尺(半徑)。
altaltitudenumber可能 null海拔(平均海平面),公尺。
- Android
- 由橢球高換算,換算失敗時是 null
ellAltellipsoidal altitudenumber可能 nullWGS84 橢球高,公尺。
altAccaltitude accuracynumber可能 null高度準度,公尺。
spdspeednumber可能 null速度,m/s。
spdAccspeed accuracynumber可能 null速度準度,m/s。
crscoursenumber可能 null移動方向,度,正北為 0、順時針。是往哪裡走,不是手機朝向。
crsAcccourse accuracynumber可能 null方向準度,度。
satUsedsatellites usedint可能 null定位用到的衛星數。Android 關螢幕時也有值。系統把 GPS 關掉之後(實測小米關螢幕約 33 分鐘),送的是最後一次的值,不是 null。
- iOS
- 沒有,固定 null
satViewsatellites in viewint可能 null看得到的衛星數。Android 關螢幕時也有值。系統把 GPS 關掉之後(實測小米關螢幕約 33 分鐘),送的是最後一次的值,不是 null。
- iOS
- 沒有,固定 null
isSimis simulatedbool可能 null這個位置是不是模擬或假的定位。實機裝了模擬定位 App 時,dev.isSim 是 false、這裡是 true。
isExtis externalbool可能 null這個位置是不是來自外接 GPS 或 CarPlay。
- Android
- 沒有,固定 null
heldbool是不是保留的位置。省電模式(rule.power 是 2)下,手機靜止約 2 分鐘後,App 改送靜止期間最準的一筆,座標與 pt.time 會一直重複,直到再次移動。兩種模式下,超過 2 分鐘沒有新定位(例如手機長時間放著,系統暫停 GPS)時也是 true。
suspectbool這一點是不是可疑。App 拿它和前面幾點比較:照前面的速度走,再加上最大的加速度,這段時間也走不到這麼遠(兩點的 pt.acc 也算進容許的範圍),就是 true。沒有速度、準度差的粗定位(多半是 Wi-Fi 估的)另外和最後一筆 GPS 定位比,離得比照原速走得到的還遠(至少 100 公尺)也是 true,等 GPS 回來確認;持續偏離 20 秒都沒有 GPS 才當成真的。可疑的點照樣送出,建議畫路線、算距離時略過。看的是速度的變化,不是固定的速度上限,所以開車、高鐵、飛機都不會因為快而被標。萬一被誤標,連續幾點都落在新位置附近時,App 會改用新位置當參考。
接收端常見情境
濾掉發送測試
pt.trig 是 4、pt.id 是 0 的是 App 裡按「發送測試」送的,不是真的移動。
排序與掉點
請求可能亂序到達,依 pt.id 排序。App 不重送,pt.id 不連續就是中間掉了點。
分出每一次定位
同一支手機看 dev.id,同一次定位看 startAt。停止再開始時 startAt 會換,pt.id 繼續往上加。
判斷是不是斷線
時間模式大約每 rule.interval 秒一筆;距離模式靜止時最久每 rule.heartbeat 秒一筆。heartbeat 是 null 時,靜止不動就不會有資料,不代表斷線。使用者可能在定位中切換採集方式,請用最新一點的 rule 推算。最後一點的 pt.held 是 true 時例外,見下一段。
Android 停著不動很久時,點會變少
Android 手機關螢幕、沒插電、放著不動約 30 分鐘以上,系統會讓手機深度睡眠。這段時間 App 只能等系統偶爾醒來時送出,就算 rule.interval 是 3 秒,也可能好幾分鐘才收到一筆(2026-10 小米、OPPO 實測最長約 10 分鐘,其他手機可能更久)。送的是保留的位置,pt.held 是 true。判斷斷線時請看最後收到的那一點:pt.held 是 false,照 rule.interval 判斷;pt.held 是 true,代表手機停著不動,請放寬到至少 15 分鐘,或不要只因為沒收到就判定斷線。手機一移動就會醒來,恢復照間隔送出。
靜止時座標不變
pt.held 是 true 時,座標與 pt.time 和上一點相同是正常的:手機沒在動,App 送的是保留的位置。pt.held 是 false、pt.time 卻很久沒變,才可能是 GPS 收不到訊號。
準度差的點
App 不依準度過濾,準度差的點照樣送。要不要留,看 pt.acc 由伺服器決定。省電模式靜止時,App 用來判斷有沒有移動的粗定位不送出,改送保留的位置(pt.held);粗定位偏很遠時,App 會先等 GPS 確認,不會直接當成出發。另外,iPhone 收不到 GPS 時,系統會用附近的 Wi-Fi 估位置,偶爾會估到幾百公尺到 1 公里外,pt.acc 卻只報幾十公尺(2026-10 實測)。這種和前面比起來跳得不合理的點,App 照送,但 pt.suspect 是 true,畫路線、算距離時建議略過。要自己另外過濾的話,用相鄰兩點的距離除以 pt.time 的差,速度明顯不合理就丟掉;只看 pt.spd 是 null 不夠,iPhone 在室內大部分的點都沒有速度。
假的位置
pt.isSim 是 true 表示這個位置是模擬的;dev.isSim 是 true 表示 App 跑在模擬器上。