> 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/development/adding-a-new-tool.md).

# 添加新工具

主页上的“高级工具”——MAC Lookup、Whois、DNS Resolver、Censorship Check 以及其余工具——都遵循同一种接线模式。本页将逐步演示如何从头到尾添加一个新的工具。

## 这个示例

我们将添加一个假设的 **证书检查**：输入一个域名，即可从上游 API 返回其 TLS 证书详情。

| 项目        | 值                                                  |
| --------- | -------------------------------------------------- |
| slug      | `certcheck`                                        |
| 组件        | `frontend/components/advanced-tools/CertCheck.vue` |
| API 处理程序  | `api/cert-check.js`                                |
| 路由        | `GET /api/certcheck?domain=…`                      |
| i18n 命名空间 | `certcheck.*`                                      |

它直接以 **MAC Lookup** 工具（`macchecker` → `MacChecker.vue` → `api/mac-checker.js`）为蓝本，这是仓库中最小的完整示例。请将这三个文件与本页一起打开。

{% hint style="info" %}
**并非每个工具都需要后端。** Browser Info 和 Security Checklist 完全在浏览器中运行。如果你的工具也是这样，跳过步骤 1–3，直接进入组件部分。
{% endhint %}

## 命名

* **slug** —— 小写，不含分隔符： `macchecker`, `dnsresolver`, `censorshipcheck`。它是以下 URL： `/tools/<slug>` 以及抽屉查询参数 `?tool=<slug>`，因此一旦发布就几乎是永久性的。
* **组件** —— PascalCase `.vue` 位于 `frontend/components/advanced-tools/`.
* **处理程序文件** —— kebab-case `.js` 位于 `api/`.
* **路由路径** —— 与旧工具的 slug 一致（`/api/macchecker`），较新的工具则用 kebab-case（`/api/ooni-blocking`, `/api/service-status`）。两种都可以；选一种并始终一致地使用。

***

{% stepper %}
{% step %}

### 编写 API 处理程序

每条路由一个文件，位于 `api/`，文件开头用头部注释说明路由及其用途。使用单个默认导出， `fetchUpstream` 用于上游请求，失败时使用共享日志记录器。

{% code title="api/cert-check.js" %}

```js
// /api/certcheck —— 某个域名的 TLS 证书详情，取自
// 上游证书 API。为前端 CertCheck 工具提供支持。

import { fetchUpstream } from '../common/fetch-with-timeout.js';
import logger from '../common/logger.js';

const CERT_API_URL = 'https://example-cert-api.test/v1/cert';

export default async (req, res) => {
    if (req.method !== 'GET') {
        return res.status(405).json({ error: '不允许的方法' });
    }

    // 由 requireValidDomain 保证存在性、格式和小写化。
    const domain = req.query.domain;

    const token = process.env.CERT_API_KEY || '';
    if (!token) {
        return res.status(500).json({ error: '缺少 API 密钥' });
    }

    try {
        const upstream = await fetchUpstream(`${CERT_API_URL}?host=${domain}&key=${token}`);
        if (!upstream.ok) {
            throw new Error(`证书 API 返回了状态 ${upstream.status}`);
        }
        res.json(await upstream.json());
    } catch (error) {
        logger.error({ err: error, domain }, 'cert-check 处理程序失败');
        res.status(500).json({ error: error.message });
    }
};
```

{% endcode %}

有四点是不容妥协的：

* **`fetchUpstream`，绝不要直接使用裸露的 `fetch()`.** 它内置 8 秒超时和项目的 `User-Agent`.
* **共享日志记录器，绝不要使用 `console.*`.** 先传上下文对象，再传简短消息。
* **错误结构要简洁。** `400` 对于无效输入， `500` 使用 `{ error: … }` 作为失败返回。
* **处理程序中不要检查 referer 或参数。** 中间件已经做过了——见下一步。

这个 `req.method !== 'GET'` 拦截是防御性的：下面的路由已经限制了请求方法。之所以保留，是因为冒烟测试会直接断言它。
{% endstep %}

{% step %}

### 复用一个守卫——或者新增一个

参数校验位于 `common/guards.js`，而不是放在处理程序内部。我们的工具使用 `?domain=`，而这个守卫已经存在：

```js
requireValidDomain()   // 拒绝缺失/格式错误的域名，并就地转为小写
```

就地转为小写很重要：边缘缓存以 URL 为键，因此大小写混合的查询不能变成单独的缓存条目。

当前可用的完整列表：

