# 云机 Worker 对接 API 文档

> 版本：v2.0.2（对齐 `云机大脑-后端` / `云机大脑-前端` 当前实现）  
> 更新日期：2026-07-02  
> 适用对象：云手机脚本、自动化 Worker、协议层以外的执行端

---

## 1. 概述

云机 Worker 通过调度大脑后端领取任务，在本地调用抖音相关能力后 **上报（report）** 回服务端。

系统支持两种 **任务模式**（`task_mode` / 导入时 `job_mode`）：

| 模式 | `job_mode` | pop 下发 | report 上报（`api_status=success` 时） |
|------|------------|----------|----------------------------------------|
| 手机号筛查（默认） | `phone_screen` | 一个候选手机号 + `bucket` | **必须** `nickname_roi_base64`（± `nickname_roi_base64s[]`）；**不要求** `api_nickname_first` / `api_nickname_firsts` |
| 抖音号找回 | `douyin_recover` | 一个抖音号 `douyin_id` | **必须** `phone_prefix`（3 位）+ `nickname_roi_base64`（75×45 PNG base64） |

> **强制约定（v1.8）**
>
> - **`phone_screen`（手机号筛查）**：`api_status=success` 时 **必须** 上报符合规格的 **PNG base64** ROI；命中 **仅** 由 ROI 评分裁决。**不要求、不读取** `api_nickname_first` / `api_nickname_firsts`（若上报亦被忽略）。用户不存在（`error_1011` / `error_code=1011`）**不要求** ROI。
> - **`douyin_recover`（抖音号找回）**：规则不变，见 §6.4；本模式 **从未要求** 昵称首字字段。
> - 缺 ROI 或格式/尺寸错误时服务端返回 **HTTP 400**，`msg` 见 §6.8。

手机号筛查详见 **§5～§7**；抖音号找回详见 **§9.5**。

---

## 1.1 双 Worker 脚本架构（推荐）

**手机号筛查** 与 **抖音号找回** 在云机侧采用 **两套独立 Worker 脚本**，不合并为同一个通用脚本：

| Worker 脚本 | 领取接口 | 对接 `task_mode` | pop 领到 | report 上报 | 典型部署 |
|-------------|----------|------------------|----------|-------------|----------|
| **手机号筛查 Worker** | `GET /task/pop/phone` | `phone_screen` | `phone` + `bucket` | **必须** `nickname_roi_base64`（± `nickname_roi_base64s[]`）；**不要求** 昵称首字 | 手机号筛查专用云机池 |
| **抖音号找回 Worker** | `GET /task/pop/douyin` | `douyin_recover` | `douyin_id` + `uid` | **必须** `phone_prefix` + `nickname_roi_base64` | 抖音号找回专用云机池 |

**为何拆成两个脚本：**

- 本地自动化流程完全不同（查号 vs 按抖音号找回）
- report 字段与成功判定逻辑不同
- 独立发版、排错、扩缩容，互不影响

**部署约定：**

1. **同一云机实例同一时间只跑一种 Worker**，不要在同一台云机上混跑两个脚本。
2. **推荐云机池隔离**：筛查任务只部署「手机号筛查 Worker」；找回任务只部署「抖音号找回 Worker」。
3. **领取任务必须使用对应专用接口**（§5.1）：手机号池调用 `/task/pop/phone`，抖音号池调用 `/task/pop/douyin`；服务端**仅**在对应 `job_mode` 的 `running` 批次间轮询，**无需**每次上传任务配置 `job_id`。
4. pop 后仍应读取 `data.task_mode` 作兜底校验；若与当前脚本不符，说明云机池或接口配置错误。
5. `device-info` 心跳、`GET /worker/proxy`（若启用）、`worker_key` 鉴权、`device_id=names` 规则 **两种 Worker 完全相同**；差异仅在领取接口与 report Body。

```text
┌─────────────────────┐   GET /task/pop/phone      ┌──────────┐
│ 手机号筛查 Worker 池 │ ─────────────────────────► │          │
└─────────────────────┘                            │ 调度大脑  │
                                                   │          │
┌─────────────────────┐   GET /task/pop/douyin     │          │
│ 抖音号找回 Worker 池 │ ─────────────────────────► └──────────┘
└─────────────────────┘
```

---

**一号多抖音（仅 `phone_screen`）：** 同一手机号可能绑定多个抖音账号。推荐上报 `nickname_roi_base64s[]` 多张 ROI，服务端取 **max(roi_match_score)** 判定（详见 §7.4）。**无需**上报 `api_nickname_firsts`。

> **手机号筛查任务**：导入 JSON 须含 `account_unique_key`（来自抖音号找回 export）并成功绑定期望 ROI。命中判定 **仅看 ROI 评分**；Worker 须截取并上报 75×45 昵称 ROI PNG base64。  
> **硬规则：** 期望 ROI 必须来自找回侧 **真实截图**；禁止用昵称首字合成假图顶替；仅有首字、无 ROI 的账号无法进入筛查命中（详见 [手机号模式-昵称ROI比对方案.md](./手机号模式-昵称ROI比对方案.md) §14）。

Worker **需对接以下接口**（另可选健康检查）：

| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/api/v1/worker/device-info` | 上报云机设备信息（**≤ 60 秒**一次） |
| GET | `/api/v1/worker/proxy` | **按需**获取出口代理（启用代理网关时） |
| GET | `/api/v1/task/pop/phone` | **手机号池专用**：领取一条手机号筛查任务 |
| GET | `/api/v1/task/pop/douyin` | **抖音号池专用**：领取一条抖音号找回任务 |
| POST | `/api/v1/task/report` | 上报该子任务结果（字段随 `task_mode` 不同） |

> 旧版 `GET /api/v1/task/pop` 仍可用（在所有 `running` 批次间轮询），**新部署请改用上述两个专用接口**，避免混池误领。`job_id` 为**可选**调试参数，日常上传任务**无需**配置。

**典型循环（启用出口代理时）：**

```text
                    device-info（≤60s）
┌─────────┐ ─────────────────────────► ┌──────────┐
│ 云机脚本 │                            │ 调度大脑  │
└─────────┘                            └──────────┘
     │  GET /worker/proxy（需要出口 IP 时）
     └────────────────────────────────►  配置本地代理
     │    pop     ┌──────────┐  本地查号   ┌─────────┐  report
     └──────────► │ 调度大脑  │ ─────────► │ 抖音 API │ ────────► ...
                  └──────────┘             └─────────┘
     ▲                                              │
     └────────────── 无任务则等待后重试 ───────────────┘
```

**未启用代理网关时：** 省略 `GET /worker/proxy`，其余流程不变。

**典型循环（未启用代理）：**

```text
                    device-info（≤60s）
┌─────────┐ ─────────────────────────► ┌──────────┐
│ 云机脚本 │                            │ 调度大脑  │
└─────────┘                            └──────────┘
     │    pop     ┌──────────┐  本地查号   ┌─────────┐  report
     └──────────► │ 调度大脑  │ ─────────► │ 抖音 API │ ────────► ...
                  └──────────┘             └─────────┘
     ▲                                              │
     └────────────── 无任务则等待后重试 ───────────────┘
```

---

## 2. 基础信息

### 2.1 Base URL

| 环境 | 示例 |
|------|------|
| 本地开发 | `http://127.0.0.1:8080` |
| 生产 | 由运维提供，如 `https://your-domain.com` |

所有业务接口前缀：**`/api/v1`**

### 2.2 鉴权：`worker_key`

服务端在 `configs/config.yaml` 中配置：

```yaml
auth:
  worker_api_key: "local-e2e-worker-key"   # 生产环境请更换为强密钥
```

| 规则 | 说明 |
|------|------|
| 配置非空 | 所有 Worker 请求必须携带正确 `worker_key`，否则 `401` |
| 配置为空 | 开发模式可关闭校验（**生产禁止**） |

**传递方式：**

| 接口 | 推荐方式 |
|------|----------|
| `POST /worker/device-info` | Query：`?worker_key=xxx` |
| `GET /worker/proxy` | Query：`?worker_key=xxx&device_id=xxx` |
| `GET /task/pop/phone` | Query：`?worker_key=xxx` |
| `GET /task/pop/douyin` | Query：`?worker_key=xxx` |
| `GET /task/pop`（兼容） | Query：`?worker_key=xxx` |
| `POST /task/report` | Query：`?worker_key=xxx`，或 JSON Body 字段 `"worker_key": "xxx"` |

### 2.3 统一响应格式

成功时 HTTP 状态码一般为 **200**，Body 为 JSON：

```json
{
  "code": 0,
  "msg": "success",
  "data": { }
}
```

| 字段 | 说明 |
|------|------|
| `code` | 业务码，`0` 表示成功 |
| `msg` | 人类可读说明 |
| `data` | 业务数据，可能为 `null` |

错误时：

| HTTP | code | 典型 msg |
|------|------|----------|
| 400 | 400 | 参数错误、task 不存在等 |
| 401 | 401 | `invalid or missing worker_key` |
| 200 | 50301 | `proxy_unavailable`（代理网关不可用，见 §4.5） |

---

## 3. 设备标识 `device_id`（= `Names`）

| 接口 | 是否必填 | 说明 |
|------|----------|------|
| device-info | **必填** | Body 字段 `names` |
| proxy | **必填** | Query 参数 `device_id`；为空返回 `400 device_id required` |
| pop | **必填** | Query 参数 `device_id`；为空返回 `400 device_id required` |
| report | **建议** | Body 字段 `device_id`；用于 Admin 云机看板 Worker 统计 |

**统一约定：** `device_id` **必须等于** 设备信息中的 **`names`** 字段（云机实例唯一名）。

示例：

```text
names / device_id: a3a5a0b3e1591722075c5c95da8dab20_1_T1001
```

**看板数据模型（Admin `/admin/workers`）：**

| 层级 | 分组键 | 说明 |
|------|--------|------|
| 宿主机 | `ip` | 一台物理机可挂载多个云机实例 |
| 云机实例 | `names` | 与 `device_id` 相同 |

**双状态（请勿混淆）：**

| 状态 | 来源 | 含义 |
|------|------|------|
| **实例状态** `state` | device-info 上报 | 云手机是否**已开机**（如 `running`） |
| **Worker 活跃状态** | pop/report 时间窗 | 脚本是否在领任务/上报（活跃/处理中/空闲） |

**看板可见条件：**

| 行为 | 宿主机摘要 | 实例明细 |
|------|------------|----------|
| 仅 `GET /health` | 否 | 否 |
| 仅 `device-info` | **是**（按 `ip` 分组） | **是**（实例属性 + Worker 可能为空） |
| `pop` / `report`（带 `device_id=names`） | 是 | 是（含 Worker 活跃/错误率等） |

> 前端管理端与云机 Worker **必须连接同一后端实例**。本地开发时前端代理默认 `http://127.0.0.1:8080`。

---

## 4. 设备信息上报 — `POST /api/v1/worker/device-info`

云机脚本应 **每 ≤ 60 秒** 调用一次，上报宿主机与实例信息。管理端看板按 **`ip` 分组** 展示宿主机摘要，展开后查看该 IP 下各实例。

