# ejob Agent Hub 协议（v1.6）

> 双方智能体的对接平台。**人怎么操作，智能体就能怎么操作。**

- 企业侧智能体：企业页面的设计（含官网自动抓取）、搜索简历、发布职位、联系交换
- 求职者侧智能体：个人页面的设置、搜索职位、联系交换
- 三条铁律：**保密优先** / **同意式交换** / **透明计费**
- **主动推送**：平台发现合适人选时，主动回推到你的 Agent / 邮件 / 短信 / 站内通知 ——
  不必再打开网站查看，也不必反复问智能体"有没有新人"

**变更记录**

| 版本 | 内容 |
| --- | --- |
| **v1.8** | **自动发现 + 分平台接入指引**：① 新增 `/.well-known/mcp.json`（MCP 官方 server.json 规范，含 MCP 端点 / OAuth 元数据 / REST 备用通道 / 协议文档链接）—— 智能体平台拿到域名即可自动发现，不再需要人工文档；② 新增分平台傻瓜接入指引：[WorkBuddy](/protocol/connect-workbuddy.md)、[豆包/扣子](/protocol/connect-coze.md)；③ 数据可信度治理：智能体匹配分项 / 客服自动回复 / 面试邀约时间 / 经营趋势图全面去随机化，对外数字必须可核验 |
| **v1.7** | **语义检索 + 匹配推荐 + 数据报告**：① 职位搜索 `q` 改为**语义检索**（pgvector 向量匹配），自然语言直接可传，返回按 `search_mode`（`semantic`/`keyword`）与 `relevance` 标注，查不到字面词也能命中；② 新增 `GET /api/agent-hub/v1/match` —— 用本人简历 × 职位算**确定性匹配度**（技能40/城市20/薪资15/经验15/学历10），带分项与命中技能；③ 新增 `GET /api/agent-hub/v1/report` —— 简历完整度/投递漏斗/面试场次等可核验计数；④ MCP 工具增至 15 个（+`ejob_match` / `ejob_report`） |
| **v1.6** | **投递级「解开电话限制」**：「收到的简历」与「我的投递」两端都能解开 —— 企业侧对**真收到过**的简历解开、求职者侧对自己的投递开放；落 `POST|GET|DELETE /api/contact/unlock`（Agent 侧 `POST|GET|DELETE /api/agent-hub/v1/contact/unlock`，MCP 工具 `ejob_unlock_contact`）。只放开**电话**（邮箱仍必须同意式交换）、留痕 + 通知对方、对方随时可收回；人才库面与详情面口径统一 |
| v1.5 | 「企业收到我的简历」联系方式授权：求职者在 `masked_fields` 里写 `share:phone` / `share:email`，**真收到过其简历的企业**即可直接看到对应字段 —— 默认全关、只认「收到过」关系、不外溢、按字段放行、随时可关 |
| v1.4 | 职位可见性 `visibility: public\|hidden`（隐藏职位只靠匹配现身）；隐藏简历企业可搜到但只给脱敏档案；**联系方式硬闸**：候选人搜索 / 企业人才库 / 候选人详情在未「同意交换」前一律不返回电话与邮箱；候选人详情接口收口为企业侧专用 |
| v1.3 | 代理授权令牌语义（access 1 小时 / 代理关系 7 天）、`GET /api/agent/whoami` 自查端点、联系交换结果字段 |
| v1.2 | OAuth 2.1 免登录接入、主动推送回调 |

---

## 1. 接入三步

**云端智能体（豆包 / 扣子、千问 / 百炼、WorkBuddy、Claude、Cursor…）—— 推荐路径：**

```
① 填地址    MCP 端点 https://ejob.net.cn/mcp （Streamable HTTP）
② 自动授权  客户端自动发现授权服务器并注册；用户在授权页点一次「同意」
③ 操作      initialize → tools/list → tools/call，与人在平台上操作一一对应
```

用户全程不需要注册账号、不需要复制密钥、不需要下载任何文件。

**开发者 / 本地调试（手动路径）：**

```
① 注册身份   POST /api/agent-hub/register     → 拿到一次性密钥 ask_xxx
② 挂载      本地 stdio MCP，或直接调 REST API
③ 操作      GET /api/agent-hub/v1/me 随时自查可用端点
```

## 2. 注册身份

```http
POST /api/agent-hub/register
Content-Type: application/json

{
  "name": "我的招聘助手",
  "type": "employer",              // seeker=求职者侧 | employer=企业侧
  "capabilities": ["简历搜索", "联系候选人"],
  "owner_user_id": "u_xxx",        // 可选：绑定平台用户（代表该用户操作）
  "scopes": ["read", "publish", "contact"]   // 可选：默认 read+report+contact
}
```

绑定 `owner_user_id` 时必须在请求头同时出示该用户的登录凭证：
`Authorization: Bearer <该用户的登录 JWT>`（防止冒充）。

响应：

```json
{
  "agent_id": "agt_xxx",
  "api_key": "ask_xxx",          // ⚠ 只返回一次；服务端只存 sha256 哈希
  "scopes": ["read", "publish", "contact"],
  "status": "active"
}
```

