前言:一個簡單的環境變數引發的災難

在部署 Strapi CMS 到 Kubernetes 正式環境時,只是加了一行看似無害的環境變數設定:

env:
  - name: NODE_ENV
    value: production  # 就是這一行!

結果卻導致整個管理後台變成一片空白,連登入頁面都看不到。更詭異的是:

  • ✅ API 完全正常,GraphQL 和 REST 都能回應
  • ✅ Pod 狀態正常,沒有任何錯誤訊息
  • ✅ 日誌顯示 Strapi 成功啟動
  • ❌ 瀏覽器打開 /admin 卻是一片空白

這種「Schrodinger 的服務」(同時正常又不正常)讓人抓狂。經過一番排查,終於發現罪魁禍首是 CSP (Content Security Policy) 在作怪。

本文將深入探討:

  • 為什麼正式環境會出現空白頁
  • CSP 的工作原理與安全機制
  • 完整的問題排查步驟
  • 如何正確配置 Strapi 的安全策略
  • 生產環境的安全最佳實踐

問題背景:開發正常,正式環境空白

環境差異對比

graph LR
    subgraph "開發環境 (Development)"
        D1[NODE_ENV=development]
        D1 --> D2[✅ CSP 寬鬆]
        D1 --> D3[✅ 詳細錯誤訊息]
        D1 --> D4[✅ Hot Reload]
        D2 --> D5[✅ Admin Panel 正常]
    end

    subgraph "正式環境 (Production)"
        P1[NODE_ENV=production]
        P1 --> P2[🔒 CSP 嚴格]
        P1 --> P3[⚠️ 精簡錯誤訊息]
        P1 --> P4[📦 壓縮打包]
        P2 --> P5[❌ Admin Panel 空白]
    end

    style D5 fill:#22c55e
    style P5 fill:#ef4444

問題現象詳細描述

Kubernetes Deployment 設定:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: strapi-prod
  namespace: default
spec:
  replicas: 1
  selector:
    matchLabels:
      app: strapi
  template:
    metadata:
      labels:
        app: strapi
    spec:
      containers:
      - name: strapi
        image: myregistry.com/strapi:v5.0.0
        env:
          - name: NODE_ENV
            value: production  # 問題的起點
          - name: DATABASE_HOST
            valueFrom:
              secretKeyRef:
                name: strapi-db-secret
                key: host
          - name: ADMIN_JWT_SECRET
            valueFrom:
              secretKeyRef:
                name: strapi-admin-secret
                key: jwt-secret
        ports:
          - containerPort: 1337

部署後的症狀:

  1. Pod 狀態看起來完美

    $ kubectl get pods -n default
    
    NAME                            READY   STATUS    RESTARTS   AGE
    strapi-prod-66cb7494c5-abcde    1/1     Running   0          5m
    
  2. 日誌顯示正常啟動

    $ kubectl logs strapi-prod-66cb7494c5-abcde -n default
    
    [2025-05-07 10:15:23.456] INFO: Server started on port 1337
    [2025-05-07 10:15:23.567] INFO: Database connection established
    [2025-05-07 10:15:23.678] INFO: Admin panel available at /admin
    

  3. API 測試完全正常

    # GraphQL 查詢正常
    $ curl -X POST https://cms.example.com/graphql \
      -H "Content-Type: application/json" \
      -d '{"query": "{ articles { title } }"}'
    
    {"data":{"articles":[{"title":"Test Article"}]}}
    # REST API 也正常
    $ curl https://cms.example.com/api/articles
    
    {"data":[{"id":1,"attributes":{"title":"Test Article"}}]}
  4. 但瀏覽器打開後台是空白的

    https://cms.example.com/admin
    
    顯示:一片空白(白屏)
    

問題排查:抽絲剝繭找出真相

排查流程圖

