00:00 / 00:00

Subspace Shop

LAPLACE Fulfiller

自動化履約框架,透過 GraphQL Admin API 把第三方倉庫(不具備 Shopify 原生整合)的訂單出貨資訊同步至 Shopify

功能特性

  • 多服務商支援:可擴充架構,支援接入多個履約服務商
  • 自動監控:每 30 分鐘檢查一次服務商訂單
  • 智慧履約:僅履約指定服務商倉庫地點的訂單
  • 防重複處理:Turso 資料庫記錄所有服務商已履約的訂單
  • 服務商專屬邏輯:每個服務商都可以自訂訂單擷取與物流追蹤邏輯
  • 型別安全的 GraphQL:使用 Shopify GraphQL Admin API (2025-07),並自動產生型別
  • 內建服務商
    • 柔造:中國履約服務商
    • HiCustom:中國 POD 履約服務商

前置需求

  • Bun 執行環境
  • 履約服務商的 API 憑證
  • 已開通 API 存取權限的 Shopify 商店
  • 與服務商倉庫相對應的 Shopify 地點

安裝

bun install

設定

在專案根目錄建立 .env 檔案,寫入以下變數:

# Turso 資料庫設定(必填)
TURSO_DATABASE_URL=libsql://your-database-turso.io
TURSO_AUTH_TOKEN=your-turso-auth-token

# Shopify API 設定(必填)
SHOPIFY_API_KEY=your_shopify_api_key_here
SHOPIFY_API_SECRET=your_shopify_api_secret_here
SHOPIFY_ACCESS_TOKEN=your_shopify_access_token_here
SHOPIFY_SHOP_DOMAIN=yourshop.myshopify.com
SHOPIFY_APP_URL=https://your-app-url.com

# 服務商 API 設定
# 柔造(設定 ROUZAO_TOKEN 後自動啟用)
ROUZAO_TOKEN=your_rouzao_token_here
ROUZAO_LOCATION_IDS=location_id_1,location_id_2  # 選填:以半形逗號分隔的 Shopify 地點 ID
# 未設定時預設使用柔造的倉庫地點 ID

# HiCustom(設定 API_KEY 與 API_SECRET 後自動啟用)
HICUSTOM_API_KEY=your_hicustom_api_key
HICUSTOM_API_SECRET=your_hicustom_api_secret
HICUSTOM_LOCATION_IDS=location_id_1,location_id_2  # 選填:以半形逗號分隔的 Shopify 地點 ID
# HICUSTOM_API_URL=https://api.hicustom.com  # 選填:覆寫 API 基礎網址

# 視需要新增更多服務商
# PROVIDER3_API_KEY=your_api_key
# PROVIDER3_API_SECRET=your_secret

設定 Turso 資料庫

  1. 安裝 Turso CLI

    curl -sSfL https://get.tur.so/install.sh | bash
  2. 建立資料庫

    turso auth signup  # 或 turso auth login
    turso db create laplace-fulfiller
  3. 取得資料庫憑證

    # 取得資料庫 URL
    turso db show laplace-fulfiller --url
    
    # 建立驗證 token
    turso db tokens create laplace-fulfiller
  4. 將 schema 推送至資料庫

    bun run db:push

注意:從本機 SQLite 遷移時,需要先從舊資料庫匯出資料再匯入 Turso,兩者的 schema 保持相容