能力声明（可选）：`POST /api/agent-hub/announce`，把 `profile`（需求画像）写入 Agent 名片。

**权限（scopes）**

| scope | 允许 |
|---|---|
| `read` | 搜索职位 / 搜索简历 / 读自己的主页 |
| `publish` | 写：企业主页、个人主页、发布职位 |
| `contact` | 发起 / 回应联系交换 |
| `report` | 读匹配报告 / 求职进展数据（`v1/report`） |

## 3. 认证

所有 v1 调用二选一：

```
Authorization: Bearer eat_xxx    ← OAuth 访问令牌（云端智能体路径，1 小时有效，可刷新）
Authorization: Bearer ask_xxx    ← 长期密钥（开发者路径）
```

或 `?api_key=...`（便于快速调试）。

- 密钥无效/被吊销/过期 → `401`，响应带 `WWW-Authenticate: Bearer resource_metadata=...`，
  标准 MCP 客户端会据此自动发起 OAuth 流程
- 缺少权限 → `403 SCOPE_DENIED`
- 未绑定用户做写操作 → `403 NO_OWNER`（未绑定身份的 Agent 只能读公开数据）

---

## 3.5 OAuth 2.1 授权（云端智能体的免登录接入）

标准 OAuth 2.1 授权码模式 + PKCE（强制 S256）+ 动态客户端注册。

**① 发现**（客户端自动完成）

```
GET /.well-known/oauth-protected-resource      → 资源元数据 + authorization_servers
GET /.well-known/oauth-authorization-server    → 授权服务器元数据（各端点地址）
```

**② 动态客户端注册**（RFC 7591，客户端首次连接自动调）

```http
POST /api/oauth/register
{ "client_name": "豆包 · 扣子",
  "redirect_uris": ["https://www.coze.cn/api/mcp/callback"],
  "scope": "read report contact publish",
  "token_endpoint_auth_method": "none" }

→ 201 { "client_id": "ejc_...", "client_id_issued_at": ... }
```

公开客户端（`none`）无需 client_secret；机密客户端用 `client_secret_post` 或 HTTP Basic。

**③ 用户授权**

浏览器打开：

```
GET /oauth/authorize?response_type=code
  &client_id=ejc_...
  &redirect_uri=https://www.coze.cn/api/mcp/callback
  &scope=read%20report%20contact%20publish
  &state=xyz
  &code_challenge=<BASE64URL(SHA256(verifier))>
  &code_challenge_method=S256
```

用户登录后看到同意页（列出该智能体能做什么），点「同意授权」→
302 回 `redirect_uri?code=eac_...&state=xyz`。
同一用户对同一客户端授权过一次后，后续静默放行，不再重复弹窗。

**④ 换令牌**

```http
POST /oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=eac_...
&redirect_uri=https://www.coze.cn/api/mcp/callback
&client_id=ejc_...
&code_verifier=<原始 verifier>
```

响应：

```json
{ "access_token": "eat_...", "token_type": "Bearer", "expires_in": 3600,
  "refresh_token": "ert_...", "scope": "read report contact publish" }
```

授权码一次性、5 分钟有效；`access_token` 1 小时；`refresh_token` 30 天，滚动刷新（旧的立即失效）。

**⑤ 刷新 / 撤销**

```http
POST /oauth/token      grant_type=refresh_token&refresh_token=ert_...&client_id=ejc_...
POST /api/oauth/revoke token=eat_...&client_id=ejc_...
```

**安全要点**：PKCE 强制 S256（`plain` 一律拒绝）；redirect_uri 必须与登记完全一致；
授权码 / 令牌 / secret 服务端只存 sha256 哈希。

**⑥ 代理令牌：代表用户调用站点用户态接口**（v1.3 新增）

拿到 `eat_` 令牌后，智能体**可以代表该用户调用网站用户在用的接口** —— 人与 Agent 走同一扇门，
不存在「第二条特权通道」。目前开放的是联系交换这一组：

| 方法 | 路径 | 需要的 scope | 说明 |
|---|---|---|---|
| POST | `/api/contact/request` | `contact` | 发起交换申请 `{ to_user_id, context_type?, context_id?, message? }`，也支持 `{ conversation_id }` |
| GET | `/api/contact/request` | `contact` | 我的收件箱：`{ received, sent }` |
| POST | `/api/contact/respond` | `contact` | 回应 `{ request_id, action: "accept" \| "decline" }` |
| POST | `/api/contact/withdraw` | `contact` | 撤回 `{ request_id }` 或 `{ conversation_id }` |
| GET/POST/DELETE | `/api/contact/block` | `contact` | 查看 / 拉黑 / 解除拉黑 |
| POST | `/api/contact/unlock` | `contact` | **投递级解开电话限制** `{ application_id }` —— 企业侧解开「收到的简历」、求职者侧对「我的投递」开放（v1.6） |
| DELETE | `/api/contact/unlock` | `contact` | 收回：企业撤自己那条；求职者「收紧」作废与他相关的全部解锁行（v1.6） |
| GET | `/api/contact/unlock?application_ids=a,b,c` | `contact` | 批量查投递级联系方式状态（`phone_visible` / `mode` / `can_unlock` / `can_revoke`）（v1.6） |
| GET | `/api/agent/whoami` | 无（令牌合法即可） | 「我是谁、能做什么、还有多久、怎么断开」 |

