> 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/architecture/backend.md).

# 后端

后端就是一个 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["slow-down（仅当设置 SECURITY_DELAY_AFTER 时）"]
    SD --> J["express.json，500kb 限制"]
    J --> NS["所有 /api 响应均设置 Cache-Control: no-store"]
    NS --> CA["cacheable(maxAge)——仅用于选择启用的路由"]
    CA --> RF["requireReferer——全局适用于 /api/*"]
    RF --> G["每个路由的参数守卫"]
    G --> H["api/ 中的处理器"]
```

每一步都值得了解：

1. **`pino-http`** 挂载于 `/api` 仅当 `LOG_HTTP=true`. 它位于速率限制器之前，因此 429 也会被记录。处理器本身不会写“收到请求”日志行。参见 [日志](/developer/zh/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. **`cacheable(maxAge)`** ，用于路由已选择启用缓存的地方——见 [边缘缓存](#edge-caching).
7. **`requireReferer`**，全局作用于 `/api/*`.
8. **每个路由的参数守卫** 来自 `common/guards.js`.
9. **处理器。**

安全相关环境变量记录在 [安全选项](/developer/zh/configuration/security-options.md) 和 [环境变量](/developer/zh/reference/environment-variables.md).

## 守卫

访问控制和参数校验都位于中间件中，绝不在处理器内部。所有这些都在 `common/guards.js` ，并在 `backend-server.js`中挂载，因此处理器可以假定其输入已经是格式正确的。

| 守卫                         | 检查                                                                      | 失败时                                                    |
| -------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------ |
| `requireReferer`           | 这个 `Referer` 主机名为 `localhost` 或位于 `ALLOWED_DOMAINS`。无法解析的 referer 计为拒绝。 | `403` `{ error: '访问被拒绝' }`，或 `'你在做什么？'` 在未发送 referer 时 |
| `requireValidIP()`         | `?ip=` 存在且是有效的 IPv4/IPv6 地址                                             | `400` `未提供 IP 地址` / `无效的 IP 地址`                        |
| `requireValidDomain()`     | `?domain=` 是语法上有效的域名。 **就地转为小写** 以便边缘缓存看到唯一的规范键                         | `400` `未提供域名` / `无效的域名`                                |
| `requireValidPrefix()`     | `?prefix=` 是格式良好的 CIDR。量化策略仍由前端负责                                       | `400` `未提供前缀` / `无效的前缀`                                |
| `requireValidASN()`        | `?asn=` 是数字，可选地 `AS`带有 - 前缀。 **将其重写为数字形式**                              | `400` `未提供 ASN` / `无效的 ASN`                            |
| `requireValidProviderId()` | `?id=` 是已知的 service-status 提供方 slug                                     | `400` `未提供 provider id` / `无效的 provider id`            |
| `requireValidReportId()`   | 这个 `:id` **路由参数** 匹配 22 个 base64url 字符（16 个随机字节）                        | `400` `无效的报告 id`                                       |

新的参数形态意味着要在 `common/guards.js` 在 `backend-server.js` 中挂载——而不是处理器中的内联检查。（`cf-radar` 早于 `requireValidASN()` ，并且至今仍在内联验证其 ASN。）守卫由 `tests/guards.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`构建，后者负责抓取、非 2xx 检查、规范化调用，以及统一的日志记录与 500 捕获。参见 [IP 数据源](/developer/zh/architecture/ip-data-sources.md).

## 上游调用

来自……的每个对外 HTTP 调用都通过 `api/` 经过 `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" %}
**私有 API 标头透传。** 代理私有 IPCheck.ing API 的处理器—— `ipcheck-ing`, `invisibility-test`, `update-user-achievement`, `get-user-info`, `dns-leak-test` ——会将调用方的标头转发给上游，因为该 API 需要调用方上下文（`Accept-Language`、认证令牌）。这是一个有意的例外。第三方上游只会拿到它们明确需要的内容。
{% endhint %}

## 边缘缓存

每个 `/api/*` 响应一开始是 `Cache-Control: no-store`。变化缓慢的公共路由通过 `cacheable(maxAgeSeconds)` 中定义的中间件工厂选择启用缓存 `backend-server.js`:

```js
const cacheable = (maxAgeSeconds) => (req, res, next) => {
    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 路径上自行应用它。否则处理器绝不会触碰 `Cache-Control`.

当前使用的 TTL 分层：

| TTL  | 路由                                                                                                                                                       | 原因                                            |
| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- |
| 5 分钟 | `/api/service-status`, `/api/service-status/detail`                                                                                                      | 与后台轮询器自身的 5 分钟刷新一致                            |
| 1 小时 | `/api/configs`                                                                                                                                           | 从环境变量派生的功能标志；在重新部署时变化                         |
| 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`                                                                                                                                 | 探测国家覆盖变化缓慢，而且选择器会 fail open                   |
| 30 天 | `/api/cfradar`, `/api/asn-history`, `/api/asn-connectivity`, `/api/macchecker`                                                                           | 注册表和历史数据：IEEE OUI 分配、ASN 元数据与互联、仅追加的 BGP 路由历史 |
| 1 年  | `/api/map`                                                                                                                                               | 一个量化坐标的静态地图瓦片                                 |

TTL 写成乘法表达式（`24 * 60 * 60`），而不是原始秒数。

其他一切都保持 `no-store`: `/api/ipchecking`, `/api/dnsresolver`, `/api/dnsleaktest/session/:token`, `/api/invisibility`, `/api/getuserinfo`, `PUT /api/updateuserachievement`，并且两者都 `/api/report` 端点。

{% hint style="warning" %}
绝不要把需要身份验证或按用户划分的端点包在 `cacheable()`中。它的缓存归拥有认证上下文的上游负责。共享报表则保持 `no-store` ，还有第二个原因：边缘缓存可能在其 KV 过期后仍提供一份报表，而私有诊断数据不应进入公共缓存。
{% endhint %}

## 路由清单一览

完整契约在 [API 端点](/developer/zh/reference/api-endpoints.md)；这里是连线视图。

| 路由                                    | 守卫                         | 缓存       | 处理程序                             |
| ------------------------------------- | -------------------------- | -------- | -------------------------------- |
| `GET /api/ipinfo`                     | `requireValidIP()`         | 1 天      | `api/ipinfo-io.js`               |
| `GET /api/ipapicom`                   | `requireValidIP()`         | 1 天      | `api/ipapi-com.js`               |
| `GET /api/ipapiis`                    | `requireValidIP()`         | 1 天      | `api/ipapi-is.js`                |
| `GET /api/ip2location`                | `requireValidIP()`         | 1 天      | `api/ip2location-io.js`          |
| `GET /api/ipsb`                       | `requireValidIP()`         | 1 天      | `api/ip-sb.js`                   |
| `GET /api/maxmind`                    | `requireValidIP()`         | 1 天      | `api/maxmind.js`                 |
| `GET /api/ipchecking`                 | `requireValidIP()`         | 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`                | —                          | no-store | `api/dns-resolver.js`            |
| `GET /api/cfradar`                    | 内联 ASN 检查                  | 30 天     | `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`           |
| `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 的自动更新器，以及 service-status 轮询器
5. `app.listen(BACKEND_PORT)`

每一步都不是致命的。失败只会让依赖的 API 退化——MaxMind 返回 `503`，CAIDA 支持的视图返回空图或回退到 RIPEstat——但绝不会阻止启动。数据集详情见 [IP 数据源](/developer/zh/architecture/ip-data-sources.md).

## 日志记录与错误监控

后端文件始终使用来自 `common/logger.js`的共享 pino 记录器；裸 `console.*` 在这里不会使用。Pino 先放上下文： `logger.error({ err, ip }, '简短消息')`。启动日志以表情符号开头（🚀 listening、📦 ready、📥 downloading、🛡️ security、🐢 throttling、🗓️ schedule、⚠️ recoverable、❌ failure）；每请求日志保持纯文本。

Sentry 由环境变量控制，对处理器不可见。 `sentry-instrument.js` 通过 `node --import` **之前** 加载到 Express 中，以便 ESM 加载器钩子可以自动插桩路由追踪； `backend-server.js` 挂载 `setupExpressErrorHandler` 在所有路由之后。没有 `SENTRY_DSN_BACKEND`, `@sentry/node` 就绝不会加载。处理器从不导入 Sentry：未捕获的抛出和 5xx 跟踪会自动上报，而已捕获的失败会留在记录器中，其中一个钩子会将 warn 及以上级别镜像到 Sentry Logs。周期性作业会把它们的 tick 包裹在 `common/sentry-cron.js` 中用于检查签到，而 API 密钥查询参数会被 `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/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/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.