取得柔造 Token

  1. 登入柔造 (https://www.rouzao.com)
  2. 開啟瀏覽器開發者工具 (F12)
  3. 切換至 Network 標籤頁
  4. 執行任意會呼叫 API 的操作
  5. 在請求中尋找 Rouzao-Token 標頭

取得 HiCustom 憑證

  1. 登入 HiCustom (https://www.hicustom.com)
  2. 前往 API 設定或開發者專區
  3. 建立新的應用程式或 API 用戶端
  4. 複製 API Key 與 API Secret
  5. 記錄 HiCustom 負責出貨的 Shopify 地點 ID

HiCustom 整合使用其 OAuth API,並會自動更新 token。參見其 API 文件:

設定 Shopify API 存取權限

  1. 在 Shopify 後台建立私有應用程式
  2. 授予以下權限:
    • 讀取訂單 (read_orders)
    • 寫入訂單 (write_orders)
    • 讀取地點 (read_locations),可選但建議
    • 讀取商家管理的履約訂單 (read_merchant_managed_fulfillment_orders)
    • 寫入商家管理的履約訂單 (write_merchant_managed_fulfillment_orders)
  3. 複製 API 憑證

重要:應用程式正常運作必須具備履約訂單相關權限。缺少這些權限時,處理訂單會回傳 403 Forbidden 錯誤

Shopify 地點設定

每個服務商都必須在 Shopify 中有對應的地點。服務商只會履約其已註冊地點的訂單

執行 bun run diagnose 可以取得 Shopify 商店中的地點 ID

柔造

  • ROUZAO_LOCATION_IDS 環境變數中指定地點 ID
  • 或建立名稱中包含「Rouzao」的地點

HiCustom

  • HICUSTOM_LOCATION_IDS 環境變數中指定地點 ID
  • 或建立名稱中包含「HiCustom」的地點

其他服務商:查看該服務商的 locationIds 或地點名稱比對規則

執行

開發

# 以 cron 方式持續執行,日誌經 pino-pretty 美化輸出
bun run dev

# 只執行一次後結束(同樣美化輸出)
bun run once

# 或直接呼叫,不做美化輸出
bun run src/index.ts --once

正式環境

正式環境使用 bun run start,它會先執行資料庫遷移,再以原始 JSON 日誌輸出啟動服務(適合日誌彙整系統):

bun run start

使用 GitHub Container Registry 上官方維護的容器映像檔部署本應用程式。為確保一致性、安全性與相容性,這是唯一支援的部署方式

使用官方映像檔

# 拉取最新映像檔
docker pull ghcr.io/laplace-live/fulfiller:latest

# 執行容器
docker run -d \
  --name laplace-fulfiller \
  --restart unless-stopped \
  --env-file .env \
  ghcr.io/laplace-live/fulfiller:latest

Docker Compose 範例

建立 docker-compose.yml 檔案可以簡化部署:

services:
  fulfiller:
    image: ghcr.io/laplace-live/fulfiller:latest
    restart: unless-stopped
    env_file: .env

接著執行:

docker-compose up -d

容器映像檔倉庫

官方映像檔已公開發布於 GitHub Container Registry:

# 拉取官方映像檔
docker pull ghcr.io/laplace-live/fulfiller:latest

# 檢視可用的標籤與版本
# 造訪 https://github.com/laplace-live/fulfiller/pkgs/container/fulfiller

每次發布都會自動建置並推送映像檔,確保你始終能取得最新的穩定版本

運作方式

  1. 服務商註冊:啟動時註冊並初始化所有已啟用的服務商
  2. 訂單監控:服務每 30 分鐘從所有已啟用的服務商拉取訂單
  3. 已出貨訂單辨識:各服務商依出貨狀態篩選自己的訂單
  4. 訂單詳情:為每筆已出貨訂單拉取詳細資訊,包括物流追蹤資訊
  5. Shopify 訂單查找:各服務商用自己的邏輯擷取 Shopify 訂單編號
  6. 智慧履約:僅履約分配給該服務商指定倉庫地點的商品
  7. 防重複處理:將已履約訂單連同服務商資訊記錄至 Turso 資料庫

資料庫

服務使用 Turso(分散式 SQLite)搭配 Drizzle ORM,記錄所有服務商已履約的訂單:

資料表結構

  • provider:服務商識別碼,例如 'rouzao'
  • provider_order_id:服務商端的訂單 ID
  • shopify_order_number:Shopify 訂單編號
  • shopify_order_id:Shopify 訂單 ID
  • fulfilled_at:履約時間戳記
  • created_at:記錄建立時間戳記

特性

  • (provider, provider_order_id) 唯一約束可防止重複
  • 依服務商與 Shopify 訂單編號建立索引,加快查詢
  • 每天凌晨自動清理超過 365 天的舊記錄
  • 藉由 Drizzle ORM 實作型別安全的查詢
  • Turso 的全球邊緣部署帶來低延遲存取
  • 自動備份與時間點還原
  • 與本機 SQLite 不同,沒有檔案大小限制

日誌

服務使用 Pino 輸出結構化日誌,時間戳記為 ISO 格式。預設以 JSON 輸出,每行一筆記錄,適合正式環境的日誌彙整系統。開發時,devoncediagnose 腳本會把輸出透過 pino-pretty 管線處理,轉成人類可讀的格式

每個模組都有自己的子 logger,例如 mainrouzaohicustom,因此可以依元件篩選日誌。日誌等級透過 LOG_LEVEL 環境變數調整,預設為 info

需要關注的輸出內容:

  • 訂單拉取結果
  • 已出貨訂單的處理過程
  • 履約成功或失敗
  • 錯誤訊息

疑難排解

常見問題

  1. 「Rouzao location not found」:確保 Shopify 中存在名為「Rouzao」或「柔造」的地點
  2. 「Order already fulfilled」:該訂單已在 Shopify 中處理或履約完成
  3. 「Invalid third party order SN format」:該訂單沒有有效的 Shopify 關聯資訊
  4. API 錯誤:檢查 API token 與網路連線狀況

除錯模式

需要更詳細的日誌時,可以修改程式碼中的 console.log 陳述式或補上額外的日誌輸出

診斷工具

執行診斷腳本,檢查 Shopify API 權限與連線狀態:

bun run diagnose

它會檢測:

  • 環境變數設定
  • Shopify API 驗證
  • 授予應用程式的存取權限範圍
  • Locations API 存取權限(列出所有倉庫地點)
  • Orders 與 Fulfillment Orders API 存取權限
  • 柔造地點是否可用

開發

技術堆疊

  • 執行環境:Bun(快速的一體化 JavaScript 執行環境)
  • 語言:TypeScript,啟用 strict 模式
  • 資料庫:Turso(分散式 SQLite),搭配 Drizzle ORM 與 @libsql/client
  • API:Shopify GraphQL Admin API (2025-07)
  • 型別產生:GraphQL Code Generator,使用 Shopify preset
  • 排程:使用 Croner 執行 cron 工作
  • 日誌:Pino(結構化 JSON),開發環境搭配 pino-pretty
  • 程式碼品質:Biome(linter + formatter),並啟用 import 排序
  • 容器化:GitHub Container Registry 提供可用於正式環境的 Docker 映像檔

GraphQL 型別產生

專案會依據 GraphQL 查詢自動產生 TypeScript 型別:

# 產生一次型別
bun run graphql-codegen

# 開發用的 watch 模式(尚未設定)
# bun run graphql-codegen:watch

產生的型別儲存在 src/types/admin.generated.d.ts,不要手動編輯

資料庫管理

使用 Drizzle ORM 進行型別安全的資料庫操作:

# 產生遷移檔案
bun run db:generate

# 套用遷移
bun run db:migrate

# 直接推送 schema 變更(開發環境)
bun run db:push

# 開啟 Drizzle Studio(視覺化資料庫瀏覽器)
bun run db:studio

多服務商架構

應用程式採用以服務商為基礎的架構,便於接入新的履約服務商

服務商介面

每個服務商都必須實作 Provider 介面:

interface Provider {
  id: string; // 服務商唯一識別碼
  name: string; // 可讀名稱
  locationIds: string[]; // 該服務商管理的 Shopify 地點 ID

  // 檢查服務商是否已完成必要設定
  isConfigured(): boolean;

  // 檢查某個地點是否屬於該服務商
  isProviderLocation(locationName: string, locationId: string): boolean;

  // 從服務商拉取已出貨訂單
  fetchShippedOrders(): Promise<ProviderOrder[]>;

  // 拉取訂單詳情
  fetchOrderDetail(orderId: string): Promise<ProviderOrderDetail | null>;

  // 從服務商資料中擷取 Shopify 訂單編號
  extractShopifyOrderNumber(orderDetail: ProviderOrderDetail): string | null;

  // 取得物流追蹤資訊
  getTrackingInfo(orderDetail: ProviderOrderDetail): TrackingInfo;
}

新增服務商

  1. 複製範例範本

    cp src/lib/providers/example.ts src/lib/providers/myprovider.ts
  2. 實作服務商邏輯

    • 更新 API 端點與驗證方式
    • 對映該服務商的資料結構
    • 設定物流商對應與物流追蹤連結
    • 填入對應的 Shopify 地點 ID
  3. 註冊服務商,在 src/lib/providers/registry.ts 中:

    import { myProvider } from "./myprovider";
    
    // 在建構函式中
    this.register(myProvider);
  4. 新增環境變數.env

    MYPROVIDER_API_KEY=your-api-key
    MYPROVIDER_API_SECRET=your-secret
  5. 執行應用程式,你的服務商會自動生效!

服務商特性

  • 自動訂單追蹤:各服務商的訂單分開追蹤
  • 自訂商業邏輯:服務商可以實作自己的訂單編號擷取規則
  • 集中式物流商體系:所有服務商共用一套物流商設定
  • 彈性的物流商別名:不同服務商可以用不同代碼指向同一家物流商
  • 自動產生物流連結:依物流商與運單號產生物流追蹤連結
  • 錯誤隔離:單一服務商出錯不影響其他服務商

集中式物流商設定

應用程式使用集中式物流商體系 (src/lib/carriers.ts):

  1. 物流商只定義一次:每家物流商都有名稱和物流追蹤連結範本
  2. 支援多個別名:不同服務商可以用不同的代碼或名稱指向同一家物流商
  3. 提供統一查詢:所有服務商都用同一組函式取得物流商資訊

範例:

// 柔造用 'sf' 代表順豐速運
// HiCustom 用 '顺丰速运' 代表順豐速運
// 兩者都會解析到同一家物流商,得到相同的物流追蹤連結

const trackingDetails = getTrackingDetails("sf", "123456");
// 或
const trackingDetails = getTrackingDetails("顺丰速运", "123456");

// 兩者的回傳值相同:
// {
//   carrierName: 'SF Express',
//   trackingUrl: 'https://www.sf-express.com/.../123456'
// }

要新增物流商或別名支援:

  1. 編輯 src/lib/carriers.ts
  2. CARRIERS 陣列中找到該物流商
  3. 將服務商使用的代碼或名稱加入 aliases 陣列

服務商設定

服務商會依自身設定自動啟用或停用:

自動偵測

  • 必要的環境變數已設定 → 啟用該服務商
  • 必要的環境變數缺失 → 停用該服務商

例如:

# 柔造會啟用(已提供必要的 token)
ROUZAO_TOKEN=abc123
ROUZAO_LOCATION_IDS=gid://shopify/Location/123,gid://shopify/Location/456  # 選填

# HiCustom 會啟用(已提供必要的憑證)
HICUSTOM_API_KEY=client123
HICUSTOM_API_SECRET=secret456
HICUSTOM_LOCATION_IDS=gid://shopify/Location/789,gid://shopify/Location/101  # 選填

# example 服務商會停用(缺少必要的金鑰)
# EXAMPLE_API_KEY=

運作方式: 每個服務商都實作 isConfigured() 方法,檢查必要的環境變數是否存在:

class MyProvider implements Provider {
  isConfigured(): boolean {
    return !!process.env["MYPROVIDER_API_KEY"];
  }
}

新增功能

  • 修改輪詢間隔:調整 src/index.ts 中的 cron 運算式
  • 新增 GraphQL 查詢:編輯 src/lib/queries.graphql.ts 並執行 bun run graphql-codegen
  • 支援更多物流商:在服務商實作中更新對應表
  • 所有匯入都使用 @/ 路徑別名,例如 import { Provider } from '@/types'

授權條款

AGPL-3.0

最後更新於 2026年9月19日

Tech otakus destroy the world