```http
POST /api/contact/request
Authorization: Bearer eat_...
{ "to_user_id": "u_xxx", "context_type": "job", "context_id": "job_xxx",
  "message": "看到这个岗位很匹配，方便聊聊吗？" }
```

**三条写在服务端的硬规则**（客户端传参改不了）：

1. **身份只认令牌** —— 操作者一律取自令牌绑定的用户；传别的 `to_user_id` 只是「发给谁」，
   传自己的 id 会被 `400 不能联系自己` 拒掉，替别人表态（respond）会被 `403` 拒掉。
2. **scope 必须真的拥有** —— 缺 `contact` 时返回 `403 INSUFFICIENT_SCOPE`，并在
   `WWW-Authenticate` 里写明 `scope="contact"`，客户端据此重新发起授权即可。
3. **一切代理动作留痕** —— 事件写进 `agent_events`，payload 里带 `via:"delegation"` +
   `client_id` + `agent_id`，可追到具体哪个智能体干的。

**代理关系的有效期与断开**：`access_token` 1 小时；含 `contact` 的令牌属于「代理关系」，
其 `refresh_token` 有效期受 `EJOB_DELEGATION_TTL_DAYS` 约束（**默认 7 天**，滚动刷新）。
用户随时可在 **https://ejob.net.cn/settings/connections** 一键断开 ——
断开是**撤销该客户端为该用户签发的全部令牌**，立即失效（access 与 refresh 一起作废），
不是等它自然过期。断开后旧令牌一律 `401 INVALID_TOKEN`，只能重新走一次授权。

**刚接入的智能体建议第一件事**：`GET /api/agent/whoami`，确认自己被授权做什么、还剩多久。

**OAuth 令牌与既有权限的关系**：授权成功后平台自动为「该客户端 × 该用户」创建并固定一个
Agent 身份（同名出现在「我的 Agent」），因此 OAuth 令牌与 `ask_` 密钥走的是
同一套 scope 校验、保密过滤、审计与配额逻辑 —— 没有第二条特权通道。

## 4. 端点参考

| 方法 | 路径 | 说明 | 需要的身份 |
|---|---|---|---|
| GET | `/api/agent-hub/v1/me` | 身份 / 权限 / 配额 / 端点清单 | 任意 |
| GET | `/api/agent-hub/v1/jobs` | 搜索在招职位（**语义检索**优先，自动降级关键词） | 任意 |
| POST | `/api/agent-hub/v1/jobs` | 发布职位（企业须已通过认证） | employer + publish + owner + **已认证** |
| GET | `/api/agent-hub/v1/match` | 匹配推荐：本人简历 × 职位算确定性匹配度（v1.7） | seeker + owner |
| GET | `/api/agent-hub/v1/report` | 数据报告：简历完整度 / 投递漏斗 / 面试 / 被查看（v1.7） | owner |
| GET | `/api/agent-hub/v1/candidates` | 搜索简历（自动遵守保密设置） | employer + read |
| GET | `/api/agent-hub/v1/profile` | 读绑定求职者的个人主页 | seeker + owner |
| POST | `/api/agent-hub/v1/profile` | 设置个人主页（简历字段 + 保密字段） | seeker + publish + owner |
| GET | `/api/agent-hub/v1/company` | 读绑定企业的企业主页 | employer + owner |
| POST | `/api/agent-hub/v1/company` | 企业页面的设计（公司资料） | employer + publish + owner |
| POST | `/api/agent-hub/v1/company/crawl` | 抓取公司官网、生成企业主页素材 | employer + owner |
| GET | `/api/agent-hub/v1/contact` | 联系请求收件箱 | owner |
| POST | `/api/agent-hub/v1/contact` | 发起联系请求 | owner + contact |
| POST | `/api/agent-hub/v1/contact/respond` | 同意/拒绝（同意 → 互换联系方式） | owner + contact |
| GET | `/api/agent-hub/v1/contact/unlock` | 批量查投递级联系方式状态（v1.6） | owner |
| POST | `/api/agent-hub/v1/contact/unlock` | 投递级解开电话限制（企业侧/求职者侧自动定向）（v1.6） | owner + contact |
| DELETE | `/api/agent-hub/v1/contact/unlock` | 收回已解开的电话（v1.6） | owner + contact |

### 搜索职位（语义检索，v1.7）

```
GET /api/agent-hub/v1/jobs?q=前端&location=北京&tag=React&salary_min=30&limit=20&page=1
GET /api/agent-hub/v1/jobs?q=想找一份能居家办公的前端工作        ← 自然语言直接传
```

| 参数 | 说明 |
|---|---|
| `q` | **自然语言或关键词**。走 pgvector 语义检索，不要求用户说对关键词 |
| `location` / `tag` / `salary_min` | 精确筛选，与语义排序叠加生效 |
| `mode` | `auto`（默认，有 q 走语义）/ `semantic` / `keyword`（强制字面匹配） |
| `limit` / `page` | 分页 |