flowchart TD
    Start[發現空白頁] --> CheckPod{Pod 狀態正常?}
    CheckPod -->|No| FixPod[修復 Pod 問題]
    CheckPod -->|Yes| CheckLogs{日誌有錯誤?}

    CheckLogs -->|Yes| AnalyzeLogs[分析錯誤日誌]
    CheckLogs -->|No| CheckAPI{API 能正常回應?}

    CheckAPI -->|No| FixAPI[修復 API 設定]
    CheckAPI -->|Yes| CheckBrowser[檢查瀏覽器 DevTools]

    CheckBrowser --> CheckNetwork[Network 標籤]
    CheckBrowser --> CheckConsole[Console 標籤]

    CheckNetwork --> FindBlocked[發現 JS 被阻擋]
    CheckConsole --> FindCSP[發現 CSP 錯誤]

    FindBlocked --> IdentifyCSP[確認是 CSP 問題]
    FindCSP --> IdentifyCSP

    IdentifyCSP --> Solution[修改 CSP 設定]
    Solution --> Test{問題解決?}
    Test -->|No| Adjust[調整設定]
    Test -->|Yes| Done[✅ 完成]

    Adjust --> Solution

    style Start fill:#f97316
    style Done fill:#22c55e
    style IdentifyCSP fill:#3b82f6

步驟 1:確認 Pod 和服務狀態

# 檢查 Pod 狀態
kubectl get pods -n default -l app=strapi

# 檢查 Pod 事件
kubectl describe pod strapi-prod-66cb7494c5-abcde -n default

# 查看 Strapi 日誌
kubectl logs strapi-prod-66cb7494c5-abcde -n default --tail=100

# 檢查 Service
kubectl get svc -n default -l app=strapi

結果:✅ 全部正常,沒有任何錯誤訊息

步驟 2:測試 API 功能

# 測試 REST API
curl -i https://cms.example.com/api/articles

# 測試 GraphQL
curl -X POST https://cms.example.com/graphql \
  -H "Content-Type: application/json" \
  -d '{"query": "{ articles { id title } }"}'

# 測試健康檢查端點
curl https://cms.example.com/_health

結果:✅ API 完全正常,資料庫連線正常

步驟 3:瀏覽器 DevTools 檢查(關鍵!)

打開 Chrome DevTools(F12 或 Cmd+Option+I)

1. Console 標籤顯示錯誤:

Refused to load the script 'https://cms.example.com/_content/admin/strapi-CNBo0SYx.js'
because it violates the following Content Security Policy directive:
"script-src 'self' blob:". Note that 'script-src-elem' was not explicitly set,
so 'script-src' is used as a fallback.

2. Network 標籤顯示:

strapi-CNBo0SYx.js    (blocked:csp)    Failed to load resource
runtime-CNBo0SYx.js   (blocked:csp)    Failed to load resource
vendor-CNBo0SYx.js    (blocked:csp)    Failed to load resource

找到了!JavaScript 檔案被 CSP (Content Security Policy) 阻擋!


CSP (Content Security Policy) 深度解析

什麼是 CSP?

CSP 是一種瀏覽器安全機制,用於防止 XSS (跨站腳本攻擊) 和其他程式碼注入攻擊。它透過 HTTP Header 告訴瀏覽器哪些資源來源是可信的。

CSP 工作原理:

sequenceDiagram
    participant Browser as 瀏覽器
    participant Server as Strapi Server
    participant JS as JavaScript 檔案

    Browser->>Server: GET /admin
    Server-->>Browser: HTTP Response<br/>+ CSP Header

    Note over Browser: 解析 CSP 規則

    Browser->>JS: 嘗試載入 strapi-xxx.js

    alt JS 來源符合 CSP 規則
        JS-->>Browser: ✅ 載入成功
        Browser->>Browser: 執行 JavaScript
    else JS 來源違反 CSP 規則
        JS--xBrowser: ❌ 被阻擋
        Browser->>Browser: Console 顯示錯誤
        Note over Browser: 頁面空白
    end

CSP 指令說明

常見的 CSP 指令:

指令說明範例
default-src預設來源(其他未指定的都套用此規則)'self'
script-srcJavaScript 來源'self' 'unsafe-inline'
style-srcCSS 樣式來源'self' https://fonts.googleapis.com
img-src圖片來源'self' data: https:
connect-srcAJAX/WebSocket 連線來源'self' https://api.example.com
font-src字型來源'self' https://fonts.gstatic.com
frame-srciframe 來源'none'

特殊關鍵字(必須加引號):