| 守卫                         | 校验                                  |
| -------------------------- | ----------------------------------- |
| `requireReferer`           | 全局应用于 `/api/*` —— 允许的域名 + localhost |
| `requireValidIP()`         | `?ip=`                              |
| `requireValidDomain()`     | `?domain=` （同时转为小写）                 |
| `requireValidPrefix()`     | `?prefix=` （CIDR）                   |
| `requireValidASN()`        | `?asn=` （去除 `AS`，并重写为数字）            |
| `requireValidProviderId()` | `?id=` 对照 service-status 的 slug 列表  |
| `requireValidReportId()`   | `/api/report/:id` 路由参数              |

{% hint style="warning" %}
**新的参数形式意味着需要新的守卫。** 将其作为导出的工厂函数添加到 `common/guards.js`，并在 `backend-server.js`中接入，并在 `tests/guards.test.js`中覆盖它。不要在处理程序里手写检查——这正是守卫层要防止的偏差。
{% endhint %}
{% endstep %}

{% step %}

### 在 `backend-server.js`

应用中的每条路由都在这个文件里声明。把处理程序导入到顶部，与其他处理程序并列：

{% code title="backend-server.js" %}

```js
import certCheckHandler from './api/cert-check.js';
```

{% endcode %}

然后声明路由。中间件顺序是：先守卫，再缓存，最后处理程序：

{% code title="backend-server.js" %}

```js
app.get('/api/certcheck', requireValidDomain(), cacheable(ONE_DAY_CACHE), certCheckHandler);
```

{% endcode %}

根据上游数据实际变化的速度来选择 TTL。文件里已经定义了这些常量—— `FIVE_MIN_CACHE`, `ONE_HOUR_CACHE`, `ONE_DAY_CACHE`, `SEVEN_DAYS_CACHE`, `THIRTY_DAYS_CACHE`, `ONE_YEAR_CACHE` —— 全部都写成乘法表达式，而不是直接写秒数。

如果数据是按用户区分、需要认证，或者每次请求都会变化， **省略 `cacheable()` 完全**。在 `/api/*` 下的所有内容默认使用 `Cache-Control: no-store`，因此省略它是更安全的选择。

新的环境变量？把它添加到 `.env.example` 中，并添加注释，然后在 [环境变量](/developer/zh/reference/environment-variables.md) 和 [可选 API 密钥](/developer/zh/configuration/optional-api-keys.md).
{% endstep %}

{% step %}

### 构建组件

创建 `frontend/components/advanced-tools/CertCheck.vue`。它是一个普通的 `<script setup>` 组件——抽屉页和独立页面都会原样挂载它，因此它不需要外层框架、标题栏，也不需要知道路由。

复制标准模式，而不要发明新的模式。从 `MacChecker.vue`:

{% code title="frontend/components/advanced-tools/CertCheck.vue" %}

```vue
<template>
    <div class="cert-check-section my-4 space-y-4">
        <p class="text-sm text-muted-foreground leading-relaxed">{{ t('certcheck.Note') }}</p>

        <div class="space-y-2">
            <Label for="queryDomain">{{ t('certcheck.Note2') }}</Label>
            <div class="flex items-center gap-2">
                <Input type="text" id="queryDomain" name="queryDomain"
                    autocomplete="off" autocorrect="off" autocapitalize="off"
                    spellcheck="false" data-1p-ignore data-lpignore="true"
                    :disabled="status === 'running'"
                    :placeholder="t('certcheck.Placeholder')"
                    v-model="queryDomain" @keyup.enter="onSubmit" />
                <Button variant="action" :disabled="status === 'running' || !queryDomain"
                    @click="onSubmit" class="cursor-pointer">
                    <Spinner v-if="status === 'running'" />
                    <Search v-else class="size-4 shrink-0" />
                </Button>
            </div>
            <p v-if="errorMsg" class="text-sm text-destructive">{{ errorMsg }}</p>
        </div>

        <!-- 结果区域 -->
        <Card v-if="result.subject">…</Card>
    </div>
</template>

<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { trackEvent } from '@/utils/analytics';
import { Search } from '@lucide/vue';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Spinner } from '@/components/ui/spinner';
import { Label } from '@/components/ui/label';

const { t } = useI18n();

const queryDomain = ref('');
const status = ref('idle');
const result = ref({});
const errorMsg = ref('');

const onSubmit = () => {
    trackEvent('Section', 'StartClick', 'CertCheck');
    errorMsg.value = '';
    result.value = {};
    if (queryDomain.value) fetchCert(queryDomain.value);
};

const fetchCert = async (domain) => {
    status.value = 'running';
    try {
        const response = await fetch(`/api/certcheck?domain=${domain}`);
        if (!response.ok) throw new Error('网络响应不正常');
        result.value = await response.json();
    } catch (error) {
        console.error('获取证书时出错：', error);
        errorMsg.value = t('certcheck.fetchError');
    } finally {
        status.value = 'idle';
    }
};
</script>
```