### 4.1 请求

```http
POST /api/v1/worker/device-info?worker_key={worker_key}
Content-Type: application/json
```

**Body（JSON）：**

```json
{
  "names": "a3a5a0b3e1591722075c5c95da8dab20_1_T1001",
  "state": "running",
  "data": "/mmc/data/data1_1780849293633",
  "index": 1,
  "ip": "192.168.1.104"
}
```

| 字段 | 必填 | 说明 |
|------|------|------|
| `names` | 是 | 云机实例唯一名；**同时作为** pop/report 的 `device_id` |
| `state` | 是 | 云手机**开机/实例运行状态**；当前为 `running` 表示已开机 |
| `data` | 是 | 实例数据目录路径 |
| `index` | 是 | 宿主机上的槽位序号 |
| `ip` | 是 | 宿主机 IP（全局唯一）；看板分组键 |

> **`state=running` 仅表示云手机已开机**，不代表 Worker 脚本正在 pop/report。脚本是否在干活请看管理端「Worker 活跃」列。

### 4.2 成功响应

```json
{
  "code": 0,
  "msg": "recorded"
}
```

### 4.3 错误示例

```json
{
  "code": 400,
  "msg": "names required"
}
```

### 4.4 信息在线与开机状态判定（后端兜底）

管理端 **不会** 因 device-info 心跳缺失而误判「关机」，前提是 pop/report 仍在进行。**云机侧无需额外逻辑。**

| 判定项 | 优先规则 | 兜底规则（后端推断） |
|--------|----------|----------------------|
| **信息在线** | `last_heartbeat_at` 距今 ≤ 60 秒 | 近 2 分钟内有 report，或近 5 分钟内有 pop，或 Worker 状态为「活跃/处理中」 |
| **已开机** | 信息在线且 `state=running` | pop/report 仍在进行 → 推断为已开机（响应字段 `instance_running=true`，`info_inferred=true`） |
| **信息离线 / 未开机** | 心跳超时 **且** pop/report 均已停滞 | 两者同时满足才显示离线 |

**典型场景：**

| 心跳 | pop/report | 管理端展示 |
|------|------------|------------|
| 正常（≤60s） | 正常 | 已开机（来自 device-info） |
| **超时** | **仍在进行** | **已开机（推断）**，不会判为关机 |
| 超时 | 均已停滞 | 信息离线 |

> `info_inferred=true` 表示当前「在线/已开机」由 Worker 活动推断，而非最新 device-info 心跳。

---

## 4.5 出口代理 — `GET /api/v1/worker/proxy`

云机 **只对接调度大脑**，不直连快代理等代理商。当管理端开启 `proxy.enabled=true` 且配置快代理（KDL）凭证后，云机在需要出口 IP 时调用本接口；**不与 pop 捆绑**，按需获取即可。

**双模式并行（v2.0+）：** 管理端通过 `proxy.dps.enabled` / `proxy.tps.enabled` **独立开关**，可同时启用；云机通过 Query **`proxy_mode=dps|tps`** 指定产品线：

| `proxy_mode` | 产品 | 云机 `connection` 含义 | 后端内部 API |
|--------------|------|------------------------|--------------|
| `dps`（默认） | 私密代理 | 动态出口 `IP:端口:账号:密码` | `getdps` / `checkdpsvalid` / `getdpsvalidtime` |
| `tps` | 隧道代理 | 固定隧道 `tunnel_host:端口:账号:密码` | `getorderinfo` / `getproxyauthorization` / `tpscurrentip` / `changetpsip` |

> **v2.0 变更：** 废弃全局 `proxy.mode` 二选一；DPS 与 TPS 绑定在 `device_proxy` 中 **按 `(device_id, proxy_mode)` 双行并存**，互不影响。

云机脚本 **仍解析 `connection` 配置本地 HTTP 代理**，一般无需区分模式；若需打日志或排查，可读 `proxy_mode`、`egress_ip`（TPS 当前出口 IP，仅展示用）、`kdl_order_id`（启用订单池时，本次分配使用的快代理订单号）。详见 [快代理-隧道代理对接文档](./快代理-隧道代理对接文档.md) §9。

**多订单池（`proxy.pool.enabled=true`）：** Worker 侧 **请求格式不变**；后端在 `getdps` / 隧道解析前从 `kdl_order_pool` 按策略 **Pick** 可用订单，并将订单号写入响应 `kdl_order_id` 与表 `device_proxy.kdl_order_id`。单订单凭证（`pool.enabled=false`）时仍走 `providers.kdl_dps` / `kdl_tps` 静态配置，`kdl_order_id` 通常为空。运维见 [出口代理网关-多订单池优化方案](./出口代理网关-多订单池优化方案.md)。

### 4.5.1 请求

```http
GET /api/v1/worker/proxy?device_id={names}&worker_key={worker_key}&proxy_mode=dps&refresh=0
```

| Query 参数 | 必填 | 说明 |
|------------|------|------|
| `device_id` | 是 | 与 device-info 的 `names` 一致 |
| `worker_key` | 是* | 与服务端配置一致 |
| `proxy_mode` | 推荐 | `dps` \| `tps`；省略时使用 `default_mode`（兼容旧脚本） |
| `refresh` | 否 | `1` 或 `true` 强制换 IP；默认 `0` 复用当前绑定 |

**`proxy_mode` 缺省解析顺序：** Query 合法且对应模式已启用 → 使用；否则读 `default_mode`；仅一种模式开启 → 用该模式；双开且无法确定 → `400 proxy_mode required`。

### 4.5.2 成功响应

```json
{
  "code": 0,
  "msg": "success",
  "data": {
    "proxy_id": "a1b2c3...",
    "device_id": "a3a5a0b3e1591722075c5c95da8dab20_1_T1001",
    "host": "113.31.123.45",
    "port": 15818,
    "username": "user001",
    "password": "pass001",
    "addr": "113.31.123.45:15818",
    "connection": "113.31.123.45:15818:user001:pass001",
    "scheme": "http",
    "status": "active",
    "assigned_at": "2026-06-12T10:00:00+08:00",
    "valid_until": "2026-06-12T10:30:00+08:00",
    "remaining_seconds": 1800,
    "last_check_at": "2026-06-12T10:05:00+08:00",
    "last_check_ok": true,
    "reused": true,
    "provider": "kdl",
    "proxy_mode": "dps",
    "kdl_order_id": "987807746559625"
  }
}
```

**TPS 模式示例（字段差异）：** `host` / `connection` 中为 **隧道入口**（非出口 IP）；`egress_ip` 为当前出口 IP（可选阅读）：

```json
{
  "code": 0,
  "msg": "success",
  "data": {
    "proxy_id": "a1b2c3...",
    "device_id": "a3a5a0b3e1591722075c5c95da8dab20_1_T1001",
    "host": "tps-xxx.kdlapi.com",
    "port": 15818,
    "username": "user001",
    "password": "pass001",
    "addr": "tps-xxx.kdlapi.com:15818",
    "connection": "tps-xxx.kdlapi.com:15818:user001:pass001",
    "scheme": "http",
    "status": "active",
    "proxy_mode": "tps",
    "egress_ip": "113.31.123.45",
    "valid_until": "2026-06-12T10:30:00+08:00",
    "remaining_seconds": 1800,
    "last_check_ok": true,
    "reused": true,
    "provider": "kdl",
    "kdl_order_id": "997807654380916"
  }
}
```

| 字段 | 说明 |
|------|------|
| `connection` | **推荐直接使用**：四段 `host:端口:账号:密码`。DPS 时 `host` 为动态出口 IP；TPS 时 `host` 为 **固定隧道入口** |
| `host` / `port` / `username` / `password` | 分项字段，与 `connection` 等价 |
| `proxy_mode` | 本次请求指定的模式（`dps` / `tps`），与 Query 一致 |
| `kdl_order_id` | **订单池开启时**：本次分配使用的快代理订单号（日志/排查）；未启用订单池时通常省略 |
| `egress_ip` | **仅 TPS**：隧道当前出口 IP（展示/日志用）；配置本地代理仍以 `connection` 为准 |
| `valid_until` / `remaining_seconds` | 绑定有效期。**DPS**：当前出口 IP 剩余可用时间（秒，来自 `getdpsvalidtime` / `getdps` 的 `f_et`）；**TPS**：隧道换 IP 周期估算 |
| `reused` | `true` 表示复用已有绑定；`false` 表示本次新分配或刷新 |
| `last_check_ok` | 最近一次有效性检测结果 |
| `provider` | 固定为 `kdl`（具体产品线看 `proxy_mode`） |

**复用与换 IP 策略（按模式）：**

| 场景 | DPS（私密代理） | TPS（隧道代理） |
|------|-----------------|-----------------|
| `refresh=0` 且绑定仍有效 | 调 `checkdpsvalid`，通过则复用同一出口 IP | 调 `tpscurrentip` 校验当前出口，通过则复用同一隧道绑定 |
| check 失败或已过期 | 重新 `getdps` 提取新 IP | 重新解析隧道并查询 `tpscurrentip` |
| `refresh=1` | 重新 `getdps` 换出口 IP | 调 `changetpsip` **更换整条隧道出口**（影响共享该隧道的全部云机，见 §13 Q16） |
| 同设备 DPS + TPS | 分别调用 `proxy_mode=dps` / `proxy_mode=tps` | 两套绑定 **并存**，互不覆盖 |
| 禁用某模式 | 该模式新请求返回 `50301`；历史绑定行保留 | 同左 |

管理端看板亦提供 **检测** / **换 IP**，行为与上表一致。

### 4.5.3 代理不可用

```json
{
  "code": 50301,
  "msg": "proxy_unavailable",
  "data": null
}
```

**含义：** 代理网关未启用、凭证无效、订单池无可用 ACTIVE 订单、或代理商暂时无可用 IP。

**常见原因（v2.0+）：**

| 原因 | 处理 |
|------|------|
| `proxy.enabled=false` 或对应 `dps/tps.enabled=false` | 管理端开启开关 |
| 单订单凭证错误或余额耗尽 | 检查 `providers.kdl_*` 或快代理控制台 |
| `pool.enabled=true` 但账户密钥未配 / 同步失败 | 配置 `pool.account_secret_*`，在 **订单池看板** 点「立即同步」 |
| 订单池全部 SUSPECT / EXHAUSTED / 临期禁用 | 看板告警处理对应订单 |

**云机建议：**

1. **不要 pop**（无可用出口时不应领任务）。
2. 等待 10～30 秒后重试 `GET /worker/proxy`。
3. 仍失败则记录日志并告警，勿空转 pop。

### 4.5.4 推荐脚本顺序

```python
# 伪代码
while True:
    post_device_info()  # 每 ≤60s

    proxy = get_proxy(device_id, proxy_mode="dps", refresh=0)
    if proxy.code == 50301:
        sleep(15)
        continue
    configure_local_proxy(proxy.data.connection)  # host:port:user:pass（DPS=出口IP，TPS=隧道入口）
    # 可选日志：proxy.data.proxy_mode, proxy.data.kdl_order_id, proxy.data.remaining_seconds

    task = pop(device_id)
    if task.code == 40401:
        sleep(3)
        continue
    result = query_douyin(task.data.phone)
    report(task.data.task_id, result)
```

