# 车况查询开放接口 v1

面向渠道（微擎站长、代理商）的接口。渠道用平台下发的 `AppKey` / `AppSecret` 调用本接口，
平台再去调上游数据源。**上游密钥只存在平台服务器上，渠道永远拿不到。**

- 接口根地址：`https://www.ttzy.net`
- 全部接口都是 `POST`，路径形如 `/open/v1/query`
- 请求体统一 `application/json`（UTF-8）
- 返回统一是 `{"code":0,"msg":"ok","data":{...}}`，**成功看的是 `code=0`**
- 哪些接口能调、每次扣多少钱，见下方「接口清单」或登录后「接口目录」

> 一个坑先说在前面：**HTTP 状态码永远是 200**，出错时也是 200。
> 所以判断成功必须看响应体里的 `code`，不能只看 HTTP 状态码。

---

## 一、接入三步

1. **拿密钥**：平台开通代理后会把账号密码和 `AppKey` / `AppSecret` 一起发你；
   也可以自助注册，登录代理后台在「接口凭据」里看。
2. **填到模块里**：微擎模块后台 →「车况查询 · 设置」→ 填接口地址 `https://www.ttzy.net`、
   AppKey、AppSecret → 保存，再点「测试连通」。
3. **按工具码发起查询**：`/open/v1/ping` 通了以后，用 `/open/v1/query` 传 `tool` + `params`。
   异步工具（比如车牌查车架号）拿 `request_id` 调 `/open/v1/fetch` 取结果。

## 二、签名

每次请求带 4 个请求头：

| 请求头 | 说明 |
|---|---|
| `X-AppId` | 平台给你的 AppKey |
| `X-Timestamp` | 秒级时间戳，与服务器时间相差超过 **300 秒**会被拒 |
| `X-Nonce` | 随机串，建议 16 位以上（如 `bin2hex(random_bytes(8))`）。**同一个 nonce 只能用一次**，重复会被判为重放 |
| `X-Sign` | 签名，小写 32 位 MD5 |

签名算法（注意 `md5(body)` 是**请求体的原始字节**，不是重新 json_encode 一遍）：

    sign = md5( appKey + timestamp + nonce + md5(请求体原文) + appSecret )

几个容易踩的点：

- 拼接顺序固定，中间**没有任何分隔符**，最后整体做一次 md5。
- `md5(请求体原文)`：发出去什么字节就算什么字节。没有请求体时按 `md5('')` 算。
- 时间戳是**秒**，不是毫秒；服务器时间要校准（装了 NTP 就不用管）。
- 四个头都要带，少一个直接 401。

## 三、接口清单

| 接口 | 路径 | 用途 |
|---|---|---|
| 连通测试 | `POST /open/v1/ping` | 验密钥、看余额，接完第一步就调它 |
| 工具清单 | `POST /open/v1/tools` | 当前可调的工具 + 你的进货价 |
| 发起查询 | `POST /open/v1/query` | 主要接口，一次查询扣一次费 |
| 取异步结果 | `POST /open/v1/fetch` | 异步工具取结果，只读、不重复扣费 |
| 查余额 | `POST /open/v1/balance` | 余额、今日用量、每日上限 |
| 调用流水 | `POST /open/v1/calls` | 最近调用记录（查询对象已脱敏） |

> 当前开放了哪些工具、每个要传什么参数，以 `/open/v1/tools` 的返回为准 ——
> 平台上下架工具会跟着变，文档不重复列，免得写的和实际能调的对不上。
> 网页版清单见 [接口清单](/docs/)。

## 四、接口详情

### 1. 连通测试 /open/v1/ping

请求体：`{}`

    {"code":0,"msg":"连通正常","data":{"channel":"某微擎站点","balance":"1000.00","server_time":"2026-09-22 10:00:00"}}

返回 401 一般是：AppSecret 填错、时间戳不对、或者 nonce 重复用了。

### 2. 工具清单 /open/v1/tools

请求体：`{}`

    {"code":0,"msg":"ok","data":{"list":[
      {"tool_code":"vehicle_five","name":"车辆五项查询","cost":"6.00","retail_tip":"8.00",
       "params":["vin"],"async":false,"custom":false}
    ]}}

| 字段 | 含义 |
|---|---|
| `tool_code` | 工具码，调 `/query` 时传它 |
| `name` | 中文名，给终端用户看的话可以直接用 |
| `cost` | **每次调用扣你的钱**（这就是你的进货价） |
| `retail_tip` | 平台建议零售价，仅供参考，卖多少你自己定 |
| `params` | 这个工具要传哪些参数，见下方「参数说明」 |
| `async` | `true` = 上游要等一会儿，出结果要用 `/fetch` 取 |
| `custom` | `true` = 这是平台单独给你的协议价（不是默认价） |

### 3. 发起查询 /open/v1/query

    {"tool":"vehicle_five","params":{"vin":"LSVAM4185E2109876"}}

`params` 里的键就是 `/tools` 返回的 `params`；车架号请传 17 位完整值，程序会自动转大写、去空格。