{% endcode %}

值得特别指出的几点：

* **触发按钮** — `variant="action"` 使用 `<Spinner v-if />` 以及一个 `:disabled` 保护条件。这是项目范围内“执行此操作”的统一入口。
* **防止自动填充的输入框** —— 每一个自由输入的 `输入框` 都包含上面展示的全部六个属性，而且占位符会避开“address”这个词（及其译法），因为 iOS QuickType 即使在 `autocomplete="off"`.
* **`console.*` 的情况下也会根据这个词本身起作用。** 前端会使用它；只有后端文件受限。
* **不要手写状态→颜色切换。** 如果你的工具要把业务状态映射为颜色，请通过 `composables/use-status-tone.js`.
* **每个字符串都是一次 `t()` 调用。** 没有任何用户可见文本是硬编码的。

更多标准模式——状态卡片、标记、表格与列表的选择、对话框标题、动效——都收录在 [前端](/developer/zh/architecture/frontend.md).
{% endstep %}

{% step %}

### 注册工具

`frontend/data/tools.js` 是唯一事实来源。那里的一条条目会为你生成首页卡片、底部抽屉、独立 `/tools/<slug>` 页面，以及导航菜单项—— **你无需触碰路由器**.

{% code title="frontend/data/tools.js" %}

```js
export const ADVANCED_TOOLS = [
  // …现有条目……
  { slug: 'certcheck', emoji: '🔐', titleKey: 'certcheck.Title', noteKey: 'advancedtools.CertCheck', component: () => import('@/components/advanced-tools/CertCheck.vue') },
];
```

{% endcode %}

条目结构：

<table><thead><tr><th width="230">字段</th><th>含义</th></tr></thead><tbody><tr><td><code>slug</code></td><td>稳定的 URL 标识符—— <code>/tools/&#x3C;slug></code> 以及抽屉的 <code>?tool=&#x3C;slug></code> 查询参数。</td></tr><tr><td><code>emoji</code></td><td>卡片图标和抽屉标题图标。</td></tr><tr><td><code>titleKey</code></td><td>工具标题的 i18n 键。</td></tr><tr><td><code>noteKey</code></td><td>单行卡片描述的 i18n 键。</td></tr><tr><td><code>component</code></td><td>对 <code>.vue</code> 文件的懒加载导入——抽屉和独立页面都会使用它。</td></tr><tr><td><code>requiresOriginalSite</code></td><td>可选。 <code>true</code> 会在自托管实例上隐藏该工具。若是公开工具则省略。</td></tr></tbody></table>

数组中的顺序就是首页卡片的顺序。

{% hint style="info" %}
**`requiresOriginalSite: true`** 用于依赖私有 IPCheck.ing API 和已登录账户的工具——它们会在 fork 和自托管实例上隐藏，因为这些实例无法访问该后端。参见 [与 IPCheck.ing 绑定的功能](/developer/zh/configuration/features-tied-to-ipcheck-ing.md)。大多数新工具都应省略此字段。
{% endhint %}
{% endstep %}

{% step %}

### 添加文案——覆盖全部四种语言环境

你引用的每个字符串都需要在 **`en.json`, `zh.json`, `fr.json`、以及 `ru.json`** 位于 `frontend/locales/`中有对应条目。四个文件要在同一次修改中完成。这不是后续任务。

每种语言环境要编辑两个地方——你这个工具自己的命名空间：

{% code title="frontend/locales/en.json" %}

```json
"certcheck": {
  "Title": "证书检查",
  "Note": "查询任意域名的 TLS 证书：颁发者、有效期和主题备用名称。",
  "Note2": "输入一个域名开始检查：",
  "Placeholder": "example.com",
  "fetchError": "无法获取证书详情"
}
```

{% endcode %}

……以及共享的 `advancedtools` 命名空间：

{% code title="frontend/locales/en.json" %}

```json
"advancedtools": {
  "CertCheck": "查看域名的 TLS 证书"
}
```

{% endcode %}

命名空间通常与 slug 一致（`macchecker`, `dnsresolver`, `censorshipcheck`）。请保持四个文件中的键名完全一致——只改值。

关于加载、子包和回退链的详细说明： [i18n](/developer/zh/development/i18n.md).
{% endstep %}

{% step %}

### 添加一条更新日志条目

将你的条目追加到 **最后** 中的版本块 `frontend/data/changelog.json` ——该文件按最旧在前的顺序运行，而界面会反向渲染它。

{% code title="frontend/data/changelog.json" %}