### 4.5.5 `connection` 字段解析（云机侧）

`data.connection` 格式固定为 **四段**，以英文冒号分隔：

```text
host:端口:账号:密码
```

| 模式 | 第一段 `host` 含义 | 示例 |
|------|-------------------|------|
| `dps` | 动态出口 IP | `113.31.123.45:15818:user001:pass001` |
| `tps` | 固定隧道入口域名/IP | `tps-xxx.kdlapi.com:15818:user001:pass001` |

> TPS 模式下实际出口 IP 在 `egress_ip` 字段；**配置本地 HTTP 代理时仍使用 `connection` 中的隧道 host:port**，不要将 `egress_ip` 当作代理服务器地址。

**解析示例（Python）：**

```python
def parse_connection(connection: str):
    host, port, username, password = connection.split(":", 3)
    return host, int(port), username, password
```

**云机脚本建议：**

1. 优先使用 `connection` 一次性解析；也可分别读取 `host` / `port` / `username` / `password`。
2. 按本地环境配置 HTTP 代理（scheme 默认 `http`，见 `data.scheme`）。
3. 在 `remaining_seconds` 接近 0 或查号失败需换 IP 时，再次调用 `GET /worker/proxy?proxy_mode={mode}&refresh=1`（须与当前业务使用的模式一致）。
4. **同一 `(device_id, proxy_mode)` 在有效期内默认复用绑定**（`reused=true`），避免频繁换 IP 触发代理商限流。
5. 可选：读取 `proxy_mode` / `egress_ip` / `kdl_order_id` / `remaining_seconds` 写日志；**配置本地代理不必分支**，两种模式解析 `connection` 方式相同。

### 4.5.6 错误响应

| HTTP | code | msg | 说明 |
|------|------|-----|------|
| 400 | 400 | `device_id required` | 未传 `device_id` |
| 400 | 400 | `proxy_mode required` | DPS 与 TPS **同时开启** 且 Query 未传合法 `proxy_mode` |
| 200 | 50301 | `proxy_unavailable` | 网关未启用、KDL 凭证无效、订单池无可用订单或代理商无可用 IP |

**`proxy_mode required` 示例：**

```json
{
  "code": 400,
  "msg": "proxy_mode required"
}
```

**`device_id required` 示例：**

```json
{
  "code": 400,
  "msg": "device_id required"
}
```

---

## 5. 领取任务 — 双池专用接口（推荐）

手机号筛查与抖音号找回使用 **两个独立领取地址**。服务端仅在对应 `job_mode` 且 `status=running` 的批次间公平轮询；管理端上传并启动任务后，云机**无需**配置 `job_id`。

### 5.1 手机号筛查 — `GET /api/v1/task/pop/phone`

```http
GET /api/v1/task/pop/phone?device_id={device_id}&worker_key={worker_key}&job_id={optional}
```

| Query 参数 | 必填 | 说明 |
|------------|------|------|
| `device_id` | 是 | 云机实例 `names`，与 device-info 一致 |
| `worker_key` | 是* | 与服务端配置一致（*配置为空时可省略） |
| `job_id` | 否 | **可选**：仅调试或强制指定单批次 UUID；日常留空即可 |

**调度范围：** 仅 `job_mode=phone_screen` 且 `status=running` 的批次。

### 5.2 抖音号找回 — `GET /api/v1/task/pop/douyin`

```http
GET /api/v1/task/pop/douyin?device_id={device_id}&worker_key={worker_key}&job_id={optional}
```

参数与 §5.1 相同。

**调度范围：** 仅 `job_mode=douyin_recover` 且 `status=running` 的批次。

### 5.3 兼容接口 — `GET /api/v1/task/pop`（不推荐）

```http
GET /api/v1/task/pop?device_id={device_id}&worker_key={worker_key}&job_id={optional}
```

在所有 `running` 批次（**不区分** `job_mode`）间公平轮询。两种模式批次同时运行时可能领到错误类型任务，**仅保留给旧脚本**；新部署请使用 §5.1 / §5.2。

### 5.4 成功 — 领到任务（手机号筛查）

```json
{
  "code": 0,
  "msg": "success",
  "data": {
    "task_id": "12345",
    "job_id": "882fb1ae-b1e6-45c6-aba5-86a8e9ae9fd4",
    "task_mode": "phone_screen",
    "uid": "104474489018",
    "phone": "15664076367",
    "bucket": "top"
  }
}
```

| 字段 | 类型 | 说明 |
|------|------|------|
| `task_id` | string | **任务 ID**，手机号模式下为 `candidate_phones.id`；抖音号找回模式下为 `douyin_records.id`；report 时必须原样回传 |
| `job_id` | string | 所属导入批次 ID |
| `task_mode` | string | `phone_screen`（默认）或 `douyin_recover` |
| `uid` | string | 抖音 UID（导入数据） |
| `phone` | string | 待筛查手机号（**仅 `phone_screen`**） |
| `bucket` | string | 候选桶：`top` / `other` / `segment`（**仅 `phone_screen`**） |

**领取后的服务端状态（`phone_screen`）：**

- 该抖音号进入 **筛查中（locked）**
- 该候选号进入 **已派发（dispatched）**
- 启动锁定时长计时（见 §8）

**领取后的服务端状态（`douyin_recover`）：**

- 该抖音号进入 **筛查中（locked）**
- 无候选手机号；**1 抖音号 = 1 次 pop**
- 启动锁定时长计时（见 §8）

### 5.5 成功 — 暂无任务

```json
{
  "code": 40401,
  "msg": "no_task",
  "data": null
}
```

**含义：** 当前接口对应模式下没有可领取的待办（无 `running` 批次、已全部处理完毕、或指定 `job_id` 无待办）。

**建议：** 等待 2～5 秒后重试 pop，避免空转打满 CPU。

> 使用专用接口时：抖音号池在仅有手机号批次 running 时会收到 `40401`（反之亦然），属**正常现象**，表示该池当前无任务，**不会**误领另一模式任务。

### 5.6 错误示例

```json
{
  "code": 400,
  "msg": "device_id required"
}
```

### 5.7 调度说明（手机号筛查 Worker）

1. **每次 pop 只返回 1 个手机号**，不是一次返回整个 Top 组。
2. 同一抖音号 **同一时刻只会有一条** 号码处于 dispatched。
3. 未指定 `job_id` 时，多个同模式 `running` 批次按 **公平轮询** 分配。
4. 单批次内，候选号按 **Top → Other → Segment** 顺序尝试（`sort_order` 升序）。

### 5.8 抖音号找回模式 — pop 响应（`douyin_recover` Worker）

```json
{
  "code": 0,
  "msg": "success",
  "data": {
    "task_id": "67890",
    "job_id": "882fb1ae-b1e6-45c6-aba5-86a8e9ae9fd4",
    "task_mode": "douyin_recover",
    "douyin_id": "Lai.1204",
    "uid": "4029078185206584"
  }
}
```

| 字段 | 说明 |
|------|------|
| `douyin_id` | 下发给云机执行的抖音号，来自导入 JSON 的 `data.buyer.douyin_id` |
| `uid` | 导入 JSON 的 `data.buyer.uid`，用于去重与展示 |

每个抖音号 **仅 pop 一次**；无 `phone` / `bucket` 字段。锁定时长与超时回收规则与 §8 相同。

---

## 6. 上报结果 — `POST /api/v1/task/report`

手机号筛查与抖音号找回 **共用同一 report 接口**，但 Body 字段与校验规则 **完全不同**。服务端根据 `task_id` 路由：

1. 先在 `douyin_recover` 批次中按 `douyin_records.id` 查找；
2. 未命中再在 `phone_screen` 批次中按 `candidate_phones.id` 查找。

> pop 响应 **不含** `has_expected_roi` 等子类型标记。Worker 须按 **本池任务模式** 与 **管理端导入约定** 选择 report 形态（见 §6.0）。

### 6.0 按任务模式：强制上报字段总览

| `task_mode` | 场景 | `api_status` | **必须上报** | **可省略 / 忽略** |
|-------------|------|--------------|--------------|-------------------|
| `phone_screen` | 手机号筛查（须已绑定期望 ROI） | `success` | `data.nickname_roi_base64` **或** 非空 `data.nickname_roi_base64s[]` | `api_nickname_first` / `api_nickname_firsts`（**手机号模式不要求**，上报亦忽略） |
| `phone_screen` | 用户不存在 | `error_1011` / `error_code=1011` | 无（勿伪造 ROI） | ROI、昵称首字均可省略 |
| `douyin_recover` | 找回成功 | `success` 或 `recover_success` | `data.phone_prefix`（恰好 3 位数字）+ `data.nickname_roi_base64` | `api_nickname_first*`（本模式从未要求） |
| `douyin_recover` | 找回失败 | 非 success / 非 recover_success（如 `recover_failed`） | 无（`data` 可 `{}`） | — |

**PNG base64 统一规格（凡要求 ROI 时）：**

| 项 | 要求 |
|----|------|
| 格式 | **PNG**（不接受 JPG/WebP/URL） |
| 尺寸 | **宽 75 × 高 45** 像素 |
| 编码 | 标准 Base64；允许 `data:image/png;base64,` 前缀 |
| 一号多抖音（仅手机号 ROI 绑定） | 使用 `nickname_roi_base64s[]` 逐张上报；服务端取 **max(roi_match_score)** |

### 6.1 请求公共约定

```http
POST /api/v1/task/report?worker_key={worker_key}
Content-Type: application/json
```

| 字段 | 必填 | 说明 |
|------|------|------|
| `task_id` | **是** | pop 返回的 `task_id` 字符串，**不得篡改** |
| `device_id` | 强烈建议 | 云机实例 `names`，与 device-info 一致 |
| `api_status` | 强烈建议 | 见 §6.6；空字符串时服务端按 `success` 处理 |
| `data` | 条件 | 成功场景按 §6.2～§6.4 填充；失败场景可 `{}` |

**成功响应：**

```json
{
  "code": 0,
  "msg": "recorded"
}
```

校验失败时 HTTP **400**，Body 示例：`{"code":400,"msg":"nickname_roi_base64 required for ROI-bound record"}`（完整列表见 §6.8）。

### 6.2 手机号筛查 — Legacy 昵称首字（**已废弃，v1.8 起不再支持**）

> **v1.8 起**：`phone_screen` 模式 **统一为 ROI 判定**，不再接受 Legacy 首字比对路径。无绑定期望 ROI 的记录在 `success` report 时将返回 **HTTP 400**（`record requires ROI binding`）。本节内容 **仅作历史参考**，新部署 Worker **请勿** 再上报 `api_nickname_first*` 作为命中依据。

### 6.3 手机号筛查 — ROI 图像比对（`phone_screen`）

适用：管理端导入手机号 JSON 时，每条携带有效 `account_unique_key` 且服务端已成功绑定期望 ROI（`expected_roi_path` 非空）。