返回：

    {"code":0,"msg":"ok","data":{
      "status":"done","tool":"vehicle_five","tool_name":"车辆五项查询",
      "fee":"6.00","refunded":"0.00","balance":"994.00","request_id":"",
      "msg":"ok",
      "data":{"品牌":"大众","车架号":"LSVAM4185E2109876","车型":"...","发动机号":"...","燃料种类":"汽油"}
    }}

`data.status` 有三种，**必须按它分支**：

| status | 含义 | 钱怎么算 | 你要做什么 |
|---|---|---|---|
| `done` | 查到数据了，`data.data` 就是结果 | 扣费 | 存下来给用户看 |
| `nodata` | 这台车查不到数据（老车 / 未收录） | **已自动全额退回** | 提示用户「查无数据」，别收用户的钱 |
| `pending` | 上游还在出结果 | 先扣 | 拿 `request_id` 调 `/fetch`，出不来结果会退 |

`nodata` 示例：

    {"code":0,"msg":"查无数据","data":{"status":"nodata","fee":"6.00","refunded":"6.00","balance":"1000.00","data":{}}}

工具不存在、参数缺失、余额不够时返回 `code` 非 0，`data` 为 `null`，具体见「错误码」。

### 4. 取异步结果 /open/v1/fetch

    {"request_id":"1276874656273911808"}

返回结构和 `/query` 一样，`status` 可能是 `pending` / `done` / `nodata` / `fail`。
建议 **每 5~10 秒取一次，最多取 3~5 分钟**，别拿 1 秒的间隔去轮询。

`/fetch` 是只读接口，反复取**不会重复扣费**，也不会重复退款：

| 情况 | 返回 | 说明 |
|---|---|---|
| 上游还在出结果 | `pending` | 继续等 |
| 上游偶发 5xx / 连不上 | `pending` | 不是失败，过会儿再取 |
| 出结果了 | `done` + 数据 | 结果存在平台，再取一次原样返回 |
| 上游超过有效期（一般 60 分钟）还没出结果 | `fail` + 全额退款 | 之后一直回 `fail`，不会重复退 |
| 已经结束的单子再取 | 原样回最终状态 | 不报错、不动钱，免得你的订单一直卡在「等待中」 |

### 5. 查余额 /open/v1/balance

    {"code":0,"msg":"ok","data":{"balance":"994.00","today_used":12,"daily_limit":0}}

- `balance`：可用余额（元，字符串）
- `today_used`：今天已调用次数
- `daily_limit`：每日上限，`0` 表示不限；到上限后 `/query` 返回 `429`

### 6. 调用流水 /open/v1/calls

    {"page":1,"size":20}

返回：

    {"code":0,"msg":"ok","data":{"page":1,"size":20,"total":1,"list":[
      {"id":1,"tool_code":"vehicle_five","query":"LSV**********76","fee":"6.00","refund":"0.00",
       "status":1,"status_text":"成功","msg":"ok","ms":640,"created_at":"2026-09-22 10:00:00"}
    ]}}

- `size` 最大 100
- `status`：`0` 处理中 / `1` 成功 / `2` 失败 / `3` 已退款 / `4` 等上游出结果
- `query` 是脱敏后的查询对象，**完整车架号不返回给渠道**

## 五、参数说明

工具要传哪些参数由 `/tools` 的 `params` 决定，常用的有：

| 参数名 | 含义 | 说明 |
|---|---|---|
| `vin` | 车架号 | 17 位，不含字母 `I` `O` `Q`；传进来会转大写、去空格和横杠 |
| `plate` | 车牌号 | 例如 `京A12345`，会转大写、去空格 |
| `engine` | 发动机号 | 少数工具（如 4S 维保）需要 |
| `mobile` | 手机号 | 手机号在网状态这类工具用 |
| `url_image` | 图片地址 | 行驶证识别这类工具用，传可公网访问的图片 URL |
| `invoice_no` | 发票号码 | 发票查验用，**必填** |
| `invoice_date` | 开票日期 | 发票查验用，**必填**，格式 `2020-01-01` |
| `invoice_code` | 发票代码 | 发票查验用，选填（全电发票没有代码，不传即可） |
| `invoice_amount` | 价税合计 | 发票查验用，选填 |
| `invoice_check` | 校验码 | 发票查验用，选填。**传完整 20 位也行，程序会自动只取后六位** |
| `invoice_file` | 发票文件 | PDF/OFD 查验用，传文件内容的 base64 |

传少了会返回 `400 缺少参数 xxx`；车架号格式不对会返回 `400 车架号格式不正确`。

> 「选填」的参数，工具定义里标了 `_optional`，不传不会报 400。
> 发票查验的规则是：**金额、价税合计、校验码三者至少填一个**，都不填上游会回
> `合计金额、价税合计和校验码都为空`，这种情况平台按查无数据处理、**自动退款**。

## 六、错误码

判断成功只看 `code`，`0` 才算成功。

