> For the complete documentation index, see [llms.txt](https://docs.ipcheck.ing/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.ipcheck.ing/developer/zh-tw/architecture/backend.md).

# 後端

Express 5 API：處理器、保護中介軟體、上游擷取，以及邊緣快取。

後端是一個 Express 5 應用程式。 `backend-server.js` 在儲存庫根目錄的是唯一負責連接路由的檔案；每個路由都只委派給位於下方的恰好一個處理器模組 `api/`。共用的後端程式碼位於 `common/`.

經驗法則： **`backend-server.js` 決定誰可以呼叫路由，以及答案可以快取多久；處理器只負責抓取並整理資料。**

## 中介軟體鏈，依序

```mermaid
flowchart TD
    R["傳入請求"] --> P["pino-http 作用於 /api（僅當 LOG_HTTP=true 時）"]
    P --> RL["速率限制器（僅當 SECURITY_RATE_LIMIT 已設定時）"]
    RL --> SD["慢速限制（僅當 SECURITY_DELAY_AFTER 已設定時）"]
    SD --> J["express.json，500kb 限制"]
    J --> NS["每個 /api 回應皆為 Cache-Control: no-store"]
    NS --> RF["requireReferer — 全域套用於 /api/*"]
    RF --> G["每條路由的參數守衛"]
    G --> TZ["withTimeZone() — 僅限地理定位路由"]
    TZ --> CA["cacheable(maxAge) — 僅適用於選擇啟用的路由"]
    CA --> H["api/ 中的處理器"]
```

每一步都值得了解：

1. **`pino-http`** 掛載於 `/api` 僅當 `LOG_HTTP=true`。它位於速率限制器之前，因此 429 也會被記錄。處理器本身不會自行寫入「收到請求」行。見 [記錄](/developer/zh-tw/configuration/logging.md).
2. **速率限制器** (`express-rate-limit`）：一個 20 分鐘的視窗，具有 `SECURITY_RATE_LIMIT` 作為上限；當該值為 `0` 或未設定時停用。在剛進入受限狀態的那一刻，它只寫入一條 `logger.warn({ ip }, 'IP 已受速率限制')` 行——不是每個被阻擋的請求各寫一行——並且在以下情況下，會選擇性地附加到磁碟上的帳本： `SECURITY_BLACKLIST_LOG_FILE_PATH` 已設定。用戶端 IP 會依序從下列來源讀取： `cf-connecting-ip`，接著是第一個 `x-forwarded-for` 項目，接著是 `cf-connecting-ipv6`，接著是 `req.ip` (`trust proxy` 為 `1`).
3. **慢速限制** (`express-slow-down`）：一個 1 小時的視窗，在 `命中次數 × 400 毫秒` 的延遲之後 `SECURITY_DELAY_AFTER` 請求；未設定時停用。兩個限制器都會略過 `/monitoring`，因為它有自己的限制器——一個被節流的遙測通道會悄悄讓錯誤回報失效。
4. **`express.json({ limit: '500kb' })`** ——這個值是從 100kb 的預設值提高而來，因為共用的診斷報告合理情況下可達約 100KB。它必須保持高於 `REPORT_MAX_BYTES` 位於 `common/report-schema.js`，否則在處理器自己的大小檢查之前，報告上傳就會在這裡以原始 413 失敗。
5. **`no-store` 預設值** 在每個 `/api/*` 回應上。
6. **`requireReferer`**，全域套用於 `/api/*`。它是請求在完成全應用程式設定後首先遇到的東西，因此未經授權的呼叫者會在任何參數被解析之前就被擋下。
7. **每條路由的參數守衛** 自 `common/guards.js`.
8. **`withTimeZone()`** 於七條地理定位路由上——見 [回應增強](#response-enrichment).
9. **`cacheable(maxAge)`** ，適用於已選擇啟用的路由——見 [邊緣快取](#edge-caching)。它排在最後，因為它只會掛鉤 `res.json`；除非守衛已先通過，否則沒有任何請求會到達它，這正是被拒絕的請求永遠無法被快取的原因。
10. **處理器。**

步驟 7 到 9 是每條路由的中介軟體。它們在路由定義中內嵌宣告，且順序永遠是——守衛、再增強、再快取——而 [路由清單](#route-inventory-at-a-glance) 下方列出哪些路由包含哪些項目。

與安全性相關的環境變數記載於 [安全選項](/developer/zh-tw/configuration/security-options.md) 和 [環境變數](/developer/zh-tw/reference/environment-variables.md).

## 守衛

存取控制與參數驗證都放在中介軟體中，絕不在處理器內。這一切都在 `common/guards.js` ，並在 `backend-server.js`中掛載，因此處理器可以假設其輸入已經是格式正確的。

| 守衛                         | 檢查                                                                                                                                    | 失敗時                                                   |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- |
| `requireReferer`           | 這個 `Referer` 主機名稱是 `localhost` 或不在 `ALLOWED_DOMAINS`。無法解析的 referer 會被視為拒絕。                                                            | `403` `{ error: '存取遭拒' }`，或 `'你在做什麼？'` 當未送出 referer 時 |
| `requirePublicIP()`        | `?ip=` 是有效的 IPv4/IPv6 位址，且 **可公開路由的**。保留位址範圍——RFC 1918、loopback、CGNAT、link-local、documentation、multicast——都會在此被拒絕，因此不會向任何上游詢問它無法回應的位址 | `400` `未提供 IP 位址` / `無效的 IP 位址` / `不是公開 IP 位址`        |
| `requireValidDomain()`     | `?domain=` 在語法上是有效的網域。 **就地轉成小寫** ，讓邊緣快取看到單一的標準鍵                                                                                      | `400` `未提供網域` / `無效的網域`                               |
| `requireValidPrefix()`     | `?prefix=` 是格式正確的 CIDR。量化策略仍交由前端處理                                                                                                    | `400` `未提供前綴` / `無效的前綴`                               |
| `requireValidASN()`        | `?asn=` 是數字，且可選擇性地 `AS`帶有前綴。 **將其改寫為數字形式**                                                                                            | `400` `未提供 ASN` / `無效的 ASN`                           |
| `requireValidCountry()`    | `?country=` 正好是兩個字母。 **就地轉成大寫** ，讓邊緣快取對每個國家看到一個鍵                                                                                      | `400` `未提供國家` / `無效的國家`                               |
| `requireValidProviderId()` | `?id=` 是已知的服務狀態供應商 slug                                                                                                               | `400` `未提供提供者 ID` / `無效的提供者 ID`                       |
| `requireValidRecordType()` | `?type=` 是解析器所處理的 DNS 記錄類型之一（`common/dns-record-types.js`). **就地轉成大寫。** 沒有它，DoH 分支會把任何字串原封不動地轉送給第三方解析器                                | `400` `未提供記錄類型` / `無效的記錄類型`                           |
| `requireValidReportId()`   | 這個 `:id` **路由參數** 符合 22 個 base64url 字元（16 個隨機位元組）                                                                                     | `400` `無效的報告 ID`                                      |

新的參數形狀代表在 `common/guards.js` 中新增一個已接線的守衛 `backend-server.js` ——而不是在處理器中的內嵌檢查。守衛只是普通的中介軟體，因此也能在路由定義之外組合使用： `/api/cfradar` 根據下列項目分派： `?view=`，而其每個條目都在其 `RADAR_VIEWS` 登錄檔（`common/cf-radar.js`）宣告該檢視的守衛—— `requireValidASN()` 對於 `view=asn`, `requireValidCountry()` 對於 `view=country-traffic` ——而分派器會在碰觸上游之前先執行它們。守衛由 `tests/guards.test.js`.

## 回應增強

守衛讀取請求；只有一個中介軟體會寫入回應。 `withTimeZone()` (`common/ip-timezone.js`）掛載到七條地理定位路由上，並新增一個 `timezone` 欄位作為輸出：

```js
app.get('/api/ipsb', requirePublicIP(), withTimeZone(), cacheable(ONE_DAY_CACHE), ipsbHandler);
```

它掛鉤 `res.json` 完全如同 `cacheable()` 所做的，而且在相同的 `statusCode < 400` 條件下，因此錯誤主體永遠不會包含該欄位。該時區是根據 `latitude` / `longitude` 處理器本身回傳的內容來解析的——絕不是對同一個 IP 的第二次查詢，否則最終可能得到一個與旁邊城市互相矛盾的時區名稱。無法使用的座標會產生 `''` ，而不是猜測。

沒有任何處理器會計算或轉送時區，包括私有 API 的直通路徑，因此新的地理定位來源只要在其路由上加入這個中介軟體，就會繼承該欄位。詳情見 [IP 資料來源](/developer/zh-tw/architecture/ip-data-sources.md)。由下列測試覆蓋： `tests/ip-timezone.test.js`.

## 處理器形狀

在以下目錄中的每個檔案 `api/` 都有一個單一的預設輸出， `async (req, res) => …`，並讀取 `req.query` 或 `req.body`，呼叫上游，並且只寫入一個回應。每個檔案都以標題註解開頭，說明其路由與用途。

錯誤格式刻意保持簡短——前端不會原樣顯示這些字串：

```js
res.status(500).json({ error: error.message });  // 上游失敗
res.status(400).json({ error: '無效 …' });    // 輸入無效（通常是守衛）
```

有些處理器會保留一個防禦性的 `req.method !== 'GET'` 分支，回傳 `405` ，即使路由本身已經對方法做了把關，因為冒煙測試會直接對該分支進行斷言。

那五個 IP 地理定位來源處理器共用更精簡的形狀：它們是由 `makeGeoHandler({ name, buildUrl, normalize })` 工廠在 `common/geo-handler.js`所建立，它負責 fetch、非 2xx 檢查、normalize 呼叫，以及一致的記錄並回傳 500 的 catch。見 [IP 資料來源](/developer/zh-tw/architecture/ip-data-sources.md).

## 上游呼叫

從 `api/` 發出的每個對外 HTTP 呼叫都會經過 `fetchUpstream` 自 `common/fetch-with-timeout.js`。絕不用裸露的 `fetch()` 或 `https.get()` ——掛起的供應商必須逾時，而不是把連線卡住。

* **8 秒逾時** 為預設值（瀏覽器端對應的兄弟實作， `fetchWithTimeout`，預設為 5 秒）。兩者都接受一個 `timeoutMs` 覆寫值，並串接呼叫端提供的 `signal`。逾時會顯示為 `AbortError`.
* **專案的 User-Agent** 為 `MyIP/v<version>/<VITE_SITE_URL>`，並由啟動時註冊的 `common/upstream-ua.js`。一些上游 WAF 會嚴格封鎖 undici 的預設 `User-Agent: node`。分支版本會宣告自己的 `VITE_SITE_URL`.
* **呼叫端提供的 `User-Agent` 標頭永遠優先**，包括下面的 `{ ...req.headers }` 直通傳遞。

{% hint style="info" %}
**IPCheck.ing API 標頭直通。** 代理私有 IPCheck.ing API 的處理器—— `ipcheck-ing`, `invisibility-test`, `update-user-achievement`, `get-user-info`, `dns-leak-test`, `persona` ——會將呼叫端的標頭轉送到上游，因為該 API 需要呼叫端情境（`Accept-Language`、驗證權杖）。這是刻意的例外。第三方上游只會拿到它們明確需要的內容。 `persona` 是第一個丟棄描述 *這個* 跳轉而不是呼叫者（`host`, `content-length`, `content-type`, `connection`, `transfer-encoding`）：它會重新序列化 JSON 主體，因此呼叫端的 `Content-Length` 已不再描述實際送出的內容。
{% endhint %}

## 邊緣快取

每個 `/api/*` 回應一開始都先是 `Cache-Control: no-store`。慢速變動的公開路由會透過 `cacheable(maxAge)` 中定義的中介軟體工廠選擇啟用 `backend-server.js`:

```js
const cacheable = (maxAge) => (req, res, next) => {
    const maxAgeSeconds = typeof maxAge === 'function' ? maxAge(req) : maxAge;
    if (maxAgeSeconds) {
        res.locals.cacheControl = `public, max-age=${maxAgeSeconds}`;
        const originalJson = res.json.bind(res);
        res.json = function (body) {
            if (res.statusCode < 400) {
                res.setHeader('Cache-Control', res.locals.cacheControl);
            }
            return originalJson(body);
        };
    }
    next();
};
```

有三個重要後果。它掛鉤 `res.json`，因此標頭只會落在狀態碼低於 400 的回應上——CDN 永遠不會快取錯誤頁面。它會把預期值暫存於 `res.locals.cacheControl`，因此串流二進位資料的處理器（繞過 `res.json`）可以在自己的 2xx 路徑上自行套用。且 `maxAge` 接受一個 `(req) => seconds` 解析器，適用於 TTL 取決於請求的路由—— `/api/cfradar` 它會從檢視登錄檔讀取，而一個假值解析結果（未知的 `?view=`）會保留 `no-store` 預設值。除此之外，處理器永遠不會碰觸 `Cache-Control`.

目前使用中的 TTL 層級：

| TTL  | 路由                                                                                                                                                       | 原因                                                                               |
| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| 5 分鐘 | `/api/service-status`, `/api/service-status/detail`                                                                                                      | 與背景輪詢器自身的 5 分鐘刷新一致                                                               |
| 1 小時 | `/api/configs`, `/api/cfradar?view=outages`                                                                                                              | 從環境變數衍生出的功能旗標會在重新部署時變更；Radar 的故障資訊流則以近似每小時的節奏移動                                  |
| 1 天  | `/api/ipinfo`, `/api/ipapicom`, `/api/ipsb`, `/api/ipapiis`, `/api/ip2location`, `/api/maxmind`, `/api/whois`, `/api/github-stars`, `/api/ooni-blocking` | 地理定位與登錄資料在一天內幾乎不變；同時也能禮貌地保留免費上游配額                                                |
| 7 天  | `/api/globalping-probes`                                                                                                                                 | 探測國家覆蓋範圍變化緩慢，而選擇器則採取失敗開啟                                                         |
| 30 天 | `/api/cfradar` (`view=asn`, `view=country-traffic`), `/api/asn-history`, `/api/asn-connectivity`, `/api/macchecker`                                      | 登錄檔與歷史資料：IEEE OUI 指派、ASN 中繼資料與互連、僅追加的 BGP 路由歷史——以及 Radar 的 28 天活動彙總，其變動最多也只是每週尺度 |
| 1 年  | `/api/map`                                                                                                                                               | 一個量化座標的靜態地圖圖磚                                                                    |

TTL 會寫成乘法運算式（`24 * 60 * 60`），而不是直接寫秒數。

其他一切都維持 `no-store`: `/api/ipchecking`, `/api/dnsresolver`, `/api/dnsleaktest/session/:token`, `/api/invisibility`, `POST /api/persona/evaluate`, `/api/getuserinfo`, `PUT /api/updateuserachievement`，而且兩者都是 `/api/report` 端點。

{% hint style="warning" %}
絕不要把受驗證或每用戶的端點包在 `cacheable()`。其快取應屬於擁有驗證情境的上游。共用報告仍保持 `no-store` 還有第二個原因：邊緣快取可能在 KV 到期後仍提供一份報告，而且私有診斷資料不應放在公開快取中。
{% endhint %}

## 路由清單一覽

完整合約記載於 [API 端點](/developer/zh-tw/reference/api-endpoints.md)；這是接線視圖。

| 路由                                    | 守衛                                                            | 快取               | 處理器                              |
| ------------------------------------- | ------------------------------------------------------------- | ---------------- | -------------------------------- |
| `GET /api/ipinfo`                     | `requirePublicIP()`                                           | 1 天              | `api/ipinfo-io.js`               |
| `GET /api/ipapicom`                   | `requirePublicIP()`                                           | 1 天              | `api/ipapi-com.js`               |
| `GET /api/ipapiis`                    | `requirePublicIP()`                                           | 1 天              | `api/ipapi-is.js`                |
| `GET /api/ip2location`                | `requirePublicIP()`                                           | 1 天              | `api/ip2location-io.js`          |
| `GET /api/ipsb`                       | `requirePublicIP()`                                           | 1 天              | `api/ip-sb.js`                   |
| `GET /api/maxmind`                    | `requirePublicIP()`                                           | 1 天              | `api/maxmind.js`                 |
| `GET /api/ipchecking`                 | `requirePublicIP()`                                           | no-store         | `api/ipcheck-ing.js`             |
| `GET /api/whois`                      | —                                                             | 1 天              | `api/get-whois.js`               |
| `GET /api/macchecker`                 | —                                                             | 30 天             | `api/mac-checker.js`             |
| `GET /api/dnsresolver`                | `requireValidDomain('hostname')` + `requireValidRecordType()` | no-store         | `api/dns-resolver.js`            |
| `GET /api/cfradar`                    | 每個檢視，來自 `RADAR_VIEWS` (`common/cf-radar.js`)                  | 每個檢視：30 天 / 1 小時 | `api/cf-radar.js`                |
| `GET /api/asn-history`                | `requireValidPrefix()`                                        | 30 天             | `api/asn-history.js`             |
| `GET /api/asn-connectivity`           | `requireValidASN()`                                           | 30 天             | `api/asn-connectivity.js`        |
| `GET /api/ooni-blocking`              | `requireValidDomain()`                                        | 1 天              | `api/ooni-blocking.js`           |
| `GET /api/globalping-probes`          | —                                                             | 7 天              | `api/globalping-probes.js`       |
| `GET /api/service-status`             | —                                                             | 5 分鐘             | `api/service-status.js`          |
| `GET /api/service-status/detail`      | `requireValidProviderId()`                                    | 5 分鐘             | `api/service-status.js`          |
| `GET /api/map`                        | —                                                             | 1 年              | `api/google-map.js`              |
| `GET /api/github-stars`               | —                                                             | 1 天              | `api/github-stars.js`            |
| `GET /api/configs`                    | —                                                             | 1 小時             | `api/configs.js`                 |
| `GET /api/invisibility`               | —                                                             | no-store         | `api/invisibility-test.js`       |
| `GET /api/dnsleaktest/session/:token` | —                                                             | no-store         | `api/dns-leak-test.js`           |
| `POST /api/persona/evaluate`          | —                                                             | no-store         | `api/persona.js`                 |
| `GET /api/getuserinfo`                | —                                                             | no-store         | `api/get-user-info.js`           |
| `PUT /api/updateuserachievement`      | —                                                             | no-store         | `api/update-user-achievement.js` |
| `POST /api/report`                    | —                                                             | no-store         | `api/share-report.js`            |
| `GET /api/report/:id`                 | `requireValidReportId()`                                      | no-store         | `api/share-report.js`            |
| `POST /api/monitoring`                | 自己的速率限制器                                                      | no-store         | `api/sentry-tunnel.js`           |

`/api/monitoring` 已掛載 **唯一的** when `VITE_SENTRY_DSN_FRONTEND` 已設定。它使用 `express.raw({ type: () => true })` ——一個萬用函式，因為 Replay 封包是二進位且到達時沒有 `Content-Type` 任何這類標頭，而 `'*/*'` 字串比對器會略過。

## 啟動序列

`bootBackend()` 會準備好每個離線資料集 **在** 監聽器啟動之前，因此伺服器絕不會提供半下載完成的資料庫：

1. `bootstrapMaxMindIfMissing()` 接著 `reloadMaxMindDatabases('startup')`
2. `bootstrapCaidaIfMissing()`
3. `bootstrapServiceStatus()`
4. 啟動 MaxMind 檔案監看器、MaxMind 與 CAIDA 自動更新器，以及服務狀態輪詢器
5. `app.listen(BACKEND_PORT)`

每個步驟都不是致命錯誤。失敗只會讓相依的 API 降級——MaxMind 回應 `503`，CAIDA 支援的檢視則回傳空圖或退回 RIPEstat——但絕不會阻擋啟動。資料集詳情記載於 [IP 資料來源](/developer/zh-tw/architecture/ip-data-sources.md).

## 記錄與錯誤監控

後端檔案一律使用來自 `common/logger.js`；裸用的 `console.*` 不會在那裡使用。Pino 會先放脈絡： `logger.error({ err, ip }, 'short message')`。啟動行會以前綴 emoji 開頭（🚀 監聽中、📦 就緒、📥 下載中、🛡️ 安全、🐢 限速、🗓️ 排程、⚠️ 可恢復、❌ 失敗）；每個請求的行則維持普通格式。

Sentry 受環境變數控制，且對處理器不可見。 `sentry-instrument.js` 會透過 `node --import` **在** 載入 Express，以便 ESM 載入器掛鉤能自動為路由追蹤加上儀表； `backend-server.js` 附加 `setupExpressErrorHandler` 在所有路由之後。若沒有 `SENTRY_DSN_BACKEND`, `@sentry/node` 從未載入。處理程序從不匯入 Sentry：未捕捉的拋出和 5xx 追蹤會自動處理，而已捕捉的失敗則保留在 logger 上，並由一個 hook 將 warn 以上的訊息鏡像到 Sentry Logs。定期工作會把其 tick 包在 `common/sentry-cron.js` 用於檢查，且 API 金鑰查詢參數會被從 telemetry URL 中遮蔽，透過 `common/sentry-scrub.js`.

## 新增路由

1. 建立 `api/<name>.js` ，並附上一段標頭註解與單一預設匯出。
2. 使用 `fetchUpstream` 於任何對外呼叫。
3. 將其接入 `backend-server.js`，在正確的快取層級中，並加上它所需的防護。
4. 如果參數結構是新的，請先在 `common/guards.js` 第一個。
5. 新增煙霧測試到 `tests/api-handlers.test.js` — 方法門控、參數分支、缺少 API 金鑰時的提前回傳。絕不要打到真實的上游；只針對在第一個 `fetchUpstream`。參見 [測試](/developer/zh-tw/development/testing.md).


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.ipcheck.ing/developer/zh-tw/architecture/backend.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