> **导入前提：** 对应抖音号找回记录须已落盘真实 75×45 昵称小图。仅有昵称首字、无 `recover_nickname_roi_path` 时，import 无法绑定期望 ROI，该条会被 **跳过**，不进入筛查队列。禁止用首字「画一张假 ROI」冒充基准（见 [手机号模式-昵称ROI比对方案.md](./手机号模式-昵称ROI比对方案.md) §14）。

**查询成功 — 仅需上报 PNG base64：**

```json
{
  "task_id": "12345",
  "device_id": "a3a5a0b3e1591722075c5c95da8dab20_1_T1001",
  "api_status": "success",
  "data": {
    "nickname_roi_base64": "iVBORw0KGgoAAAANSUhEUgAAAEsAAAAtCAIAAABpgQH6...",
    "nickname_roi_base64s": []
  }
}
```

**一号多抖音 — 推荐多张 ROI：**

```json
{
  "task_id": "12345",
  "device_id": "a3a5a0b3e1591722075c5c95da8dab20_1_T1001",
  "api_status": "success",
  "data": {
    "nickname_roi_base64s": [
      "iVBORw0KGgoAAAANSUhEUg...",
      "iVBORw0KGgoAAAANSUhEUg..."
    ]
  }
}
```

| 字段 | `success` 时 | 说明 |
|------|--------------|------|
| `data.nickname_roi_base64` | **必填**（可与 `nickname_roi_base64s` 二选一） | 单张 75×45 PNG base64；与绑定期望 ROI 评分比对（**命中判定依据**） |
| `data.nickname_roi_base64s` | 推荐（一号多抖音） | 字符串数组；与 `nickname_roi_base64` **合并去重**后逐张算分，取 **最高分** |
| `data.api_nickname_first` | **不需要** | 手机号模式 **不要求**；若上报服务端 **忽略** |
| `data.api_nickname_firsts` | **不需要** | 同上 |

**用户不存在：**

```json
{
  "task_id": "12345",
  "api_status": "error_1011",
  "data": { "error_code": 1011 }
}
```

| 场景 | 服务端行为 |
|------|------------|
| `success` 但 **未提供** `nickname_roi_base64` / `nickname_roi_base64s` | **HTTP 400**，`msg`: `nickname_roi_base64 required for ROI-bound record` |
| `success` 但 base64 非法 / 非 PNG / 尺寸非 75×45 | **HTTP 400**，见 §6.8 |
| `success` 且 ROI 合法但 `roi_match_score < 97.5`（默认） | **未命中**，候选号记为已查询，继续下一候选号 |
| `success` 且 `roi_match_score ≥ 97.5` | **命中**，抖音号 passed（§7.4） |

> `data.api_register_time` **已废弃**：Worker **无需上报**。手机号筛查 **命中判定仅看** `roi_match_score`（默认阈值 **97.5**，可配置 `roi.pass_score`）。`api_nickname_first*` **手机号模式不要求**。

### 6.4 抖音号找回 — `douyin_recover` report

由 `GET /task/pop/douyin` 领到的任务，`task_id` = `douyin_records.id`（**不是** `candidate_phones.id`）。

**找回成功 — `phone_prefix` 与 `nickname_roi_base64` 均为必填：**

```json
{
  "task_id": "67890",
  "device_id": "a3a5a0b3e1591722075c5c95da8dab20_1_T1001",
  "api_status": "success",
  "data": {
    "phone_prefix": "156",
    "nickname_roi_base64": "iVBORw0KGgoAAAANSUhEUgAAAEsAAAAtCAIAAABpgQH6..."
  }
}
```

| 字段 | `success` / `recover_success` 时 | 说明 |
|------|----------------------------------|------|
| `data.phone_prefix` | **必填** | 恰好 **3 位数字**（如 `"156"`） |
| `data.nickname_roi_base64` | **必填** | 75×45 **PNG** base64；用于落盘、生成 `account_unique_key` |
| `data.nickname_roi_base64s` | **不支持** | 找回模式仅读取单字段 `nickname_roi_base64` |
| `data.api_nickname_first` | 不需要 | 不参与找回判定；**不可** 代替 ROI |

> 缺 ROI 仅报首字：**不算找回完成**，不能产生可用于手机号筛查的期望图绑定。历史仅有首字的数据须 **补采**（重跑本模式并强制截图）后再 export。

**找回失败：**

```json
{
  "task_id": "67890",
  "device_id": "a3a5a0b3e1591722075c5c95da8dab20_1_T1001",
  "api_status": "recover_failed",
  "data": {}
}
```

| 场景 | 服务端行为 |
|------|------------|
| `success` 但缺少 `phone_prefix` 或 `nickname_roi_base64` | **HTTP 400** |
| `phone_prefix` 非 3 位数字 | **HTTP 400**，`msg`: `phone_prefix must be 3 digits` |
| ROI base64 非法 / 非 PNG / 尺寸错误 | **HTTP 400**，见 §6.8 |
| 非 success / 非 recover_success | 记录标为 **failed**；`data` 可省略 |
| 记录已 passed / failed 后重复 report | **幂等成功**（§6.7） |

### 6.5 管理端回写字段（ROI 绑定手机号任务）

Worker 无需上报以下字段；服务端比对后写入任务详情供运维复核：

| 字段 | 说明 |
|------|------|
| `roi_match_score` | 0～100 综合分 |
| `match_tier` | `L1` / `L2` / `L3` / `none` |
| `binary_match_rate` | L2 二值化像素一致率 |
| `dhash_hamming` | L3 感知哈希汉明距离 |

管理端展示 **查询结果**（用户存在 / 不存在）由服务端根据 `api_status` 与 ROI 判定自动回写；Worker **无需** 上报昵称首字。

### 6.6 `api_status` 约定

| 值 | 适用模式 | 含义 | 服务端处理 |
|----|----------|------|------------|
| `success` | 通用 | 查询/找回成功 | 按 §6.3～§6.4 校验必填字段并判定命中 |
| `recover_success` | 仅 `douyin_recover` | 找回成功（与 `success` 等效） | 同 `success` |
| `error_1011` | `phone_screen` | 手机号未注册抖音 | 候选号 **skipped**，**不要求** ROI / 首字 |
| `recover_failed` 等 | `douyin_recover` | 找回失败 | 记录 **failed** |
| 其他非空值 | 通用 | 脚本/业务错误 | 记入 Worker 错误统计；手机号模式按未命中处理 |

也可仅通过 `data.error_code: 1011` 表达用户不存在（与 `api_status: error_1011` 等效）。

### 6.7 重复上报（幂等）

| 模式 | 条件 | 行为 |
|------|------|------|
| `phone_screen` | 候选号已是 `已查询` 或 `已跳过` | 直接返回 `recorded`，不重复改判 |
| `douyin_recover` | 记录已是 `passed` 或 `failed` | 直接返回 `recorded`，不重复改判 |

### 6.8 错误示例（与后端实现一致）

**通用：**

```json
{ "code": 400, "msg": "task not found" }
```

```json
{ "code": 400, "msg": "invalid task_id" }
```

**手机号 ROI 绑定：**

```json
{ "code": 400, "msg": "nickname_roi_base64 required for ROI-bound record" }
```

```json
{ "code": 400, "msg": "invalid nickname_roi_base64" }
```

```json
{ "code": 400, "msg": "nickname_roi must be valid PNG" }
```

```json
{ "code": 400, "msg": "nickname_roi must be 75x45" }
```

**抖音号找回：**

```json
{ "code": 400, "msg": "phone_prefix must be 3 digits" }
```

```json
{ "code": 400, "msg": "nickname_roi_base64 required" }
```

```json
{ "code": 400, "msg": "record not locked" }
```

> 以上 `msg` 为服务端 `tasksvc` / `verify` 包实际返回字符串；云机脚本应对 **400** 打日志并区分「缺字段」与「图像规格错误」。

---

## 7. 命中判定规则（report 后）

> 政策原因：**不再使用注册年份** 作为通过条件。

服务端按任务类型选择判定路径：

| 任务类型 | 判定方式 |
|----------|----------|
| **`phone_screen`（手机号筛查）** | **图像 ROI 评分**（§7.4），阈值默认 **97.5** |
| **`douyin_recover`（抖音号找回）** | 找回成功即记录 passed（§6.4）；不涉及昵称首字 |

> **v1.8 起**：手机号筛查 **不再** 使用昵称首字文本比对（Legacy 路径已移除）。

### 7.1～7.2 Legacy 昵称首字（**已废弃**）

v1.8 之前 JSON/JSONL 无 ROI 绑定时曾用 `api_nickname_first(s)` 与导入期望首字比对。**当前版本不再支持**；所有 `phone_screen` 任务须走 ROI 判定。

### 7.3 命中后的数据

| 字段 | 说明 |
|------|------|
| `verified_phone` | 命中的手机号 |
| `verified_bucket` | 来自 pop 的 `bucket` |
| `strict_verified` | `top` / `strict` 桶命中 |
| `loose_verified` | `other` / `segment` / `loose` 桶命中 |

### 7.4 手机号筛查 — 昵称 ROI 图像比对

当导入记录已通过 `account_unique_key` 绑定期望 ROI 后，手机号 report 在 `api_status=success` 时 **必须** 上报符合 §6.3 规格的 `nickname_roi_base64`（或 `nickname_roi_base64s[]`）。缺 ROI 返回 **HTTP 400**，不会进入评分流程。

**硬门槛（V2）：**

- 全图二值一致率 s2 ≥ **98%**
- 左侧首字区（宽 35%）二值一致率 ≥ **95%**

**评分公式：**

```
若 PNG 字节完全一致：roi_match_score = 100（L1）
否则：
  s2 = binary_match_rate × 100
  s3 = (1 - dhash_hamming / 63) × 100
  tier_bonus = (s2 ≥ 98% ? +5 : 0)    # 仅 L2 达标加分
  roi_match_score = min(100, 0.70×s2 + 0.30×s3 + tier_bonus)
```

| 条件 | 结果 |
|------|------|
| `error_code == 1011` 或 `api_status == error_1011` | **未命中**，该号 **skipped** |
| `api_status == success` 且 **max(roi_match_score) ≥ 97.5**（默认）且通过硬门槛 | **命中** |
| `api_status == success` 但缺少 ROI、未过硬门槛或 score < 阈值 | **未命中**，继续下一候选号 |

**一号多抖音：** 对 `nickname_roi_base64s[]` 逐张算分，取 **max** 与阈值比较。

**report 成功示例（手机号筛查）：**

```json
{
  "task_id": "12345",
  "device_id": "a3a5a0b3e1591722075c5c95da8dab20_1_T1001",
  "api_status": "success",
  "data": {
    "nickname_roi_base64": "iVBORw0KGgoAAAANSUhEUg...",
    "nickname_roi_base64s": ["iVBORw0KGgo...", "iVBORw0KGgo..."]
  }
}
```

> 手机号筛查 **命中判定仅看** `roi_match_score` 与硬门槛；**不要求** `api_nickname_first`。管理端 ROI 评分回写字段见 **§6.5**。