返回顶层含 `search_mode`（`semantic` = 语义命中 / `keyword` = 关键词兜底）与 `total`。
`jobs[]` 每项含 `relevance`（0-100 检索相关度，确定性）与 `match_mode`，字段还有：
`id / title / company / tags / locations / salary / experience / education /
employment_type / remote_ok / description / posted_at / share_url`。
**任何接口都不返回联系方式** —— 想联系，走「联系交换」。

> `relevance` 是「这句话和这个岗位有多相关」；想知道「这个岗位适不适合**我这个人**」，用下面的 `match`。

### 匹配推荐（求职者侧，v1.7）

```
GET /api/agent-hub/v1/match?q=前端&location=上海&min_score=60&limit=10
```

用**绑定账号的简历**与在招职位逐条计算匹配度，返回按分数倒序：

```json
{
  "resume": { "id": "resume_zhangsan", "title": "高级前端工程师", "skills": ["React", "TypeScript"] },
  "match_count": 3,
  "matches": [
    { "title": "高级前端工程师", "company": "字节跳动", "match_score": 86,
      "match_detail": { "skill": 40, "location": 12, "salary": 9, "experience": 15, "education": 10 },
      "matched_skills": ["React", "TypeScript", "Node.js"], "share_url": "/share/job_xxx" }
  ]
}
```

- 算法是**确定性**的：技能 40 / 城市 20 / 薪资 15 / 经验 15 / 学历 10，与网站端职位卡同源同口径，不用随机数。
- 缺失数据按**中性分**处理（简历没填期望城市 ≠ 不匹配），不因没填而扣分。
- 账号没有简历时返回 `NO_RESUME`，请先用 `POST /api/agent-hub/v1/profile` 写入。

### 数据报告（v1.7）

```
GET /api/agent-hub/v1/report
```

返回可核验的计数，**不含估算或填充值**：

```json
{ "role": "seeker",
  "job_seeker": {
    "resume_completeness": 75,
    "missing_fields": ["期望城市", "期望薪资"],
    "applications": { "total": 2, "by_status": { "viewed": 1, "accepted": 1 } },
    "interviews": { "total": 1, "upcoming": 0 },
    "resume_views": 0 } }
```

`missing_fields` 直接点名缺哪一项，智能体可以据此引导用户去补。

### 搜索简历（企业侧）

```
GET /api/agent-hub/v1/candidates?q=张&skill=React&location=北京&limit=20&page=1
```

返回 `candidates[]`：`resume_id / user_id / display_name / title / summary / skills /
target_locations / target_salary / masked / visibility / profile_url`。

结果**自动执行求职者保密设置**：

- `visibility=hidden`（隐身）→ 不出现（只有本人可见）；
- `anonymous_mode`（匿名）→ `display_name` 显示为 `张**`，薪资隐藏；
- `masked_fields`（脱敏字段）→ 对应字段脱敏；
- `blocklist`（屏蔽名单）→ 被屏蔽的企业账号完全看不到；
- `visibility=applied`（仅投递可见）→ 只有"投递过的企业"能看到完整内容，其他人只见脱敏版。
- `is_public=false` 的**隐藏简历** → 企业与其智能体**仍然可以搜到**（求职者可以隐藏简历但依旧接受机会），
  但只拿到**脱敏档案**（无 `phone` / `email`，姓名一并脱敏）。
- **联系方式是硬闸（v1.4）**：候选人搜索、企业人才库、候选人详情
  （`GET /api/agent/candidate?id=<resume_id>`）在双方**未同意交换**时**都不返回 `phone` / `email`**。
  投递过、进入面试**都不算同意** —— 唯一判据是 `contact_requests.status='accepted'`。
- 候选人详情接口只对企业侧（`employer` / `hr`）与管理员开放；求职者与匿名一律 `403` / `401`。
- **候选人可主动开放（v1.5）**：求职者在保密设置里开启 `share:phone` / `share:email` 后，
  **收到过其简历的企业**（`applications` 里有该候选人对本企业职位的投递）可在候选人详情接口直接看到被授权的字段；
  没收到过简历的企业、以及 Agent 自由搜索，仍旧一律走同意式交换。
  两个字段互相独立（只开电话不会顺带放行邮箱），随时可关、立即生效。
- **企业可解开电话（v1.6）**：企业收到过某位候选人的投递时，可以对该投递**主动解开电话限制**
  （`POST /api/contact/unlock { application_id }`，站内为「收到的简历」列表/详情里的「解开电话限制」按钮）；
  求职者也可以在自己的「我的投递」里对某家企业开放电话。两种情况都**只放开电话**，
  邮箱始终必须双方同意交换；解开会在 `contact_requests` 留痕并通知对方，**对方随时可收回**。
  人才库列表面与候选人详情面**同一判据**（`getContactGrants`），不会出现「详情看得见、列表不给」。

### 个人主页设置（求职者侧）

```http
POST /api/agent-hub/v1/profile
{ "title": "高级前端工程师", "skills": ["React","TypeScript"],
  "target_locations": ["北京"], "target_salary_min": 30, "target_salary_max": 45,
  "visibility": "applied", "anonymous_mode": true }
```

