# 任务大厅 & 任务详情 —— 对外开放接口文档

> **版本**：v1.0  
> **最后更新**：2026-06-30
> **测试环境-基础URL**：`https://api.zjmjyy.com/api/api-platform`

---

## 目录

1. [认证方式（验签规则）](#认证方式验签规则)
2. [接口一：任务大厅列表](#接口一任务大厅列表)
3. [接口二：任务详情](#接口二任务详情)
4. [通用响应结构](#通用响应结构)
5. [错误码说明](#错误码说明)
6. [附录：Java 签名实现参考](#附录java-签名实现参考)

---

## 认证方式（验签规则）

### 概述
 
本文档仅开放 [接口一：任务大厅列表] [接口二：任务详情]对外接口**免登录** ，                                          
但必须通过 **签名验证**。每个请求需携带 3 个自定义 Header，服务端会校验合法性。
平台其它所有接口都需要**登录** 

### 前置条件

联系平台管理员获取以下凭证（由平台分配并录入 `api_platform_info` 表）：

| 参数 | 说明 | 示例 |
|------|------|------|
| `appKey` | 平台身份标识 | `（填你的 appKey）` |
| `appPublicKey` | 签名密钥（机密，切勿泄露） | `（填你的 appPublicKey）` |

### 请求 Header

| Header 名称 | 必填 | 说明 |
|-------------|------|------|
| `X-App-Key` | 是 | 平台分配的身份标识 |
| `X-Timestamp` | 是 | 当前时间戳（毫秒），有效期 ±5 分钟 |
| `X-Sign` | 是 | 请求签名，生成算法见下方 |
| `Authorization` | 否 | 小程序端使用，携带此 Header 时跳过验签 |

### 签名算法

```
签名原串 = 所有 Query 参数按 key 字典序拼接 "k1=v1&k2=v2&...&timestamp={ts}&key={appPublicKey}"
签名结果 = MD5(签名原串)
```

**步骤详解：**

1. 取当前请求的全部 Query 参数（即 URL 中 `?` 后面的部分，不含路径参数）
2. 将参数按照 **key 的字母序升序排列**（Java 中用 `TreeMap`）
3. 拼接为 `key1=value1&key2=value2&...` 的形式（值为空则跳过该参数）
4. 在末尾追加 `timestamp={时间戳}&key={appPublicKey}`
5. 对整个字符串计算 **MD5**，取 **32 位小写十六进制**字符串

### 签名示例

以**任务大厅列表**接口为例，假设请求参数为 `current=1&size=10`：

```
步骤1: 原始参数      → current=1, size=10
步骤2: 字典序排列    → current=1, size=10  （本身就是升序）
步骤3: 拼接参数字符串 → "current=1&size=10&"
步骤4: 追加timestamp和key →
       "current=1&size=10&timestamp=1751289600000&key=70c1b93ae1df463809ed3b41156e4791"
步骤5: MD5           → "3f8c5d2a1b9e4f6c8d0a2b4c6e8f0a1b"
```

最终请求：

```http
GET /task/visitor/hall?current=1&size=10 HTTP/1.1
Host: 121.199.7.40:9998
X-App-Key: 填你的 appKey
X-Timestamp: 1751289600000
X-Sign: 3f8c5d2a1b9e4f6c8d0a2b4c6e8f0a1b
```

### 签名注意事项

1. **参数排序**：必须按 key 的 `String` 自然排序（字典序/ASCII 序），Java 用 `TreeMap` 即可保证
2. **空参数跳过**：值为 `null` 或空字符串 `""` 的参数**不参与签名**
3. **路径参数不参与签名**：`@PathVariable`（如 `/{taskId}` 中的 taskId）不在 Query String 中，**不参与签名**
4. **时间戳有效期**：服务端允许的时间偏差为 **±5 分钟**，超时请求返回 `请求已过期`
5. **签名格式**：MD5 输出 **32 位小写十六进制**字符串
6. **编码**：签名原串使用 **UTF-8** 编码后计算 MD5
7. **小程序兼容**：若请求携带 `Authorization` Header（JWT Token），则直接放行，不触发验签

---

## 接口一：任务大厅列表

### 基本信息

| 项目 | 内容                   |
|------|----------------------|
| **接口路径** | `/task/visitor/hall` |
| **请求方式** | `GET`                |
| **认证** | 免登录 + 签名验证           |
| **Content-Type** | 无需（GET 请求）           |

### 请求参数（Query String）

| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `current` | int | 否 | 页码，默认 1 |
| `size` | int | 否 | 每页条数，默认 10 |
| `occupationCode` | String | 否 | 职业编码 |
| `lng` | Double | 否 | 经度 |
| `lat` | Double | 否 | 纬度 |
| `provinceId` | String | 否 | 省 ID |
| `cityId` | String | 否 | 市 ID |
| `areaId` | String | 否 | 区 ID |
| `streetId` | String | 否 | 街道 ID |
| `titleFuzzy` | String | 否 | 任务标题模糊搜索 |
| `timeRange` | String | 否 | 时间范围：`LastWeek`（最近一周）、`LastHalfYear`（最近半年）、不传或 `ALL`（不限） |
| `sortByType` | int | 否 | 排序类型：`1`=创建时间倒序，`2`=距离升序 |
| `sortField` | String | 否 | 自定义排序字段（如 `wage`=工资待遇） |
| `sortOrder` | String | 否 | 排序规则：`asc`=升序，`desc`=降序 |

### 响应参数

返回分页结构，`records` 中每个元素字段如下：

| 字段 | 类型 | 说明 |
|------|------|------|
| `taskId` | String | 任务 ID |
| `bizId` | String | 业务身份 ID |
| `bizType` | Integer | 业务域：0=个人，1=一般企业 |
| `bizName` | String | 业务身份名称 |
| `workTimeLimit` | Integer | 是否限制作业时间：0=不限制，1=限制 |
| `title` | String | 任务标题 |
| `description` | String | 任务描述 |
| `taskAddress` | String | 任务工作地址 |
| `provinceId` / `provinceName` | String | 省 ID / 名称 |
| `cityId` / `cityName` | String | 市 ID / 名称 |
| `areaId` / `areaName` | String | 区 ID / 名称 |
| `streetId` / `streetName` | String | 街道 ID / 名称 |
| `lat` / `lng` | Double | 纬度 / 经度 |
| `contactPhone` | String | 联系人手机号 |
| `email` | String | 联系人邮箱 |
| `settlementUnit` | Integer | 结算单位：1=元/小时，2=元/天，3=元/次，4=元/件 |
| `occupationCode` | String | 职业编码 |
| `occupationName` | String | 职业名称 |
| `wage` | BigDecimal | 佣金 |
| `requiredPersonnel` | Integer | 需要人数 |
| `welfare` | String | 任务福利 |
| `taskType` | Integer | 任务类型：1=撮合，2=代发 |
| `taskStatus` | Integer | 任务状态：1=代付款，2=待开始，3=已超时，4=进行中，5=已完成，6=已取消 |
| `taskCycleStartTime` | Date | 任务周期起始时间（`yyyy-MM-dd HH:mm:ss`） |
| `taskCycleEndTime` | Date | 任务周期结束时间（`yyyy-MM-dd HH:mm:ss`） |
| `certificateRequired` | Integer | 是否需要证书：0=不需要，1=需要 |
| `certificate` | String | 证书列表，多个逗号分隔 |
| `insureMethod` | Integer | 保险方式：1=发单方购买，2=双方线下承保 |
| `insureEffectType` | Integer | 保险购买方式：0=暂不购买，2=按打卡购买，3=按任务周期购买 |
| `insuranceType` | String | 险种类别 |
| `distance` | String | 距离（米） |
| `isPersonal` | Integer | 发单方身份：0=个人，1=企业 |
| `applyCount` | Integer | 报名人数 |
| `taskNumber` | String | 任务编号 |
| `onDutyTime` / `offDutyTime` | String | 到场时间 / 离场时间 |
| `gmtCreated` | Date | 创建时间（`yyyy-MM-dd HH:mm:ss`） |
| `createTime` | String | 创建时间字符串（兼容字段） |

### 请求示例

```bash
curl -X GET \
  'https://api.zjmjyy.com/api/api-platform/task/visitor/hall?current=1&size=10' \
  -H 'X-App-Key: 填你的 appKey' \
  -H 'X-Timestamp: 1751289600000' \
  -H 'X-Sign: 3f8c5d2a1b9e4f6c8d0a2b4c6e8f0a1b'
```

### 响应示例

```json
{
  "code": 200,
  "msg": "操作成功",
  "data": {
    "current": 1,
    "size": 10,
    "total": 156,
    "pages": 16,
    "records": [
      {
        "taskId": "TASK20260601001",
        "bizId": "BIZ001",
        "bizType": 1,
        "bizName": "XX建筑公司",
        "title": "招电工-日结",
        "description": "需要持证电工，工作地点XX工地",
        "taskAddress": "浙江省杭州市余杭区XX路XX号",
        "provinceId": "330000",
        "provinceName": "浙江省",
        "cityId": "330100",
        "cityName": "杭州市",
        "areaId": "330110",
        "areaName": "余杭区",
        "lat": 30.2500,
        "lng": 120.1300,
        "occupationCode": "OCC001",
        "occupationName": "电工",
        "wage": 350.00,
        "requiredPersonnel": 5,
        "taskType": 1,
        "taskStatus": 4,
        "taskCycleStartTime": "2026-06-15 08:00:00",
        "taskCycleEndTime": "2026-07-15 18:00:00",
        "settlementUnit": 1,
        "distance": "1250.50",
        "isPersonal": 1,
        "applyCount": 12,
        "gmtCreated": "2026-06-01 10:30:00"
      }
    ]
  }
}
```

---

## 接口二：任务详情

### 基本信息

| 项目 | 内容 |
|------|------|
| **接口路径** | `/task/visitor/{taskId}` |
| **请求方式** | `GET` |
| **认证** | 免登录 + 签名验证 |
| **Content-Type** | 无需（GET 请求） |

### 请求参数

| 参数名 | 类型 | 位置 | 必填 | 说明 |
|--------|------|------|------|------|
| `taskId` | String | Path | 是 | 任务 ID（从任务大厅列表获取） |

> ⚠️ **重要**：`taskId` 是路径参数（`@PathVariable`），**不参与签名计算**。  
> 当该接口没有 Query 参数时，签名原串为：`timestamp={ts}&key={appPublicKey}`

### 响应参数

| 字段 | 类型 | 说明 |
|------|------|------|
| `taskId` | String | 任务 ID |
| `bizId` | String | 业务身份 ID |
| `bizType` | Integer | 业务域：0=个人，1=企业 |
| `bizName` | String | 业务身份名称 |
| `title` | String | 任务标题 |
| `description` | String | 任务描述 |
| `workTimeLimit` | Integer | 是否限制作业时间：0=不限制，1=限制 |
| `onDutyTime` / `offDutyTime` | String | 到场时间 / 离场时间 |
| `taskAddress` | String | 任务工作地址 |
| `provinceId` / `provinceName` | String | 省 ID / 名称 |
| `cityId` / `cityName` | String | 市 ID / 名称 |
| `areaId` / `areaName` | String | 区 ID / 名称 |
| `streetId` / `streetName` | String | 街道 ID / 名称 |
| `lat` / `lng` | Double | 纬度 / 经度 |
| `contactPhone` | String | 联系人手机号 |
| `contact` | String | 联系人 |
| `welfare` | String | 任务福利 |
| `occupationCode` | String | 职业编码 |
| `occupationName` | String | 职业名称 |
| `settlementUnit` | Integer | 结算单位：1=元/小时，2=元/天，3=元/次，4=元/件 |
| `taskCycle` | Integer | 任务周期：1=长期，2=短期 |
| `taskCycleStartTime` | Date | 任务周期起始时间（`yyyy-MM-dd`） |
| `taskCycleEndTime` | Date | 任务周期结束时间（`yyyy-MM-dd`） |
| `wage` | BigDecimal | 佣金 |
| `requiredPersonnel` | Integer | 需要人数 |
| `taskType` | Integer | 任务类型：1=撮合，2=代发 |
| `taskStatus` | Integer | 任务状态：1=代付款，2=待开始，3=已超时，4=进行中，5=已完成，6=已取消 |
| `taskCompletionTime` | Date | 任务完成时间 |
| `taskActualStartTime` | Date | 任务实际开始时间 |
| `taskCancellationTime` | Date | 任务取消时间 |
| `taskTimeout` | Date | 任务超时时间 |
| `certificateRequired` | Integer | 是否需要证书：0=不需要，1=需要 |
| `certificate` | String | 证书列表 |
| `sexLimit` | Integer | 性别限制：1=不限，2=男，3=女 |
| `ageLimit` | Integer | 是否限制年龄：0=不限制，1=限制 |
| `minAge` / `maxAge` | Integer | 最小/最大年龄限制 |
| `insureMethod` | Integer | 保险方式：1=发单方购买，2=双方线下承保 |
| `insureEffectType` | Integer | 保险购买方式：0=暂不购买，2=按打卡购买，3=按任务周期购买 |
| `insuranceType` | String | 险种类别 |
| `isFaceCheckInRequired` | Integer | 是否需要人脸打卡：0=不需要，1=需要 |
| `checkInRangeLimit` | Integer | 是否限制打卡范围：0=不限，1=限制 |
| `checkRange` | Long | 打卡范围（米） |
| `distance` | String | 距离（米） |
| `applyCount` | Integer | 报名人数 |
| `isPersonal` | Integer | 发单方身份：0=个人，1=企业 |
| `taskNumber` | String | 任务编号 |
| `days` | Integer | 任务天数 |
| `taskCycleEndTimeOver` | Boolean | 是否已过任务周期结束时间 |
| `hasDevice` | Boolean | 是否包含打卡机 |
| `attendanceGroupId` | String | 打卡组 ID |
| `companyIntro` | String | 公司简介 |
| `jobRequirements` | String | 招聘条件 |

### 请求示例

```bash
curl -X GET \
  'https://api.zjmjyy.com/api/api-platform/task/visitor/TASK20260601001' \
  -H 'X-App-Key: 填你的 appKey' \
  -H 'X-Timestamp: 1751289600000' \
  -H 'X-Sign: 8a2d4e6f1b3c5d7e9f0a2b4c6d8e0f1a'
```

### 响应示例

```json
{
  "code": 200,
  "msg": "操作成功",
  "data": {
    "taskId": "TASK20260601001",
    "bizId": "BIZ001",
    "bizType": 1,
    "bizName": "XX建筑公司",
    "title": "招电工-日结",
    "description": "需要持证电工，工作地点XX工地，每天8小时",
    "workTimeLimit": 1,
    "onDutyTime": "08:00",
    "offDutyTime": "18:00",
    "taskAddress": "浙江省杭州市余杭区XX路XX号",
    "provinceId": "330000",
    "provinceName": "浙江省",
    "cityId": "330100",
    "cityName": "杭州市",
    "areaId": "330110",
    "areaName": "余杭区",
    "lat": 30.2500,
    "lng": 120.1300,
    "contactPhone": "138****1234",
    "contact": "张经理",
    "occupationCode": "OCC001",
    "occupationName": "电工",
    "settlementUnit": 1,
    "taskCycle": 2,
    "taskCycleStartTime": "2026-06-15",
    "taskCycleEndTime": "2026-07-15",
    "wage": 350.00,
    "requiredPersonnel": 5,
    "taskType": 1,
    "taskStatus": 4,
    "sexLimit": 1,
    "ageLimit": 1,
    "minAge": 25,
    "maxAge": 55,
    "certificateRequired": 1,
    "certificate": "电工证",
    "insureMethod": 1,
    "insureEffectType": 2,
    "isPersonal": 1,
    "applyCount": 12,
    "distance": "1250.50",
    "companyIntro": "XX建筑公司成立于2000年...",
    "jobRequirements": "持有有效电工证，3年以上工作经验"
  }
}
```

---

## 通用响应结构

所有接口统一返回：

```json
{
  "code": 200,
  "msg": "操作成功",
  "data": { ... }
}
```

| 字段 | 类型 | 说明 |
|------|------|------|
| `code` | int | 状态码，`200` 表示成功 |
| `msg` | String | 提示信息 |
| `data` | Object | 业务数据 |

**分页响应 `data` 额外包含：**

| 字段 | 类型 | 说明 |
|------|------|------|
| `current` | int | 当前页码 |
| `size` | int | 每页条数 |
| `total` | int | 总记录数 |
| `pages` | int | 总页数 |
| `records` | Array | 数据列表 |

---

## 错误码说明

### 签名相关错误

| 错误信息 | 原因 | 解决方案 |
|----------|------|----------|
| `缺少验签参数` | 请求未携带 `X-App-Key`、`X-Timestamp` 或 `X-Sign` | 确保三个 Header 全部传入且值非空 |
| `请求已过期` | `X-Timestamp` 与服务器时间差超过 ±5 分钟 | 生成新时间戳，重新计算签名后重试 |
| `时间戳格式错误` | `X-Timestamp` 不是合法的长整型毫秒值 | 传入正确的毫秒时间戳，如 `1751289600000` |
| `无效的AppKey` | `X-App-Key` 在平台不存在或已停用 | 检查 appKey 是否正确，联系管理员确认 |
| `签名验证失败` | `X-Sign` 与服务端计算的不一致 | 检查签名算法实现，确认参数排序、拼接方式、MD5 结果 |

### 排查技巧

1. **打印签名原串**：在调用方代码中把签名原串打印出来，与服务端日志对比
2. **确认参数排序**：务必用 TreeMap / 字典序排序
3. **确认 MD5 格式**：32 位小写十六进制
4. **确认时间戳单位**：使用**毫秒**，不是秒
5. **确认编码**：签名字符串使用 UTF-8 编码后计算 MD5

---

## 附录：Java 签名实现参考

以下是完整的 Java 签名工具代码，第三方可直接复制使用：

```java
import java.security.MessageDigest;
import java.util.*;

public class ApiSignUtil {

    /**
     * 构建请求签名
     *
     * @param params    请求参数 Map（query 参数，key=参数名，value=参数值）
     * @param timestamp 时间戳（毫秒）
     * @param secret    平台分配的 appPublicKey
     * @return 32位小写MD5签名字符串
     */
    public static String buildSign(Map<String, String> params, long timestamp, String secret) {
        // 1. 参数字典序排列（TreeMap 自动排序）
        TreeMap<String, String> sorted = new TreeMap<>(params);

        // 2. 拼接签名原串：k1=v1&k2=v2...&timestamp=xxx&key=secret
        StringBuilder sb = new StringBuilder();
        for (Map.Entry<String, String> entry : sorted.entrySet()) {
            if (entry.getValue() != null && !entry.getValue().isEmpty()) {
                sb.append(entry.getKey()).append("=").append(entry.getValue()).append("&");
            }
        }
        sb.append("timestamp=").append(timestamp);
        sb.append("&key=").append(secret);

        // 3. MD5 → 32位小写十六进制
        return md5(sb.toString());
    }

    private static String md5(String input) {
        try {
            MessageDigest md = MessageDigest.getInstance("MD5");
            md.reset();
            md.update(input.getBytes("UTF-8"));
            byte[] digest = md.digest();
            StringBuilder sb = new StringBuilder(digest.length * 2);
            for (byte b : digest) {
                sb.append(String.format("%02x", b & 0xff));
            }
            return sb.toString();
        } catch (Exception e) {
            throw new RuntimeException("MD5 error", e);
        }
    }

    // ────────── 使用示例 ──────────

    public static void main(String[] args) {
        String appKey = "AK_eaf449b6fb9f21";
        String secret = "70c1b93ae1df463809ed3b41156e4791";

        // 示例1：任务大厅列表
        Map<String, String> hallParams = new LinkedHashMap<>();
        hallParams.put("current", "1");
        hallParams.put("size", "10");
        hallParams.put("occupationCode", "OCC001");

        long ts = System.currentTimeMillis();
        String sign = buildSign(hallParams, ts, secret);

        System.out.println("X-App-Key   : " + appKey);
        System.out.println("X-Timestamp : " + ts);
        System.out.println("X-Sign      : " + sign);

        // 示例2：任务详情（无Query参数）
        Map<String, String> emptyParams = new LinkedHashMap<>();
        long ts2 = System.currentTimeMillis();
        String sign2 = buildSign(emptyParams, ts2, secret);

        System.out.println("\n[任务详情] X-Timestamp: " + ts2);
        System.out.println("[任务详情] X-Sign     : " + sign2);
    }
}
```