```json
{
  "type": "add",
  "change": {
    "en": "New Certificate Check tool: inspect any domain's TLS certificate",
    "zh": "新增证书检查工具：查看任意域名的 TLS 证书",
    "fr": "Nouvel outil de vérification de certificat : inspectez le certificat TLS de n'importe quel domaine",
    "ru": "Новый инструмент проверки сертификатов: просмотр TLS-сертификата любого домена"
  }
}
```

{% endcode %}

`type` 必须是以下之一： `add`, `improve`，或 `fix`。这四个区域设置字符串都必须存在且不能为空—— `tests/changelog.test.js` 否则会导致构建失败。
{% endstep %}

{% step %}

### 编写测试

两种测试，都放在 `tests/`.

**处理程序冒烟测试** 放在 `tests/api-handlers.test.js`，在一个 `describe` 块中，与其他测试放在一起。只断言会返回 **之前** 第一次 `fetchUpstream` 调用之前——整个测试套件不会接触真实上游：

{% code title="tests/api-handlers.test.js" %}

```js
import certCheckHandler from '../api/cert-check.js';

// -- cert-check handler ---------------------------------------------------
// Domain presence/shape is enforced by requireValidDomain middleware
// (tests/guards.test.js); the handler's own pre-fetch branches are the
// method gate and the missing-API-key early return.

describe('cert-check handler', () => {
    it('rejects non-GET with 405 before hitting the upstream', async () => {
        const res = createResponse();
        await certCheckHandler(createRequest({ method: 'POST', query: { domain: 'example.com' } }), res);
        assert.equal(res.statusCode, 405);
        assert.equal(res.body.error, 'Method Not Allowed');
    });

    it('returns 500 when the API key is missing', async () => {
        delete process.env.CERT_API_KEY;
        const res = createResponse();
        await certCheckHandler(createRequest({ query: { domain: 'example.com' } }), res);
        assert.equal(res.statusCode, 500);
        assert.equal(res.body.error, 'API key missing');
    });
});
```

{% endcode %}

该文件已经提供了 `createRequest()` / `createResponse()` 存根。如果你的处理程序读取了新的环境变量，请将其名称添加到顶部的 `ENV_KEYS` 数组中，这样备份/恢复钩子就会覆盖它。

**单元测试** 覆盖你的工具引入的任何纯逻辑——校验器、解析器、转换、带有可模拟输入的可组合项。为每个部分单独提供一个 `tests/<subject>.test.js`。如果你添加了守卫，请扩展 `tests/guards.test.js`.

哪些内容 *不* 需要测试：Vue 渲染、真实网络请求、浏览器 API。参见 [测试](/developer/zh/development/testing.md).
{% endstep %}

{% step %}

### 运行自检

```bash
pnpm check
```

测试加上生产构建。在你打开 PR 之前，它必须全部通过。

然后你自己在以下位置查看该工具 `pnpm dev` ——在首页卡片网格、抽屉中以及 `/tools/certcheck` ——因为这些都无法通过机器验证。请在 PR 描述中说明。
{% endstep %}
{% endstepper %}

***

## 检查清单

| 步骤             | 文件                                                 |
| -------------- | -------------------------------------------------- |
| 处理程序           | `api/cert-check.js`                                |
| 守卫（仅在有新的参数形状时） | `common/guards.js`                                 |
| 路由             | `backend-server.js`                                |
| 组件             | `frontend/components/advanced-tools/CertCheck.vue` |
| 注册表项           | `frontend/data/tools.js`                           |
| 复制 × 4         | `frontend/locales/{en,zh,fr,ru}.json`              |
| 更新日志 × 4       | `frontend/data/changelog.json`                     |
| 冒烟测试           | `tests/api-handlers.test.js`                       |
| 单元测试           | `tests/*.test.js`                                  |
| 新的环境变量         | `.env.example`                                     |

## 进一步扩展

你的工具还可以接入两个可选系统，两者都属于事件驱动——组件在 `utils/app-events.js` 事件总线上发出事件，并且绝不直接调用这些系统：

* **成就。** 发出一个领域事件，并将其映射为位于 `frontend/data/achievement-rules.js`中的成就 slug，然后将该成就添加到 `frontend/data/achievements.js`.
* **可分享的诊断报告。** 一个“my network”测试会发出 `<domain>:finished` 及其结构化结果；位于 `frontend/utils/report-builders.js` 的构建器会将其规范化，并且 `common/report-schema.js` 对字段进行白名单限制。构建器采用软失败，因此缺失的 schema 条目只会表现为静默缺失的字段，而不是报错——请在同一次修改中同时添加构建器和 schema 条目。

这两者的更详细说明见于 [前端](/developer/zh/architecture/frontend.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/development/adding-a-new-tool.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.