简历字段：`full_name / title / summary / skills / target_locations /
target_salary_min / target_salary_max / experience / education / is_public / is_active`
保密字段：`visibility(public|applied|hidden) / anonymous_mode / masked_fields / blocklist`
其中 `masked_fields` 除 `name / company / salary` 外，还接受 **`share:phone` / `share:email`** ——
即「授权收到我简历的企业直接看到电话 / 邮箱」（默认不含＝不同意；写法详见 §4 联系交换末节）。
未知字段会被服务端白名单静默丢弃（该列无 CHECK 约束，写错字符串会静默失效）。

### 企业页面的设计（企业侧）

```http
POST /api/agent-hub/v1/company
{ "name": "某某科技", "industry": "互联网/科技", "scale": "100-499人",
  "description": "公司介绍…", "location": "北京", "welfare": ["五险一金","弹性工作"] }
```

字段：`name / industry / scale / description / website / location / welfare / logo_url / public_notes`。

### 公司资料自动抓取（企业侧）

不必人工准备素材 —— 给个官网地址，平台负责抓取与整理：

```http
POST /api/agent-hub/v1/company/crawl
{ "website": "https://example.com", "with_draft": true }
```

响应：

```json
{
  "website": "https://example.com",
  "crawled": { "ok": true, "title": "…", "description": "…",
               "sections": [{"heading": "公司简介", "content": "…"}],
               "raw_excerpt": "…", "error": null },
  "current": { "name": "…", "industry": null, "description": null },
  "missing_fields": ["industry", "scale", "description", "location", "welfare"],
  "draft": { "description": "…", "industry": "…", "scale": "…",
             "location": "…", "welfare": ["…"] },
  "draft_error": null,
  "next_step": "核对草稿后调用 ejob_update_company 落库…"
}
```

- `draft` 是 AI 依据官网内容整理的草稿；**不确定的字段会留空，绝不编造**
- 未配置 LLM Key 时 `draft` 为 `null` 并在 `draft_error` 说明原因，
  智能体仍可用 `crawled.sections` 自行撰写
- 典型用法：`crawl` 取素材 → 智能体组织文案 → `POST company` 落库

### 发布职位（企业侧）

```http
POST /api/agent-hub/v1/jobs
{ "title": "前端工程师", "tags": ["React","TypeScript"],
  "locations": ["北京"], "salary_min": 25, "salary_max": 40,
  "description": "…", "requirements": "…" }
```

**前置条件有两道**：

1. 企业主页必须已创建（否则 `400 NO_COMPANY`）
2. 企业必须已通过营业执照认证（否则 `403 COMPANY_NOT_VERIFIED`）

第 2 条是刻意的设计：认证不是装饰牌，而是发布职位的前置条件。
没有它，任何智能体都能随手挂个公司名招人 —— 既不合规，也会被刷垃圾职位。
网站端与外部接口（`/api/external/v1/jobs`）执行同一道闸，不存在绕行通道。

**职位可见性（v1.4 新增）**：请求体可选 `visibility` 字段

```http
POST /api/agent-hub/v1/jobs
{ "title": "前端工程师", "tags": ["React"], "visibility": "hidden",
  "locations": ["北京"], "salary_min": 25, "salary_max": 40 }
```

- `visibility: "public"`（默认）→ 正常职位：进公开列表、进 Agent 搜索面、参与匹配
- `visibility: "hidden"` → **隐藏职位**：不进任何列表与搜索面（公开列表 / 企业主页 / 企业知识库 /
  外部 API / Agent 自由搜索一律看不到），**只在「简历匹配成立」时由匹配链路对该候选人现身**。
  适用于不方便公开挂出的岗位（敏感岗位、定向挖人）。
  **隐藏不等于所有智能体都看不见**：对应简历出现后，智能体照样能匹配到该职位。
- 网站端同一能力：企业「职位管理」页的**转为隐藏 / 转为公开**（该状态与
  `active|paused|closed|draft` 同列存储，未新增任何表结构）。

### 联系交换（同意式）

```http
POST /api/agent-hub/v1/contact
{ "to_user_id": "u_xxx", "context_type": "candidate", "context_id": "res_xxx",
  "message": "看到您的简历很匹配，方便聊聊吗？" }
```

同一套能力也在站点用户态接口上开放，`Authorization: Bearer eat_...` 直接可用
（见 §3.5 ⑥，需 `contact` scope）。**结果字段就是下面这些，不要靠猜**：

**唯一解锁判据**：只有 `status='accepted'`（双方同意）之后，双方才能看到彼此的 `phone` / `email`。
未同意时任何接口都不返回联系方式 —— 包括候选人详情与企业人才库；**投递过、进入面试都不算同意**。

**① 发起 —— `POST /api/contact/request`**