**导入后缓存预扫：** ES 缓存 **仅** 用于 `user_not_exists`（1011）预跳过；**不再** 用昵称首字预 pass/skip。须等待 Worker 实际上报 PNG base64 后评分。

**与「仅有首字」的关系：** 期望图必须是找回真截图。算法「首字区」= 左 35% 像素相似度，不是文本字符。禁止合成假期望图；详见方案 §14 / FAQ Q18。

---

## 8. 锁定时长与超时回收

pop 成功后，服务端对该抖音号加锁，默认 **120 秒**（`import.lock_timeout_seconds`）。

| 阶段 | 行为 |
|------|------|
| 0 ~ 锁定时长内未 report | 号码保持 dispatched，其他 Worker **不能** pop 该抖音号 |
| 超过锁定时长仍未 report | 后台 daemon 自动回收：候选号 → 待筛查，抖音号 → 待筛查，**可被再次 pop** |

daemon 扫描间隔默认 **30 秒**（`daemon.interval_seconds`），故最坏回收时间约为 **锁定时长 + 扫描间隔**。

### 8.1 云机脚本建议

1. pop 后 **尽快** report；若本地脚本约需 60 秒，当前默认 120 秒锁通常足够。
2. 本地记录 pop 时间；若接近锁超时仍无法完成，**放弃本次 report**，重新 pop（避免过期 report 干扰）。
3. 若脚本 P99 耗时 > 90 秒，请运维调大 `lock_timeout_seconds`（如 180）。

---

## 9. `bucket` 字段说明

| bucket | 含义 | 尝试顺序 |
|--------|------|----------|
| `top` | Top 综合命中 | 最先 |
| `other` | Other 综合命中 | 其次 |
| `segment` | Segment 综合命中 | 再次 |
| `strict` / `loose` | JSONL 等模式的 strict/loose 桶 | 按 sort_order |

bucket **不影响** 命中判定规则，仅影响统计分类与尝试顺序。

---

## 9.5 抖音号找回模式（`douyin_recover` Worker 专章）

本节供 **抖音号找回 Worker** 对接；**手机号筛查 Worker 无需阅读 report 字段部分**。

### 9.5.1 管理端导入格式

管理端选择 **任务模式 = 抖音号找回**，上传 **JSON 数组**（非 JSONL）。单条结构示例：

```json
{
  "data": {
    "buyer": {
      "douyin_id": "Lai.1204",
      "uid": "4029078185206584",
      "user_name": "不再打翻牛奶"
    }
  }
}
```

| 字段 | 必填 | 说明 |
|------|------|------|
| `data.buyer.douyin_id` | 是 | pop 时下发的抖音号 |
| `data.buyer.uid` | 是 | 去重与展示 |
| `data.buyer.user_name` / `nickname_search` | 否 | 昵称展示用 |

**任务完成后 export JSON（v1.6）** 会在 `data.buyer` 内写入 **`account_unique_key`**（32 位 hex，UI：账号唯一值），供后续手机号筛查 import 绑定 ROI。**不含** ROI 原图 / base64 / 内部路径。

```json
{
  "data": {
    "buyer": {
      "uid": "4029078185206584",
      "recover_phone_prefix": "156",
      "account_unique_key": "a1b2c3d4e5f6789012345678abcdef01"
    }
  },
  "recover_api_status": "success"
}
```

### 9.5.2 与手机号筛查的差异

| 项目 | 手机号筛查 Worker | 抖音号找回 Worker |
|------|-------------------|-------------------|
| 子任务粒度 | 1 抖音号 × N 候选手机号 | **1 抖音号 = 1 次 pop** |
| 导入后 | ES 缓存 **仅** `user_not_exists` 预跳过 | **跳过缓存预扫**，直接 `running` |
| pop 接口 | `GET /task/pop/phone` | `GET /task/pop/douyin` |
| pop 字段 | `phone` + `bucket` | `douyin_id` + `uid` |
| report 字段 | `nickname_roi_base64`（± `nickname_roi_base64s[]`）；**不要求** 昵称首字 | `phone_prefix` + `nickname_roi_base64` |
| 锁 / 超时 | §8 | **相同** |

### 9.5.3 Worker 主循环（伪代码）

```python
# 抖音号找回 Worker — 独立脚本，部署在抖音号专用云机池
while True:
    r = requests.get(f"{BASE}/api/v1/task/pop/douyin", params={
        "device_id": NAMES,
        "worker_key": WORKER_KEY,
    }, timeout=30)
    body = r.json()
    if body["code"] == 40401:
        sleep(3)
        continue

    task = body["data"]
    if task.get("task_mode") != "douyin_recover":
        raise RuntimeError(f"unexpected task_mode: {task.get('task_mode')}")

    douyin_id = task["douyin_id"]
    task_id = task["task_id"]

    # --- 云机本地：按 douyin_id 执行找回流程，截取 75×45 昵称 ROI ---
    ok, phone_prefix, roi_png_b64 = run_douyin_recover(douyin_id)

    if ok:
        report(task_id, api_status="success", data={
            "phone_prefix": phone_prefix,           # 恰好 3 位数字
            "nickname_roi_base64": roi_png_b64,     # PNG base64，75×45
        })
    else:
        report(task_id, api_status="recover_failed", data={})
```

### 9.5.4 report 要点

| 场景 | `api_status` | `data` |
|------|--------------|--------|
| 找回成功 | `success` 或 `recover_success` | **必填** `phone_prefix` + `nickname_roi_base64` |
| 找回失败 | `recover_failed` 等 | 可省略或 `{}` |

- `phone_prefix`：恰好 **3 位数字**
- `nickname_roi_base64`：**PNG** base64（可带 `data:image/png;base64,` 前缀），尺寸 **75×45**，**不接受 URL**
- 接口路径与鉴权与手机号 Worker **相同**，见 §6.3 抖音号找回 report 示例

---

## 10. 完整对接示例

### 10.1 cURL

**设备信息心跳（≤ 60 秒一次）：**

```bash
curl -s -X POST "http://127.0.0.1:8080/api/v1/worker/device-info?worker_key=local-e2e-worker-key" \
  -H "Content-Type: application/json" \
  -d '{
    "names": "a3a5a0b3e1591722075c5c95da8dab20_1_T1001",
    "state": "running",
    "data": "/mmc/data/data1_1780849293633",
    "index": 1,
    "ip": "192.168.1.104"
  }'
```

**获取出口代理（启用代理网关时，pop 前调用）：**

```bash
curl -s "http://127.0.0.1:8080/api/v1/worker/proxy?device_id=a3a5a0b3e1591722075c5c95da8dab20_1_T1001&worker_key=local-e2e-worker-key&proxy_mode=dps&refresh=0"
```

**强制换 IP：**

```bash
curl -s "http://127.0.0.1:8080/api/v1/worker/proxy?device_id=a3a5a0b3e1591722075c5c95da8dab20_1_T1001&worker_key=local-e2e-worker-key&proxy_mode=dps&refresh=1"
```

**领取（手机号筛查 Worker）：**

```bash
curl -s "http://127.0.0.1:8080/api/v1/task/pop/phone?device_id=a3a5a0b3e1591722075c5c95da8dab20_1_T1001&worker_key=local-e2e-worker-key"
```

**上报命中（手机号筛查 / ROI）：**

```bash
curl -s -X POST "http://127.0.0.1:8080/api/v1/task/report?worker_key=local-e2e-worker-key" \
  -H "Content-Type: application/json" \
  -d '{
    "task_id": "12345",
    "device_id": "a3a5a0b3e1591722075c5c95da8dab20_1_T1001",
    "api_status": "success",
    "data": {
      "nickname_roi_base64": "iVBORw0KGgoAAAANSUhEUgAAAEsAAAAtCAIAAABpgQH6..."
    }
  }'
```

**上报一号多抖音（多张 ROI）：**

```bash
curl -s -X POST "http://127.0.0.1:8080/api/v1/task/report?worker_key=local-e2e-worker-key" \
  -H "Content-Type: application/json" \
  -d '{
    "task_id": "12345",
    "device_id": "a3a5a0b3e1591722075c5c95da8dab20_1_T1001",
    "api_status": "success",
    "data": {
      "nickname_roi_base64s": [
        "iVBORw0KGgoAAAANSUhEUg...",
        "iVBORw0KGgoAAAANSUhEUg..."
      ]
    }
  }'
```

**上报用户不存在：**

```bash
curl -s -X POST "http://127.0.0.1:8080/api/v1/task/report?worker_key=local-e2e-worker-key" \
  -H "Content-Type: application/json" \
  -d '{
    "task_id": "12345",
    "device_id": "a3a5a0b3e1591722075c5c95da8dab20_1_T1001",
    "api_status": "error_1011",
    "data": { "error_code": 1011 }
  }'
```

**（抖音号找回 Worker）领取任务：**

```bash
curl -s "http://127.0.0.1:8080/api/v1/task/pop/douyin?device_id=a3a5a0b3e1591722075c5c95da8dab20_1_T1001&worker_key=local-e2e-worker-key"
```

**（抖音号找回 Worker）上报成功：**

```bash
curl -s -X POST "http://127.0.0.1:8080/api/v1/task/report?worker_key=local-e2e-worker-key" \
  -H "Content-Type: application/json" \
  -d '{
    "task_id": "67890",
    "device_id": "a3a5a0b3e1591722075c5c95da8dab20_1_T1001",
    "api_status": "success",
    "data": {
      "phone_prefix": "156",
      "nickname_roi_base64": "iVBORw0KGgoAAAANSUhEUg..."
    }
  }'
```

**（抖音号找回 Worker）上报失败：**

```bash
curl -s -X POST "http://127.0.0.1:8080/api/v1/task/report?worker_key=local-e2e-worker-key" \
  -H "Content-Type: application/json" \
  -d '{
    "task_id": "67890",
    "device_id": "a3a5a0b3e1591722075c5c95da8dab20_1_T1001",
    "api_status": "recover_failed",
    "data": {}
  }'
```

### 10.2 Python 伪代码 — 手机号筛查 Worker（`phone_screen`）