| code | 含义 | 怎么处理 |
|---|---|---|
| 0 | 成功 | 按 `data.status` 分支 |
| 400 | 参数错误（缺参数、车架号格式不对） | 检查 `tool` 和 `params`，别拿原参数重试 |
| 401 | 签名 / 时间戳 / nonce 有问题 | 核对 AppSecret、服务器时间；nonce 要每次新生成 |
| 402 | 余额不足 | 去代理后台充值；充值到账后恢复 |
| 403 | 渠道被停用 | 联系平台 |
| 404 | 工具不存在或已下架、request_id 找不到 | 重新拉 `/tools` 再调 |
| 405 | 用了 GET | 改 POST |
| 429 | 超过每日调用上限 | 明天再试或找平台提额 |
| 500 | 服务器或上游异常 | 稍后重试；这一笔已扣的钱会自动退回 |

## 七、计费与退款

1. **先扣后调**：发起查询时按工具 `cost` 从余额扣，余额不够直接返回 `402`，不会去调上游
2. **查不到就退**：`nodata` / `fail` 全额退回，余额明细里有一笔「退款」
3. **异步只扣一次**：`pending` 时扣一次，之后 `/fetch` 不再扣钱；最终没出结果也退
4. **不会重复扣费、也不会重复退款**：取结果是只读的，平台侧做了幂等
5. 余额、扣费、退款都能在 `/open/v1/balance`、`/open/v1/calls` 和后台「余额明细」里对上

## 八、接入示例

### PHP

    <?php
    $appKey    = '你的 AppKey';
    $appSecret = '你的 AppSecret';
    $base      = 'https://www.ttzy.net';

    function ocCall(string $path, array $payload, string $appKey, string $appSecret, string $base): array
    {
        // 注意：签名用的一定是「将要发出去的这串字节」，别在签名后又改 body
        $body      = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        $timestamp = (string) time();
        $nonce     = bin2hex(random_bytes(8));
        $sign      = md5($appKey . $timestamp . $nonce . md5($body) . $appSecret);

        $ch = curl_init($base . $path);
        curl_setopt_array($ch, [
            CURLOPT_POST           => true,
            CURLOPT_POSTFIELDS     => $body,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT        => 30,
            CURLOPT_HTTPHEADER     => [
                'Content-Type: application/json',
                'X-AppId: ' . $appKey,
                'X-Timestamp: ' . $timestamp,
                'X-Nonce: ' . $nonce,
                'X-Sign: ' . $sign,
            ],
        ]);
        $raw = curl_exec($ch);
        curl_close($ch);

        return json_decode((string) $raw, true) ?: ['code' => -1, 'msg' => '响应不是 JSON'];
    }

    // 1) 连通
    var_dump(ocCall('/open/v1/ping', [], $appKey, $appSecret, $base));

    // 2) 查一台车
    $res = ocCall('/open/v1/query', [
        'tool'   => 'vehicle_five',
        'params' => ['vin' => 'LSVAM4185E2109876'],
    ], $appKey, $appSecret, $base);

    if (($res['code'] ?? -1) === 0 && $res['data']['status'] === 'pending') {
        // 3) 异步：过 5~10 秒再来取
        $one = ocCall('/open/v1/fetch', ['request_id' => $res['data']['request_id']], $appKey, $appSecret, $base);
    }

### curl（排障用）

签名要算，所以先用 PHP/脚本算好再填进来：

    curl -s -X POST 'https://www.ttzy.net/open/v1/ping' \
      -H 'Content-Type: application/json' \
      -H 'X-AppId: 你的AppKey' \
      -H 'X-Timestamp: 1758500000' \
      -H 'X-Nonce: 3f9a1c7b2e8d4056' \
      -H 'X-Sign: 算出来的签名' \
      -d '{}'

## 九、常见问题

**Q：一直返回 401 签名不正确？**
按顺序查：AppSecret 有没有多余空格 → 时间戳是不是秒 → nonce 是不是这次新生成的 →
签名的 body 和实际发出去的 body 是不是同一串字节（用 JSON 库重新序列化过就不一样了）。

**Q：`pending` 一直不出结果怎么办？**
`/fetch` 每 5~10 秒取一次，超过工具有效期（一般 60 分钟）平台会自动判失败并退款，
你不用自己处理超时。

**Q：`nodata` 是不是代表接口坏了？**
不是。10 年以上的老车、进口车、信息没收录的车都会 `nodata`，平台不收费，钱已退回。

**Q：能不能把车架号完整存到自己库里给用户看？**
可以，你查出来的结果归你。但平台侧给渠道的接口和后台一律脱敏，
不存在「平台的接口把你客户的车架号泄出去」这件事。

**Q：一次请求能查多台车吗？**
不能。一个 `request_id` / 一次调用对应一台车，批量请自己循环，注意限流和余额。

**Q：接口地址能换吗？**
不能。模块里默认写的就是 `https://www.ttzy.net`，改了就调不通。

---

平台开通代理后可自助查看：接口凭据、接口目录（含你的进货价）、余额明细、调用记录、充值。
文档正文只有这一份，网页版（[/docs/](/docs/)）和这里的内容永远一致。