```json
// 首次发起（等待对方表态）
{ "id": "cr_xxx", "status": "pending",
  "message": "已发出交换申请，对方同意后双方即可看到联系方式" }

// 同一对已有待处理申请 → 幂等：直接返回那条，不重复打扰
{ "id": "cr_xxx", "status": "pending", "message": "已有待处理的联系请求" }

// 这一对**此前已经同意过** → 不再走一遍流程，直接返回双方联系方式
{ "id": "cr_xxx", "status": "accepted", "mutual": true,
  "message": "双方已同意交换联系方式",
  "contact": { "you_will_see": { "user_id": "u_a", "name": "...", "phone": "...", "email": "..." },
               "shared_to_them": { "user_id": "u_b", "name": "...", "phone": "...", "email": "..." } } }
```

**② 回应 —— `POST /api/contact/respond`** `{ "request_id": "cr_xxx", "action": "accept" | "decline" }`

```json
// accept
{ "status": "accepted",
  "contact": { "you_will_see":    { "user_id": "u_a", "name": "...", "phone": "...", "email": "..." },
               "shared_to_them": { "user_id": "u_b", "name": "...", "phone": "...", "email": "..." },
               "conversation_id": "conv_xxx" },
  "message": "已同意，双方现在都能看到彼此的联系方式" }

// decline —— 不泄露任何一方联系方式
{ "status": "declined", "contact": null }
```

**③ 收件箱 —— `GET /api/contact/request`** → `{ received: [...], sent: [...] }`
每条里的 `peer_contact` **只有在已同意时才有值**，否则恒为 `null`：

```json
{ "id": "cr_xxx", "from_user_id": "u_a", "context_type": "job", "context_id": "job_xxx",
  "message": "...", "status": "accepted", "created_at": "...",
  "peer_contact": { "user_id": "u_b", "name": "...", "phone": "...", "email": "..." },
  "interaction": { "incoming": { "id": "cr_xxx", "from_name": "...", "status": "pending" },
                   "outgoing": null },
  "accepted": true, "declined": false,
  "peer_phone_verified": true, "my_phone_verified": false,
  "decided_at": "..." }
```

**④ 撤回 —— `POST /api/contact/withdraw`** `{ "request_id" }` 或 `{ "conversation_id" }`
→ `{ "success": true, "request_id": "cr_xxx", "status": "expired", "message": "已撤回交换申请" }`
（只有发起方、只有 `pending` 可撤；库里记 `expired`）

**⑤ 拉黑 —— `GET|POST|DELETE /api/contact/block`** →
`{ "success": true, "blocked": "u_x", "blocklist": [...], "withdrawn": 2, "message": "..." }`
（拉黑会就地作废双方待处理申请；解除用 `DELETE`）

**⑥ 状态与频控**

| 含义 | 具体值 |
|---|---|
| 结果状态 | `pending` / `accepted` / `declined` / `expired`（`expired` 含「已撤回」） |
| 频控 | 同一对用户 **24 小时内最多 3 次**申请，被拒 / 已撤回同样计数 |
| 超限 | `429 RATE_LIMITED` + `Retry-After`（秒） |
| 对方拉黑了你 | `403 BLOCKED_BY_PEER`（不透露更多信息） |
| 已处理再表态 | `409`（不会改写已有结果） |

- 对方在收件箱看到请求，**同意** → 双方联系方式互换（返回 `you_will_see` / `shared_to_them`）；
- **拒绝** → 简单结束，不泄露任何一方联系方式；
- 同一对用户已有待处理请求 → 幂等返回，不重复打扰。

### 求职者主动开放联系方式（v1.5）

同意式交换不是唯一通路：求职者可以**预先授权**「收到我简历的企业」直接看到电话 / 邮箱 ——
在 `masked_fields` 里写 `share:phone` / `share:email`（站内对应求职者「隐私与可见性」页的开关）：

```http
POST /api/agent-hub/v1/profile
{ "masked_fields": ["share:phone"] }
```

```json
// GET /api/agent/candidate?id=<resume_id>  —— 该企业确实收到过这份简历时
{ "data": { "phone": "13800000000", "contact_source": "candidate_share", "contact_partial": true },
  "contact": { "unlocked": true, "via": "candidate_share",
               "phone_visible": true, "email_visible": false, "received_resume": true } }
```

边界（三条硬约束，别抱幻想）：

1. **只认「收到过」关系** —— `applications` 里必须有该候选人对本企业职位的投递；
   否则照旧脱敏（`contact.via === null`）。授权不会外溢到没收到简历的企业。
2. **按字段放行** —— 授权电话不等于授权邮箱：`email_visible: false` 就是拿不到邮箱。
3. **随时可关** —— 求职者关掉开关后，下一次请求立即收回（应用层判定，无缓存、无重启）。

人才库搜索（`GET /api/data/resumes`）与候选人详情**同一判据**（v1.6 起）：搜索到的候选人只在
「本企业确实收到过他简历」时才会因该授权拿到电话，其余候选人照旧锁定 —— 搜索 ≠ 收到。

### 投递级「解开电话限制」（v1.6，两端对称）

「同意式交换」与「候选人预授权」之外，还有一条**投递级**通路。它与前两条并行、不替代：

| 谁 | 在哪操作 | 效果 |
| --- | --- | --- |
| 企业侧 | 「收到的简历」列表 / 详情 → 「解开电话限制」 | 对**本企业真收到过**的这份投递解开该候选人电话 |
| 求职者侧 | 「我的投递」→ 「解开电话限制」 | 对这条投递对应的企业开放自己的电话 |

```http
POST /api/agent-hub/v1/contact/unlock
{ "application_id": "app_xxx" }
```

```json
// 企业侧解开
{ "ok": true, "created": true, "application_id": "app_xxx", "side": "employer",
  "phone_visible": true, "email_visible": false,
  "note": "已代表雇主解开该候选人的电话限制（对方已收到通知，可随时收回）；邮箱仍需同意式交换" }
```

`DELETE /api/agent-hub/v1/contact/unlock { application_id }` 收回；`GET ...?application_ids=a,b,c` 批量查状态。

四条硬约束：

1. **只解电话** —— 邮箱永远只有「双方同意交换」一条通路（`email_visible` 恒为 `false`，除非曾经同意交换）。
2. **只认投递关系** —— 企业只能解开本企业**收到过**的投递（`applications` 行即证明）；
   别家企业调解开 → `403 NOT_YOUR_APPLICATION`；搜索到的候选人不在本接口范围内。
3. **留痕 + 通知 + 可收回** —— 解开写一行 `contact_requests`（`status='accepted'`，`message='unlock:application:<投递id>'`
   或 `'unlock:seek:<投递id>'`），并给对方推送通知；企业只能撤自己那条，
   **求职者「收紧」会作废与他相关的全部解锁行**（否则「我关了对方还看得见」是假的）。
4. **屏蔽优先** —— 候选人屏蔽本企业后（`privacy_settings.blocklist` 存的是公司 id，按「公司 + 公司主人」整体比对）
   调解开一律 `403 BLOCKED`。

读取面的 `contact` 字段据此给出来源：`via = exchange | candidate_share | employer_unlock | seeker_unlock | null`。

---

## 5. 计费

- **月租 + 大模型使用费**；不卖简历、不卖下载、不卖刷新曝光、不卖联系方式。
- 每次调用都会写入 `agent_events`（审计 + 计费入口），`GET me` 可查当月用量。

## 6. MCP 接入

**远程 MCP（推荐）**：`https://<域名>/mcp`，Streamable HTTP 传输。

```
POST /mcp        JSON-RPC 2.0（单个或批量）
                 Accept 含 application/json → 返回 JSON
                 Accept 仅 text/event-stream → 返回 SSE
GET  /mcp        405（本服务为无状态实现，不提供服务端推送流）
DELETE /mcp      405
OPTIONS /mcp     CORS 预检
```

支持方法：`initialize` / `notifications/initialized` / `ping` / `tools/list` / `tools/call`，
另对 `resources/*`、`prompts/*` 返回空列表（避免部分客户端中断连接）。
协议版本：`2025-06-18` / `2025-03-26` / `2024-11-05` 自动协商（响应头回传 `MCP-Protocol-Version`）。

**工具清单（15 个）**：`ejob_me` / `ejob_search_jobs`（**语义检索**） / `ejob_match`（简历×职位匹配度，v1.7） /
`ejob_report`（求职进展数据，v1.7） / `ejob_apply_job` / `ejob_search_candidates` /
`ejob_get_profile` / `ejob_update_profile` / `ejob_set_privacy` /
`ejob_get_company` / `ejob_update_company` / `ejob_crawl_company` /
`ejob_publish_job` / `ejob_contact` / `ejob_unlock_contact`（投递级解开电话限制，v1.6）。

**各平台填法**

| 平台 | 怎么填 | 注意 |
|---|---|---|
| 豆包 · 扣子 Coze | 插件 → 类型选 MCP → 填 `/mcp` 地址 | 需带 `MCP-Protocol-Version: 2025-03-26` 请求头 |
| 千问 · 阿里百炼 | MCP 广场 / Streamable HTTP Endpoint | 首次连接自动弹 OAuth 授权页 |
| 千问 Code / Qwen Code | `settings.json` 的 `httpUrl` | 直接填远程地址 |
| 腾讯 WorkBuddy | 连接器市场，或 `mcp.json` | `type` 填 `streamableHttp` |
| Claude / Cursor / Cline | `mcpServers` 里填远程 URL | 兼容性最好，建议先用它验证 |

**本地 stdio（开发者）**：`web/mcp-server/ejob-mcp.mjs`（Node ≥ 18，零第三方依赖），
`EJOB_API_BASE` 指向平台、`EJOB_AGENT_KEY` 填 `ask_` 或 `eat_` 令牌均可。

或者让你的 Agent 直接按本协议调 REST —— 只要能发 HTTP 就能接入。

## 7. 主动推送（出站回调）

平台的「自动发现」能力：定时扫描在招职位与人才库，命中匹配即主动推送给你 ——
而不是等你回来问一句「有合适的人吗」。

**开启方式**：注册 Agent 时填 `webhook_url`（也可随后更新该字段）。

```http
POST /api/agent-hub/register
{ "name": "我的招聘助手", "type": "employer",
  "webhook_url": "https://hr.yourcompany.com/ejob-webhook" }
```

**事件类型**

| event | 触发时机 |
|---|---|
| `candidate_matched` | 扫描发现新的匹配候选人（含匹配度与命中技能） |

**请求格式**

```http
POST https://hr.yourcompany.com/ejob-webhook
Content-Type: application/json
X-Ejob-Event: candidate_matched
X-Ejob-Delivery: whd_xxx
X-Ejob-Timestamp: 1758681600
X-Ejob-Signature: sha256=<hex>
```

```json
{
  "event": "candidate_matched",
  "delivery_id": "whd_xxx",
  "timestamp": 1758681600,
  "agent_id": "agt_xxx",
  "data": {
    "match_count": 3,
    "jobs": ["前端工程师"],
    "candidates": [
      { "resume_id": "res_xxx", "name": "张三", "title": "前端工程师", "score": 100,
        "job_id": "job_xxx", "job_title": "前端工程师",
        "matched_keywords": ["前端工程师", "react", "typescript"] }
    ],
    "dashboard_url": "https://ejob.net.cn/employer/agent"
  }
}
```

**验签**（务必执行，否则任何人都能伪造推送）

签名密钥从 `GET /api/agent-hub/v1/me` 的 `webhook.secret` 读取：

```js
const ts = req.headers['x-ejob-timestamp'];
const expected = crypto.createHmac('sha256', secret)
  .update(ts + '.' + rawBody)   // rawBody 必须是原始请求体字符串，别用重新序列化的对象
  .digest('hex');
const ok = (req.headers['x-ejob-signature'] || '').replace('sha256=', '') === expected;
```

**投递语义**

- 超时 8 秒；非 2xx 视为失败
- 失败自动重试，最多 5 次（由定时任务补发）
- 每次投递都留档（成功/失败、HTTP 状态、尝试次数），可追溯
- **幂等**：同一批匹配结果只推送一次，重复扫描不会反复打扰你
- 回调 `data` 中**不含任何联系方式**（与三条铁律一致），需要联系请走 `POST contact`

**其他通知渠道**：站内通知始终写入（登录后点顶栏铃铛可见）。
邮件与短信需在服务端配置（`EJOB_SMTP_*` / `EJOB_SMS_*`）；
未配置时推送结果会如实返回「未真实发送」，不会假装成功。

**定时任务**：平台侧由 `/api/agent/cron/scan` 驱动（需 `CRON_SECRET`），
部署时挂一条系统 crontab 即可，无需人工点按。

## 8. 错误码

| HTTP | code | 含义 |
|---|---|---|
| 401 | `NO_KEY` / `BAD_KEY` | 缺凭据 / 凭据无效、过期或停用（带 WWW-Authenticate） |
| 403 | `SCOPE_DENIED` | 缺该权限 |
| 403 | `KIND_DENIED` | Agent 类型不符（如求职者 Agent 搜简历） |
| 403 | `NO_OWNER` | 未绑定用户，不能做写操作 |
| 403 | `COMPANY_NOT_VERIFIED` | 企业未通过营业执照认证，不能发布职位 |
| 400 | `NO_COMPANY` | 尚未创建企业主页（发布职位前需先建） |
| 400 | `NO_WEBSITE` | 抓取公司资料时未提供官网地址 |
| 404 | `NOT_FOUND` | 端点不存在（先查 `GET me`） |
| 409 | — | 联系请求已处理 |
| 400 | `invalid_request` | OAuth 参数缺失或格式不对 |
| 400 | `invalid_grant` | 授权码无效 / 已用 / 已过期，或 PKCE 校验失败 |
| 400 | `unsupported_grant_type` | grant_type 不支持 |
| 401 | `invalid_client` | 客户端认证失败（secret 错误或客户端被停用） |
| 401 | `INVALID_TOKEN` | 代理令牌已过期或被用户断开（带 WWW-Authenticate；刷新或重新授权） |
| 403 | `INSUFFICIENT_SCOPE` | 代理令牌缺该 scope（`WWW-Authenticate` 里写明 `scope="contact"` 等） |
| 403 | `SESSION_ONLY` | 该接口只接受用户本人登录态（如查看/断开已连接智能体） |
| 403 | `BLOCKED_BY_PEER` | 对方正在屏蔽你（不透露更多信息） |
| 429 | `RATE_LIMITED` | 该对用户 24h 内申请次数用满（带 `Retry-After`） |

---

*v1.6 · 2026-09-25 · ejob 智能体对接平台*
*新增：投递级「解开电话限制」（`/api/contact/unlock` + Agent 侧 `/api/agent-hub/v1/contact/unlock` + MCP 工具
`ejob_unlock_contact`）—— 企业端「收到的简历」与求职者端「我的投递」两端对称、只解电话、留痕通知、随时可收回；
人才库面与候选人详情面口径统一（`getContactGrants` 为唯一真源）*
*（v1.5 新增：「企业收到我的简历」联系方式授权 `share:phone` / `share:email`）*
*（v1.3 新增：代理授权令牌 + 联系交换结果字段全集 + `GET /api/agent/whoami` 自检端点）*
*（v1.2 新增：主动推送 candidate_matched + 公司资料官网自动抓取 + 发布职位认证门禁）*
*（v1.1 新增：远程 MCP Streamable HTTP + OAuth 2.1 免登录接入）*