```python
import time
import requests
import threading

BASE = "http://127.0.0.1:8080"
WORKER_KEY = "local-e2e-worker-key"
NAMES = "a3a5a0b3e1591722075c5c95da8dab20_1_T1001"
HOST_IP = "192.168.1.104"

def heartbeat_loop():
    while True:
        requests.post(
            f"{BASE}/api/v1/worker/device-info",
            params={"worker_key": WORKER_KEY},
            json={
                "names": NAMES,
                "state": "running",
                "data": "/mmc/data/data1_1780849293633",
                "index": 1,
                "ip": HOST_IP,
            },
            timeout=30,
        ).raise_for_status()
        time.sleep(60)

threading.Thread(target=heartbeat_loop, daemon=True).start()

PROXY_ENABLED = True  # 与后端 proxy.enabled 一致；关闭时可设为 False 并跳过 get_proxy
PROXY_MODE = "dps"    # dps | tps；双模式均开启时必传，与 UI worker_proxy_mode 一致

def get_proxy(refresh: int = 0, proxy_mode: str = PROXY_MODE):
    r = requests.get(
        f"{BASE}/api/v1/worker/proxy",
        params={
            "device_id": NAMES,
            "worker_key": WORKER_KEY,
            "proxy_mode": proxy_mode,
            "refresh": refresh,
        },
        timeout=30,
    )
    r.raise_for_status()
    return r.json()

def configure_local_proxy(connection: str):
    host, port, user, password = connection.split(":", 3)
    # TODO: 写入云机系统/脚本 HTTP 代理配置
    pass

while True:
    if PROXY_ENABLED:
        body = get_proxy(refresh=0)
        if body.get("code") == 50301:
            time.sleep(15)
            continue
        if body.get("code") != 0:
            raise RuntimeError(body)
        configure_local_proxy(body["data"]["connection"])

    r = requests.get(f"{BASE}/api/v1/task/pop/phone", params={
        "device_id": NAMES,
        "worker_key": WORKER_KEY,
    }, timeout=30)
    body = r.json()

    if body.get("code") == 40401:
        time.sleep(3)
        continue

    if body.get("code") != 0:
        raise RuntimeError(body)

    task = body["data"]
    if task.get("task_mode", "phone_screen") != "phone_screen":
        raise RuntimeError(f"unexpected task_mode for phone worker: {task.get('task_mode')}")

    task_id = task["task_id"]
    phone = task["phone"]
    uid = task["uid"]

    # --- 云机本地：用 phone + uid 调抖音查询，裁切 75×45 昵称 ROI ---
    roi_png_b64, roi_png_b64_list, api_status, error_code = run_phone_lookup_roi(phone, uid)

    data = {"error_code": error_code}
    if api_status == "success":
        if roi_png_b64_list:
            data["nickname_roi_base64s"] = roi_png_b64_list
        else:
            data["nickname_roi_base64"] = roi_png_b64

    requests.post(
        f"{BASE}/api/v1/task/report",
        params={"worker_key": WORKER_KEY},
        json={
            "task_id": task_id,
            "device_id": NAMES,
            "api_status": api_status,
            "data": data,
        },
        timeout=30,
    ).raise_for_status()
```

> **v1.8**：手机号筛查 **不要求** 上报 `api_nickname_first` / `api_nickname_firsts`；命中仅由 ROI 评分决定。

### 10.2.1 Python 伪代码 — 一号多抖音 ROI 片段

一号多抖音时改用 `nickname_roi_base64s[]` 上报多张 ROI，服务端取 **max(roi_match_score)**：

```python
# 在 §10.2 report 片段中，success 时：
if api_status == "success":
    data["nickname_roi_base64s"] = roi_png_b64_list  # 多张 ROI
```

### 10.3 Python 伪代码 — 抖音号找回 Worker

完整示例见 **§9.5.3**；以下为与 §10.2 同结构的独立脚本骨架：

```python
# 抖音号找回 Worker — 独立脚本，部署在找回专用云机池
while True:
    # device-info 心跳、proxy 配置与 §10.2 相同，此处省略

    r = requests.get(f"{BASE}/api/v1/task/pop/douyin", params={
        "device_id": NAMES,
        "worker_key": WORKER_KEY,
    }, timeout=30)
    body = r.json()

    if body.get("code") == 40401:
        time.sleep(3)
        continue
    if body.get("code") != 0:
        raise RuntimeError(body)

    task = body["data"]
    if task.get("task_mode") != "douyin_recover":
        raise RuntimeError(f"unexpected task_mode for recover worker: {task.get('task_mode')}")

    task_id = task["task_id"]
    douyin_id = task["douyin_id"]

    ok, phone_prefix, roi_b64 = run_douyin_recover(douyin_id)

    requests.post(
        f"{BASE}/api/v1/task/report",
        params={"worker_key": WORKER_KEY},
        json={
            "task_id": task_id,
            "device_id": NAMES,
            "api_status": "success" if ok else "recover_failed",
            "data": {
                "phone_prefix": phone_prefix,
                "nickname_roi_base64": roi_b64,
            } if ok else {},
        },
        timeout=30,
    ).raise_for_status()
```

---

## 11. 可选：健康检查

Worker 启动时可探测服务是否可用（**无需** worker_key）：

```http
GET /health
```

```json
{
  "status": "ok",
  "mysql": "up"
}
```

---

## 12. 服务端配置参考

| 配置项 | 默认值 | 说明 |
|--------|--------|------|
| `auth.worker_api_key` | — | Worker 鉴权密钥 |
| `import.lock_timeout_seconds` | `120` | pop 后锁定时长（秒） |
| `daemon.interval_seconds` | `30` | 超时锁回收扫描间隔 |
| `daemon.scheduler_refresh_seconds` | `10` | running 批次列表刷新间隔 |
| `server.addr` | `:8080` | 监听地址 |
| `proxy.enabled` | `false` | 是否启用出口代理网关 |
| `proxy.dps.enabled` | `true` | 是否启用私密代理（可与 TPS 同时 true） |
| `proxy.tps.enabled` | `false` | 是否启用隧道代理 |
| `proxy.default_mode` | `dps` | Worker **未传** `proxy_mode` 时的默认（迁移自旧 `proxy.mode`） |
| ~~`proxy.mode`~~ | — | **废弃**；读取时映射到 `default_mode` + enabled 开关 |
| `proxy.pool.enabled` | `false` | 多订单池；开启后使用账户 API 发现订单并 Pick |
| `proxy.pool.account_secret_id` / `account_secret_key` | — | 快代理**账户**级密钥（订单池同步用） |
| `proxy.pool.sync_interval_seconds` | `60` | 后台 SyncWorker 基准间隔（秒）；空闲期自动拉长 |
| `proxy.pool.min_ip_balance` | `10` | DPS `PRE_PAY_IP` Pick 最低 IP 余额 |
| `proxy.pool.tps_load_factor` | `0.8` | TPS 并发上限系数（相对 `tunnel_req`） |
| `proxy.pool.max_pick_retries` | `5` | Pick 失败 failover 次数 |
| `proxy.pool.strategies.dps` | `prefer_high_balance` | DPS 选单策略 |
| `proxy.pool.strategies.tps` | `least_devices` | TPS 选单策略 |
| `proxy.pool.alerts.ip_balance_warn` | `100` | 订单池 IP 余额告警阈值 |
| `proxy.pool.alerts.expire_days_warn` | `7` | 订单临期告警天数 |
| `proxy.default_provider` | `kdl` | 默认 Provider 名称（当前固定走 KDL） |
| `proxy.providers.kdl_dps.secret_id` | — | 私密代理订单 Secret ID |
| `proxy.providers.kdl_dps.signature` | — | 私密代理订单 Signature |
| `proxy.providers.kdl_dps.base_url` | `https://dps.kdlapi.com` | DPS API 地址 |
| `proxy.providers.kdl_dps.default_username` | — | DPS 写入 `connection` 的代理账号（可与 KDL 订单默认账号不同） |
| `proxy.providers.kdl_dps.default_password` | — | DPS 写入 `connection` 的代理密码 |
| `proxy.providers.kdl_dps.timeout_seconds` | `15` | 调用 DPS API 超时（秒） |
| `proxy.providers.kdl_tps.secret_id` | — | 隧道代理订单 Secret ID |
| `proxy.providers.kdl_tps.signature` | — | 隧道代理订单 Signature |
| `proxy.providers.kdl_tps.tunnel_host` | — | 隧道入口（可选；留空时由 `getorderinfo` 自动解析） |
| `proxy.providers.kdl_tps.tunnel_port_http` | `0` | HTTP 隧道端口（可选；留空时由订单详情填充） |
| `proxy.providers.kdl_tps.auth_plaintext` | `true` | 调 `getproxyauthorization` 时是否请求明文账号密码 |
| `proxy.providers.kdl_tps.valid_seconds_default` | `300` | TPS 绑定默认有效秒数（订单换 IP 周期未知时的兜底） |
| `proxy.providers.kdl_tps.timeout_seconds` | `15` | 调用 TPS / dev API 超时（秒） |
| `roi.pass_score` | `97.5` | ROI 命中阈值（`roi_match_score ≥ 此值` 即 passed） |
| `roi.bind_mode` | `copy` | 手机号 import 绑定期望 ROI：`copy`（复制到本 job）或 `reference`（引用源路径） |

配置文件路径：`云机大脑-后端/configs/config.yaml`

**`proxy` 段示例 — 双模式 + 订单池（推荐 v2.0+）：**

```yaml
proxy:
  enabled: true
  default_mode: dps
  dps:
    enabled: true
  tps:
    enabled: false
  default_provider: kdl
  pool:
    enabled: true
    account_secret_id: "your-account-secret-id"
    account_secret_key: "your-account-secret-key"
    sync_interval_seconds: 60
    min_ip_balance: 10
    tps_load_factor: 0.8
    max_pick_retries: 5
    strategies:
      dps: prefer_high_balance
      tps: least_devices
    alerts:
      ip_balance_warn: 100
      expire_days_warn: 7
  providers:
    kdl_dps:
      secret_id: ""          # pool.enabled=true 时可留空，由订单池提供
      signature: ""
      base_url: "https://dps.kdlapi.com"
      default_username: ""
      default_password: ""
      timeout_seconds: 15
    kdl_tps:
      secret_id: ""
      signature: ""
      tunnel_host: ""
      tunnel_port_http: 0
      auth_plaintext: true
      valid_seconds_default: 300
      timeout_seconds: 15
```

**`proxy` 段示例 — 单订单 DPS（未启用订单池）：**

```yaml
proxy:
  enabled: true
  default_mode: dps
  dps:
    enabled: true
  tps:
    enabled: false
  default_provider: kdl
  pool:
    enabled: false
  providers:
    kdl_dps:
      secret_id: "your-dps-secret-id"
      signature: "your-dps-signature"
      base_url: "https://dps.kdlapi.com"
      default_username: "proxy_user"
      default_password: "proxy_pass"
      timeout_seconds: 15
```

**`proxy` 段示例 — 单订单 TPS：**

```yaml
proxy:
  enabled: true
  default_mode: tps
  dps:
    enabled: false
  tps:
    enabled: true
  default_provider: kdl
  pool:
    enabled: false
  providers:
    kdl_tps:
      secret_id: "your-tps-secret-id"
      signature: "your-tps-signature"
      tunnel_host: ""              # 可选，留空则 getorderinfo 自动填充
      tunnel_port_http: 0
      auth_plaintext: true
      valid_seconds_default: 300
      timeout_seconds: 15
```

> **旧配置兼容：** 若 YAML 仍使用顶层 `mode: dps|tps` 或 `providers.kdl`，启动时会自动映射到 `default_mode` + `dps/tps.enabled`，并合并到 `providers.kdl_dps`。
>
> 管理端可在 **系统配置 → 出口代理** Tab，或 **云机集群看板 → 出口代理配置** 弹窗中配置 **DPS/TPS 独立开关**、`default_mode`、订单池与凭证；保存后走 `PUT /api/v1/admin/config` **热更新**，无需重启。
>
> 代理绑定持久化于表 `device_proxy`（`migrations/008`）；v1.9 增加 `proxy_mode`、`egress_ip`（`015`）；**v2.0** 改为 **`uk_device_mode (device_id, proxy_mode)`** 双行并存，并增加 `kdl_order_id`（`017`）。订单池快照见 `kdl_order_pool`（`016`～`019`）。

---

## 13. 常见问题

### Q1：pop 一直返回 `40401 no_task`？

- 确认任务批次状态为 `running`（管理端任务大厅可见）。
- 确认云机调用的是**对应专用接口**：手机号池用 `/task/pop/phone`，抖音号池用 `/task/pop/douyin`。
- 可能 ES 缓存阶段已全部命中/跳过（手机号模式），或任务已 `succeeded`。
- 未指定 `job_id` 时，确认至少有一个**同模式**的 `running` 批次。

### Q2：report 成功但抖音号仍显示筛查中？

- 未命中且仍有候选号：记录会回到 pending，继续 pop 下一个。
- 仅当 **命中** 或 **候选耗尽** 时，抖音号才会 passed / failed。

### Q3：`api_register_time` / `api_nickname_first` 还要填吗？

- **`api_register_time`**：**不需要**，云机脚本 **不要上报**。
- **`api_nickname_first` / `api_nickname_firsts`**：**仅 `phone_screen`（手机号筛查）不要求**。该模式命中 **仅看 ROI**；若上报首字字段，服务端 **忽略**。
- **`douyin_recover`**：本模式 **从未要求** 昵称首字；仍须 `phone_prefix` + `nickname_roi_base64`。
- `1011` 表示用户不存在，**不要求** ROI 与首字。

### Q14：ROI 绑定 / 抖音号找回为什么必须传 base64？不传会怎样？

- **手机号筛查**（§6.3）：`api_status=success` 时 **必须** 提供 `nickname_roi_base64` 或非空 `nickname_roi_base64s[]`；缺 ROI 则 **HTTP 400**，`msg`: `nickname_roi_base64 required for ROI-bound record`。
- **抖音号找回**（§6.4）：`success` / `recover_success` 时 **必须** 同时提供 `phone_prefix` + `nickname_roi_base64`；缺一即 **HTTP 400**。
- base64 须解码为 **75×45 PNG**；格式/尺寸错误同样 **HTTP 400**（见 §6.8）。
- **用户不存在**（`error_1011`）时 **不要** 传 ROI，也不要伪造昵称首字。

### Q18：找回时只有昵称首字、没有 ROI 小图，能否用合成图或文本首字做高精度筛查？

- **不能。** 命中闸门 **仅** 为找回真实截图 ROI ↔ 手机号真实截图 ROI（评分 + 硬门槛）。详见 [手机号模式-昵称ROI比对方案.md](./手机号模式-昵称ROI比对方案.md) **§14**。
- **禁止**：服务端/客户端用首字字体渲染合成「假期望 ROI」；禁止恢复 Legacy「首字文本相等即命中」。
- **正确做法**：对该 uid **补采**——重跑 `douyin_recover`，强制上报 75×45 `nickname_roi_base64`，再 export `account_unique_key` 并导入手机号任务。
- 算法中的「首字区」是图像左侧约 35% 的二值相似度，**不是** OCR/文本字符「张」。
- 二期可选：对实际上报 ROI 左区做 OCR 与找回首字 **审计**（方案 §15），**不参与** `HitPass`；Worker 协议不变。

### Q7：一个手机号绑了两个抖音，只上报一张 ROI 怎么办？

- **手机号筛查**：推荐上报 `nickname_roi_base64s[]` 多张 ROI；服务端取 **max(roi_match_score)** 与阈值比较。
- **无需** 上报 `api_nickname_firsts`；首字字段不参与判定，管理端也不再展示。

### Q11：手机号 Worker 和抖音号 Worker 可以写成同一个脚本吗？

- **不推荐。** 本项目约定 **两套独立脚本 + 两个领取接口 + 两个云机池**（见 **§1.1**）。
- 技术上可在同一脚本内分支，但会增加维护成本。
- **推荐做法**：手机号池固定 `GET /task/pop/phone`，抖音号池固定 `GET /task/pop/douyin`；日常**无需**配置 `job_id`。

### Q12：抖音号找回 Worker pop 一直 `40401`？

- 确认管理端已导入 **抖音号找回** 批次且状态为 `running`。
- 确认 pop 地址为 **`/task/pop/douyin`**（不是 `/task/pop/phone` 或旧版 `/task/pop`）。
- 若线上只有手机号筛查批次在跑，抖音号池会收到 `40401`（**不会**误领手机号任务）——属正常现象，待抖音批次启动即可。
- `nickname_roi_base64` 须为 **75×45 PNG**；尺寸或格式错误会 report 失败。

### Q13：每次上传任务都要配置 `job_id` 吗？

- **不需要。** 专用 pop 接口已按 `job_mode` 过滤调度；管理端上传并启动后，对应云机池自动领取。
- `job_id` 仅用于**调试**或强制指定单批次（可选 Query 参数）。

### Q4：pop 后 60 秒才 report 有没有问题？

- 默认锁 120 秒，60 秒内 **安全**。
- 超过锁定时长会被回收，号码可能被其他 Worker 再次领取。

### Q5：同一 `task_id` 可以 report 两次吗？

- 可以，第二次起服务端幂等返回成功，不重复改判。

### Q6：Worker 需要 JWT 吗？

- **不需要**。Worker 接口仅使用 `worker_key`，与用户登录 JWT 无关。

### Q8：`state=running` 和 Worker「活跃」是一回事吗？

- **不是。** `device-info` 中的 `state=running` 表示云手机**已开机**。
- Worker「活跃/处理中/空闲」由 pop/report 时间窗判定，表示筛查脚本是否在接任务。
- 云机已开机但脚本未启动时，可能出现 `running` + Worker `idle`。

### Q9：device-info 没按时上报，但 pop/report 还在跑，会被判关机吗？

- **不会。** 后端会以 pop/report 活动作为兜底：脚本仍在领任务/上报时，推断实例**仍在线、已开机**（`info_inferred=true`）。
- 仅当心跳超时 **且** pop/report 均已停滞时，才显示「信息离线」。
- **云机无需为此增加任何额外请求或逻辑。**

### Q10：`50301 proxy_unavailable` 时还能 pop 吗？

- **不建议。** 应先重试 `GET /worker/proxy` 直至拿到可用 `connection`，再配置本地代理后 pop。
- 代理网关关闭（`proxy.enabled=false`）时，本接口恒返回 `50301`；若业务不需要出口代理，可跳过该接口直接 pop。

### Q11：管理端点「检测」提示尚未分配代理？

- **检测**仅针对 `device_proxy` 表中**已有绑定**的记录；未分配时返回 `400`，msg 为「该实例尚未分配出口代理…」。
- 处理方式：云机先调用 `GET /worker/proxy`，或管理端在实例抽屉点击 **「换 IP」** 主动分配。
- 看板抽屉中无 `proxy_id` / 出口代理列为 `-` 时，「检测」按钮应置灰（前端已做禁用与 Tooltip 提示）。

### Q15：云机需要直连快代理吗？

- **不需要。** 云机只调用调度大脑 `GET /worker/proxy?proxy_mode=...`；后端按请求模式内部对接 KDL：
  - **DPS**：`getdps` / `checkdpsvalid` / `getdpsvalidtime`
  - **TPS**：`getorderinfo` / `getproxyauthorization` / `tpscurrentip` / `changetpsip`
- 快代理原始 API 说明见 [快代理-私密代理API对接示例](./快代理-私密代理API对接示例.md)、[快代理-隧道代理对接文档](./快代理-隧道代理对接文档.md)（供运维/后端开发参考，非 Worker 必读本）。

### Q16：TPS 模式下「换 IP」会影响其他云机吗？

- **会。** TPS 使用 **共享隧道**；`refresh=1` 或管理端「换 IP」会调用 `changetpsip`，更换的是 **整条隧道的出口 IP**，而非单台 `device_id` 独占 IP。
- 同一隧道订单下的所有云机实例会 **同时** 获得新出口 IP；请勿在高并发场景对多台云机连续点「换 IP」。
- DPS 模式下换 IP 为 **按实例重新 getdps**，互不影响（仍受代理商提取频率限制）。

### Q17：响应里的 `kdl_order_id` / `remaining_seconds` 是什么？订单池和 Worker 有关系吗？

- **`kdl_order_id`**：启用 `proxy.pool.enabled` 时，后端从订单池 Pick 的快代理订单号，便于日志与运维对照 **订单池看板**；Worker **无需**据此改请求参数。
- **`remaining_seconds`**：**当前这次绑定的出口** 剩余可用秒数（DPS≈单 IP 可用时长，TPS≈换 IP 周期），**不是**订单级「IP 套餐总分钟数」。接近 0 时可 `refresh=1` 换新绑定。
- **订单池** 仅影响后端选哪张 KDL 订单出代理；Worker 仍只调 `GET /worker/proxy?proxy_mode=...`，与是否启用订单池无关。

---

## 14. 联调脚本

后端仓库提供 E2E 脚本，含 pop + report 完整流程：

```bash
cd 云机大脑-后端
make run          # 终端 1
./scripts/e2e_test.sh   # 终端 2
```

环境变量：

| 变量 | 默认 |
|------|------|
| `BASE_URL` | `http://127.0.0.1:8080` |
| `WORKER_KEY` | `local-e2e-worker-key` |
| `DEVICE` | `e2e_device_001` |

---

## 15. 相关文档

| 文档 | 说明 |
|------|------|
| [云机大脑-后端 README](../云机大脑-后端/README.md) | 部署、全量 API 索引 |
| [快代理-私密代理API对接示例](./快代理-私密代理API对接示例.md) | KDL DPS 原始 API（后端内部调用，Worker 无需直连） |
| [快代理-隧道代理对接文档](./快代理-隧道代理对接文档.md) | KDL TPS 原始 API 与调度大脑集成说明（§9） |
| [手机号模式-昵称ROI比对方案](./手机号模式-昵称ROI比对方案.md) | ROI 评分、账号唯一值、**§14 禁止首字合成期望图** |
| [出口代理网关-多订单池优化方案](./出口代理网关-多订单池优化方案.md) | 订单池架构、SyncWorker、Pick 策略（运维/后端） |
| [WORKER_PROXY_FETCH_FOR_BACKEND.md](./WORKER_PROXY_FETCH_FOR_BACKEND.md) | 云机 StepPro 脚本实际请求与排查对照 |
| [抖音号手机号筛查系统-Go-MySQL-v1开发文档](./抖音号手机号筛查系统-Go-MySQL-v1开发文档.md) | 系统架构与数据模型 |
| [云机大脑-前端页面开发与设计详细方案](./云机大脑-前端页面开发与设计详细方案.md) | 管理端 UI（非 Worker 对接） |

---

## 16. 管理端看板 API（Admin，JWT）

管理端按 **宿主机 IP 分组** 展示云机集群，默认仅加载摘要，展开 IP 后懒加载实例明细。需 Header：`Authorization: Bearer {admin_jwt}`，且用户 `role=admin`。

| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/v1/admin/workers/hosts` | 宿主机摘要列表 |
| GET | `/api/v1/admin/workers/hosts/{ip}/devices` | 指定 IP 下实例明细（含出口代理摘要） |
| PATCH | `/api/v1/admin/workers/hosts/{ip}` | 更新宿主机别名/备注 |
| GET | `/api/v1/admin/config` | 读取系统配置（含 `proxy` 段） |
| PUT | `/api/v1/admin/config` | 保存系统配置（含出口代理，热更新） |
| GET | `/api/v1/admin/workers/devices/{names}/proxy` | 查询实例当前代理绑定 |
| POST | `/api/v1/admin/workers/devices/{names}/proxy/check` | 检测代理有效性（调 KDL check） |
| POST | `/api/v1/admin/workers/devices/{names}/proxy/refresh` | 强制换 IP（等同 Worker `refresh=1`） |
| GET | `/api/v1/admin/proxy/pool/stats` | 订单池统计（DPS/TPS 可用数等） |
| GET | `/api/v1/admin/proxy/pool/orders` | 订单池列表（Query `product=DPS\|TPS`） |
| GET | `/api/v1/admin/proxy/pool/alerts` | 订单池分组告警 |
| POST | `/api/v1/admin/proxy/pool/sync` | 立即同步订单池 |
| PATCH | `/api/v1/admin/proxy/pool/orders/{order_id}` | 调整权重 / 禁用订单 |
| GET | `/api/v1/admin/workers` | 旧版扁平 Worker 列表（兼容） |

> 路径中的 `{names}` 为 URL 编码后的云机实例名（与 Worker `device_id` 相同），例如 `a3a5a0b3e1591722075c5c95da8dab20_1_T1001`。

### 16.1 宿主机备注 — `PATCH .../hosts/{ip}`

**Body 示例：**

```json
{
  "alias": "机房A-03号机",
  "remark": "40 槽主力筛查机"
}
```

### 16.2 实例明细中的代理字段

`GET /api/v1/admin/workers/hosts/{ip}/devices` 返回的每个 `devices[]` 项包含 **DPS / TPS 双行摘要**（v2.0+）：

| 字段 | 说明 |
|------|------|
| `proxy_dps` | 私密代理绑定摘要（未分配时各子字段为空） |
| `proxy_tps` | 隧道代理绑定摘要 |
| `proxy_dps.proxy_connection` / `proxy_tps.proxy_connection` | 脱敏 `host:端口:账号:密码`（DPS=出口 IP，TPS=隧道入口） |
| `proxy_dps.proxy_valid_until` / `proxy_tps.proxy_valid_until` | 绑定过期时间 |
| `proxy_dps.proxy_remaining_seconds` / `proxy_tps.proxy_remaining_seconds` | 剩余有效秒数 |
| `proxy_dps.proxy_last_check_ok` / `proxy_tps.proxy_last_check_ok` | 最近一次检测是否通过 |
| `proxy_tps.egress_ip` | TPS 当前出口 IP（展示用） |

**兼容字段（等同 `proxy_dps`，便于旧前端）：** `proxy_id`、`proxy_connection`、`proxy_status`、`proxy_valid_until`、`proxy_remaining_seconds`、`proxy_last_check_ok`。

未分配时上述字段为空；完整 `ProxyDTO`（含 `proxy_mode`、`kdl_order_id`）见 **§16.3** `GET .../proxy`。看板抽屉按模式 **检测** / **换 IP**。

### 16.3 查询实例代理 — `GET .../devices/{names}/proxy`

| Query | 说明 |
|-------|------|
| `proxy_mode` | `dps` \| `tps`；双模式均开启时必填（规则同 §4.5.1） |

**成功 — 已绑定（DPS 示例）：**

```json
{
  "code": 0,
  "data": {
    "proxy_id": "a1b2c3...",
    "device_id": "a3a5a0b3e1591722075c5c95da8dab20_1_T1001",
    "host": "113.31.123.45",
    "port": 15818,
    "username": "user001",
    "password": "pass001",
    "addr": "113.31.123.45:15818",
    "connection": "113.31.123.45:15818:user001:pass001",
    "scheme": "http",
    "status": "active",
    "proxy_mode": "dps",
    "kdl_order_id": "987807746559625",
    "valid_until": "2026-06-12T10:30:00+08:00",
    "remaining_seconds": 1800,
    "last_check_ok": true,
    "provider": "kdl"
  }
}
```

TPS 模式下 `data` 还会包含 `proxy_mode: "tps"`、`egress_ip`（当前出口 IP），且 `host` / `connection` 为隧道入口（结构同 §4.5.2）。

**成功 — 未绑定：** `data` 为 `null`（HTTP 200）。

### 16.4 检测代理 — `POST .../devices/{names}/proxy/check`

无 Body。后端按绑定记录的 `proxy_mode` 调用对应 KDL 接口，并更新 `last_check_at` / `last_check_ok`：

| 模式 | 内部 API | 说明 |
|------|----------|------|
| `dps` | `checkdpsvalid` | 校验当前绑定的出口 IP 是否仍有效 |
| `tps` | `tpscurrentip` | 查询隧道当前出口，与库中 `egress_ip` 比对 |

**成功：**

```json
{
  "code": 0,
  "msg": "checked",
  "data": { "...": "同 GET proxy 的 data 结构" }
}
```

**失败 — 尚未分配：**

```json
{
  "code": 400,
  "msg": "该实例尚未分配出口代理，请云机先调用 GET /worker/proxy 或由管理员点击「换 IP」"
}
```

### 16.5 强制换 IP — `POST .../devices/{names}/proxy/refresh`

无 Body。行为等同 Worker 侧 `GET /worker/proxy?proxy_mode={mode}&refresh=1`，按 Query **`proxy_mode`**（或 `default_mode`）执行：

| 模式 | 内部 API | 影响范围 |
|------|----------|----------|
| `dps` | 重新 `getdps` | 仅更新当前 `device_id` 绑定 |
| `tps` | `changetpsip` + `tpscurrentip` | **整条隧道**出口 IP 变更，共享隧道的实例均受影响（见 §13 Q16） |

成功后 upsert 到 `device_proxy`。

**成功：**

```json
{
  "code": 0,
  "msg": "refreshed",
  "data": { "...": "新分配的 ProxyDTO" }
}
```

**代理不可用：**

```json
{
  "code": 50301,
  "msg": "proxy_unavailable",
  "data": null
}
```

### 16.6 出口代理网关配置（Admin）

通过 `GET/PUT /api/v1/admin/config` 读写 `config.proxy` 对象，字段与 §12 一致。前端入口：

- **系统配置** 页 → 「出口代理」Tab：**DPS / TPS 独立开关** + `default_mode` + 订单池配置 + 凭证分块
- **出口代理 · 订单池** 看板（`/admin/proxy-pool`）：同步、告警、订单权重/禁用
- **云机集群看板** 页头 → 「出口代理配置」弹窗（同上）；抽屉内 **DPS/TPS 分列** 展示代理，可选 `proxy_mode` 后 **检测** / **换 IP**

保存后即时生效（热更新）。关闭某模式后该模式请求返回 `50301`；关闭 `proxy.enabled` 后 Worker 全部返回 `50301`。

---

## 17. 变更记录

| 日期 | 版本 | 说明 |
|------|------|------|
| 2026-07-02 | v2.0.2 | FAQ **Q18**：仅有昵称首字不可筛查；禁止合成期望 ROI；交叉引用 ROI 方案 §14/§15；§6.3/§6.4 补采说明 |
| 2026-07-02 | v2.0.1 | 补全订单池、`kdl_order_id`、`proxy_mode required`、§12 双模式 YAML、Admin 订单池 API、看板 `proxy_dps`/`proxy_tps` 双列、Q17 |
| 2026-07-02 | v2.0.0 | **双模式并行**：`dps.enabled` + `tps.enabled`；Worker Query `proxy_mode`；`device_proxy` 双行 `uk_device_mode`；多订单池；Admin 代理操作带 `proxy_mode` |
| 2026-06-25 | v1.9.0 | **快代理双模式**：`proxy.mode` 支持 `dps` 私密代理 / `tps` 隧道代理；Worker 响应新增 `proxy_mode`、`egress_ip`；TPS 下 `connection` 为隧道入口；§12 配置拆分为 `kdl_dps` / `kdl_tps`；Admin 检测/换 IP 分模式说明；迁移 `015_device_proxy_mode.sql` |
| 2026-06-21 | v1.8.0 | **废弃 Legacy 首字比对**：**仅 `phone_screen`** 不要求 `api_nickname_first*`；命中 **仅 ROI**；管理端剔除首字展示；缓存预扫仅保留 `user_not_exists` 跳过；§7.4 对齐 V2 硬门槛与评分公式 |
| 2026-06-18 23:00 | v1.7.1 | **§6.3 修订**（已被 v1.8 取代）：曾要求 ROI 绑定同时上报首字供管理端展示 |
| 2026-06-18 | v1.7.0 | **§6 重写**：按 `phone_screen` / `douyin_recover` 分列 **强制上报字段**；明确 ROI 绑定 / 抖音找回 **success 时必须 PNG base64**；补全 HTTP 400 `msg` 列表；§6.5 管理端 ROI 回写字段；§7.4 缓存预扫跳过 ROI 记录 |
| 2026-06-18 | v1.6.0 | **ROI 昵称比对**：手机号 ROI 绑定 report 须上报 `nickname_roi_base64`；命中阈值 **97.5**；抖音 export 含 `account_unique_key` |
| 2026-06-18 | v1.5.0 | 新增双池专用领取接口 `GET /task/pop/phone`、`GET /task/pop/douyin`；按 `job_mode` 隔离调度；`job_id` 改为可选；旧 `/task/pop` 保留兼容 |
| 2026-06-17 | v1.4.1 | 明确双 Worker 脚本架构（§1.1）；扩充抖音号找回专章、cURL/Python 示例与 FAQ |
| 2026-06-12 | v1.4.0 | 新增抖音号找回模式 `douyin_recover`：pop 下发 `douyin_id`；report 上报 `phone_prefix` + 75×45 PNG base64 |
| 2026-06-12 | v1.3.1 | 补全 proxy：`connection` 解析、cURL/Python 示例、`proxy` 配置项、Admin 代理 API 详情与 FAQ |
| 2026-06-12 | v1.3 | 新增出口代理网关 `GET /worker/proxy`；Admin 代理检测/换 IP；看板展示代理列 |
| 2026-06-12 | v1.2.1 | 心跳缺失时以 pop/report 兜底推断在线/已开机，避免误判关机 |
| 2026-06-12 | v1.2 | 新增 `device-info` 心跳；`device_id=names`；看板按 IP 分组；区分实例开机状态与 Worker 活跃 |
| 2026-06-12 | v1.1.1 | 看板可见条件说明；pop 未 report 亦可在看板展示为「处理中」 |
| 2026-06-10 | v1.1 | 支持 `api_nickname_firsts` 一号多抖音；命中改为任一首字匹配；任务详情展示多首字 |
| 2026-06-09 | v1.0 | 首版：pop/report、鉴权、命中规则（仅昵称首字）、锁超时 |