關鍵字說明安全性
'none'不允許任何來源最安全
'self'只允許同源安全
'unsafe-inline'允許行內腳本/樣式⚠️ 較不安全
'unsafe-eval'允許 eval() 等動態執行⚠️ 不安全
'strict-dynamic'動態載入腳本(需配合 nonce)較安全

Strapi 預設的 CSP 設定

開發模式(NODE_ENV=development):

// Strapi 內建預設
{
  directives: {
    'script-src': ["'self'", "'unsafe-inline'", 'cdn.jsdelivr.net'],
    'img-src': ["'self'", 'data:', 'blob:', 'cdn.jsdelivr.net'],
    'connect-src': ["'self'", 'https:'],
    // ... 較寬鬆的設定
  }
}

正式模式(NODE_ENV=production):

// Strapi 自動啟用嚴格模式
{
  directives: {
    'script-src': ["'self'", 'blob:'],  // ❌ 缺少 'unsafe-inline'
    'img-src': ["'self'", 'data:', 'blob:'],
    'connect-src': ["'self'"],
    // ... 更嚴格的設定
  }
}

問題所在:

  • Strapi Admin Panel 會動態載入打包後的 JS 檔案(如 strapi-CNBo0SYx.js
  • 這些檔案需要 'unsafe-inline' 和可能的外部來源
  • 正式環境的嚴格 CSP 阻擋了這些檔案的載入

解決方案:正確配置 CSP 設定

方案 1:修改 Strapi 中介層設定(推薦)

位置:config/middlewares.js

module.exports = [
  'strapi::logger',
  'strapi::errors',
  {
    name: 'strapi::security',
    config: {
      contentSecurityPolicy: {
        useDefaults: true,
        directives: {
          // 允許從同源和行內載入腳本
          'script-src': ["'self'", "'unsafe-inline'", 'cdn.jsdelivr.net'],

          // 如果使用外部 CDN 或自訂域名
          'script-src-elem': [
            "'self'",
            'cdn.jsdelivr.net',
            process.env.PUBLIC_URL,  // 允許從設定的 CDN 載入
          ],

          // 允許圖片來源
          'img-src': [
            "'self'",
            'data:',
            'blob:',
            'cdn.jsdelivr.net',
            'strapi.io',
            process.env.CDN_URL,
          ],

          // 允許媒體來源
          'media-src': ["'self'", 'data:', 'blob:', process.env.CDN_URL],

          // 允許 API 連線
          'connect-src': ["'self'", 'https:'],

          // 允許字型來源
          'font-src': ["'self'", 'data:'],

          // 允許樣式來源
          'style-src': ["'self'", "'unsafe-inline'", 'cdn.jsdelivr.net'],

          // Frame ancestors(防止被嵌入 iframe)
          'frame-ancestors': ["'self'"],
        },
      },
    },
  },
  'strapi::cors',
  'strapi::poweredBy',
  'strapi::query',
  'strapi::body',
  'strapi::session',
  'strapi::favicon',
  'strapi::public',
];

關鍵說明:

1. 'self' 必須加引號

// ❌ 錯誤
'script-src': [self]

// ✅ 正確
'script-src': ["'self'"]

2. 'unsafe-inline' 的必要性

Strapi Admin Panel 使用了一些行內腳本和樣式,如果不加這個會被阻擋。雖然名字有 “unsafe”,但對於管理後台來說是可接受的(因為只有管理員能存取)。

3. process.env.PUBLIC_URL 動態設定

如果你的靜態資源部署在 CDN:

# Kubernetes Deployment
env:
  - name: PUBLIC_URL
    value: "https://cdn.myshop.com"
  - name: CDN_URL
    value: "https://cdn.myshop.com"

4. useDefaults: true

保留 Strapi 預設的基本規則,只覆蓋需要調整的部分。

方案 2:環境變數控制(靈活)

建立 config/middlewares.js(支援環境變數):

const isDevelopment = process.env.NODE_ENV === 'development';

module.exports = [
  'strapi::logger',
  'strapi::errors',
  {
    name: 'strapi::security',
    config: {
      contentSecurityPolicy: {
        useDefaults: true,
        directives: {
          'script-src': [
            "'self'",
            ...(isDevelopment ? ["'unsafe-inline'", "'unsafe-eval'"] : ["'unsafe-inline'"]),
            'cdn.jsdelivr.net',
            process.env.CDN_URL,
          ].filter(Boolean),

          'connect-src': [
            "'self'",
            'https:',
            ...(isDevelopment ? ['http:', 'ws:'] : []),  // 開發模式允許 WebSocket
          ].filter(Boolean),

          'img-src': [
            "'self'",
            'data:',
            'blob:',
            process.env.CDN_URL,
            process.env.UPLOAD_CDN_URL,
          ].filter(Boolean),
        },
      },
    },
  },
  'strapi::cors',
  'strapi::poweredBy',
  'strapi::query',
  'strapi::body',
  'strapi::session',
  'strapi::favicon',
  'strapi::public',
];

方案 3:Nginx/Ingress 層級設定(不推薦)

也可以在 Nginx Ingress 設定 CSP Header,但這樣會覆蓋 Strapi 的設定:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: strapi-ingress
  annotations:
    nginx.ingress.kubernetes.io/configuration-snippet: |
      add_header Content-Security-Policy "script-src 'self' 'unsafe-inline' cdn.jsdelivr.net;" always;
spec:
  rules:
  - host: cms.example.com
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: strapi-service
            port:
              number: 1337

不推薦的原因:

  • 難以維護(設定分散在多個地方)
  • Nginx 設定會覆蓋應用層設定
  • 不利於開發/測試環境的一致性

部署流程與驗證

完整部署流程

flowchart TD
    Start[修改 config/middlewares.js] --> Build[建置 Docker Image]
    Build --> Push[推送到 Registry]
    Push --> Deploy[部署到 Kubernetes]

    Deploy --> Wait[等待 Pod 就緒]
    Wait --> Check1{Pod 狀態正常?}

    Check1 -->|No| Debug1[檢查 logs]
    Check1 -->|Yes| Check2[測試 API]

    Check2 --> Check3{API 正常?}
    Check3 -->|No| Debug2[檢查設定]
    Check3 -->|Yes| Check4[測試 Admin Panel]

    Check4 --> Check5{Admin 正常?}
    Check5 -->|No| CheckCSP[檢查 CSP Header]
    Check5 -->|Yes| Verify[功能驗證]

    CheckCSP --> Adjust[調整 CSP 設定]
    Adjust --> Build

    Verify --> Done[✅ 部署完成]

    style Start fill:#4ade80
    style Done fill:#22c55e
    style Debug1 fill:#f97316
    style Debug2 fill:#f97316

步驟 1:更新 Dockerfile

FROM node:18-alpine

WORKDIR /app

# 複製 package files
COPY package.json package-lock.json ./
RUN npm ci --only=production

# 複製應用程式碼
COPY . .

# 複製更新的 middlewares 設定
COPY config/middlewares.js ./config/

# 建置 Admin Panel
ENV NODE_ENV=production
RUN npm run build

# 暴露 port
EXPOSE 1337

# 啟動
CMD ["npm", "start"]

步驟 2:建置並推送

# 建置映像檔
docker build -t myregistry.com/strapi:v5.0.1 .

# 推送到 Registry
docker push myregistry.com/strapi:v5.0.1

步驟 3:更新 Kubernetes Deployment

apiVersion: apps/v1
kind: Deployment
metadata:
  name: strapi-prod
  namespace: default
spec:
  replicas: 1
  selector:
    matchLabels:
      app: strapi
  template:
    metadata:
      labels:
        app: strapi
        version: v5.0.1
    spec:
      containers:
      - name: strapi
        image: myregistry.com/strapi:v5.0.1  # 更新版本
        env:
          - name: NODE_ENV
            value: production
          - name: PUBLIC_URL
            value: "https://cms.example.com"
          - name: CDN_URL
            value: "https://cdn.example.com"
          - name: DATABASE_HOST
            valueFrom:
              secretKeyRef:
                name: strapi-db-secret
                key: host
        ports:
          - containerPort: 1337
        livenessProbe:
          httpGet:
            path: /_health
            port: 1337
          initialDelaySeconds: 60
          periodSeconds: 30
        readinessProbe:
          httpGet:
            path: /_health
            port: 1337
          initialDelaySeconds: 30
          periodSeconds: 10

步驟 4:部署並驗證

# 套用設定
kubectl apply -f deployment.yaml

# 監控部署狀態
kubectl rollout status deployment/strapi-prod -n default

# 查看 Pod 狀態
kubectl get pods -n default -l app=strapi -w

# 查看日誌
kubectl logs -f deployment/strapi-prod -n default

步驟 5:驗證 CSP Header

# 檢查回應的 CSP Header
curl -I https://cms.example.com/admin

# 應該看到類似這樣的 Header
HTTP/2 200
content-security-policy: script-src 'self' 'unsafe-inline' cdn.jsdelivr.net;
                         img-src 'self' data: blob:;
                         connect-src 'self' https:;

或使用瀏覽器 DevTools:

Network → 選擇 admin 頁面 → Headers → Response Headers
查找 content-security-policy

步驟 6:功能測試檢查清單

  • Admin Panel 首頁正常顯示
  • 能夠登入管理後台
  • Content Manager 正常載入
  • Media Library 圖片正常顯示
  • 可以新增/編輯/刪除內容
  • GraphQL Playground 正常運作
  • API 端點正常回應
  • 瀏覽器 Console 沒有 CSP 錯誤

安全性最佳實踐

1. 最小權限原則

只開放必要的來源,避免使用過於寬鬆的設定:

// ❌ 不安全:允許任何 HTTPS 來源
'script-src': ["'self'", 'https:']

// ✅ 安全:明確指定可信來源
'script-src': ["'self'", "'unsafe-inline'", 'cdn.jsdelivr.net']

2. 避免 'unsafe-eval'

盡可能不要使用 'unsafe-eval',它會允許 eval() 等動態程式碼執行:

// ❌ 非常不安全
'script-src': ["'self'", "'unsafe-inline'", "'unsafe-eval'"]

// ✅ 只使用必要的 unsafe-inline
'script-src': ["'self'", "'unsafe-inline'"]

3. 使用 Nonce 或 Hash(進階)

對於更高的安全性,可以使用 nonce 或 hash:

// 使用 nonce(需要在每個請求動態生成)
module.exports = {
  name: 'strapi::security',
  config: {
    contentSecurityPolicy: {
      useDefaults: true,
      directives: {
        'script-src': ["'self'", (ctx) => `'nonce-${ctx.state.nonce}'`],
      },
    },
  },
};

4. 分離 Admin 和 API

更安全的做法是將 Admin Panel 和 Public API 分開部署:

# Admin Panel (內部網路)
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: strapi-admin-ingress
spec:
  rules:
  - host: admin.internal.example.com
    http:
      paths:
      - path: /admin
        pathType: Prefix
        backend:
          service:
            name: strapi-service
            port:
              number: 1337

---
# Public API (對外開放)
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: strapi-api-ingress
spec:
  rules:
  - host: api.example.com
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: strapi-service
            port:
              number: 1337

5. 監控 CSP 違規

設定 CSP 報告端點來追蹤違規:

module.exports = {
  name: 'strapi::security',
  config: {
    contentSecurityPolicy: {
      useDefaults: true,
      directives: {
        'script-src': ["'self'", "'unsafe-inline'"],
        'report-uri': ['/csp-violation-report'],  // 違規報告
      },
    },
  },
};

建立接收違規報告的端點:

// api/csp-report/routes/csp-report.js
module.exports = {
  routes: [
    {
      method: 'POST',
      path: '/csp-violation-report',
      handler: 'csp-report.receive',
      config: {
        auth: false,  // CSP 報告不需要認證
      },
    },
  ],
};

// api/csp-report/controllers/csp-report.js
module.exports = {
  async receive(ctx) {
    const report = ctx.request.body;
    strapi.log.warn('CSP Violation:', report);

    // 可以儲存到資料庫或發送到監控系統
    ctx.body = { received: true };
  },
};

常見問題與解決方案

Q1: 為什麼只有 Admin 空白,API 正常?

A: Admin Panel 是 SPA (Single Page Application),需要載入大量 JavaScript 檔案。API 端點只是 JSON 回應,不需要載入任何 JS,所以不受 CSP 影響。

Q2: 加了 'unsafe-inline' 還是不行?

可能原因:

  1. 引號問題

    // ❌ 錯誤:缺少引號
    'script-src': ['self', 'unsafe-inline']
    
    // ✅ 正確:必須加引號
    'script-src': ["'self'", "'unsafe-inline'"]
    
  2. 設定沒有生效

    # 確認 Pod 已使用新的設定
    kubectl delete pod -l app=strapi -n default
    
    # 檢查 CSP Header
    curl -I https://cms.example.com/admin | grep -i content-security
    
  3. Nginx/Ingress 覆蓋了設定

    # 檢查 Ingress annotations
    kubectl get ingress strapi-ingress -o yaml | grep -A 10 annotations
    

Q3: 可以完全關閉 CSP 嗎?

可以,但不推薦:

module.exports = [
  'strapi::logger',
  'strapi::errors',
  {
    name: 'strapi::security',
    config: {
      contentSecurityPolicy: false,  // 完全關閉 CSP
    },
  },
  // ...
];

風險:

  • 容易遭受 XSS 攻擊
  • 不符合安全最佳實踐
  • 可能違反資安稽核要求

Q4: 如何為不同環境設定不同的 CSP?

const isDevelopment = process.env.NODE_ENV === 'development';
const isProduction = process.env.NODE_ENV === 'production';

const cspDirectives = {
  development: {
    'script-src': ["'self'", "'unsafe-inline'", "'unsafe-eval'", 'cdn.jsdelivr.net'],
    'connect-src': ["'self'", 'https:', 'http:', 'ws:', 'wss:'],
  },
  production: {
    'script-src': ["'self'", "'unsafe-inline'", 'cdn.jsdelivr.net'],
    'connect-src': ["'self'", 'https:'],
  },
};

module.exports = [
  {
    name: 'strapi::security',
    config: {
      contentSecurityPolicy: {
        useDefaults: true,
        directives: isProduction ? cspDirectives.production : cspDirectives.development,
      },
    },
  },
  // ...
];

Q5: 如何測試 CSP 設定而不阻擋?

使用 Content-Security-Policy-Report-Only Header:

module.exports = {
  name: 'strapi::security',
  config: {
    contentSecurityPolicy: {
      useDefaults: true,
      reportOnly: true,  // 只報告,不阻擋
      directives: {
        'script-src': ["'self'"],
        'report-uri': ['/csp-violation-report'],
      },
    },
  },
};

這樣可以在不影響功能的情況下,收集違規報告並調整設定。


總結與收穫

關鍵要點

問題診斷:

  • 空白頁不一定是程式錯誤:可能是安全策略阻擋
  • 瀏覽器 DevTools 是最佳夥伴:Console 和 Network 標籤能揭露真相
  • 環境差異要注意:開發和正式環境的預設設定可能完全不同

CSP 理解:

  • CSP 是重要的安全機制:防止 XSS 和程式碼注入攻擊
  • 'self' 必須加引號:CSP 語法規定
  • 'unsafe-inline' 並非絕對不安全:對管理後台來說是可接受的
  • 最小權限原則:只開放必要的來源

Strapi 配置:

  • config/middlewares.js 是關鍵:控制中介層行為
  • useDefaults: true 保留基本設定:只覆蓋需要調整的部分
  • 環境變數靈活控制:不同環境使用不同策略

檢查清單

部署前:

  • 確認 config/middlewares.js 設定正確
  • 本地測試 production 模式
  • 檢查所有外部資源來源
  • 確認環境變數設定

部署後:

  • 檢查 Pod 狀態和日誌
  • 測試 API 端點
  • 測試 Admin Panel
  • 檢查 CSP Header
  • 瀏覽器 Console 無錯誤
  • 功能測試通過

安全性:

  • 不使用 'unsafe-eval'
  • 明確指定可信來源
  • 設定 CSP 違規報告
  • 定期檢查安全更新
  • 考慮分離 Admin 和 API

延伸閱讀

這次的經驗讓我深刻體會到:

  1. 環境變數的影響比想像中大:一個簡單的 NODE_ENV=production 會觸發許多隱藏的行為變化
  2. 安全性與可用性的平衡:CSP 提供了重要的保護,但需要正確配置才不會影響功能
  3. 瀏覽器工具的重要性:DevTools 是前端開發者最好的朋友

希望這篇分享能幫助你少走一些彎路!🚀


參考資源

官方文件:

實用工具:

延伸閱讀: