# 签约结果回调


## 基本信息

| 名称 | 描述 |
|-|-|
| HTTP URL | 开发者配置的回调地址 |
| HTTP Method | POST |
| 回调类型（type） | sign_callback |
| Content-Type | application/json |

## 接口说明

签约状态变更时（签约成功、解约等），豆包平台会向开发者配置的回调地址发送签约结果通知。开发者收到通知后需验证签名并返回标准响应。

回调方向说明： 本接口为回调接口，由豆包平台主动调用开发者服务器，而非开发者调用平台。开发者需在自己的服务端实现一个 HTTP POST 接口来接收并处理该回调。

## 回调 Header

豆包平台在发送回调请求时，会在 HTTP Header 中携带以下字段：

| 字段名 | 类型 | 是否必填 | 字段说明 | 取值范例 |
|-|-|-|-|-|
| Content-Type | string | 是 | 固定值：application/json | application/json |
| X-DB-Logid | string | 是 | 开平统一日志 ID，当出现问题时可以提供此 ID 给研发人员协助定位问题 | 20230504163800ABA7347CE4F5 |
| X-DB-Authorization | string | 是 | 签名认证信息，开发者需验证签名确保请求来自豆包平台 |  |

## 回调 Body 参数

### 外层结构

| 字段名 | 必填 | 类型 | 说明 | 示例 |
|-|-|-|-|-|
| type | 是 | string | 回调类型枚举，与接口一一对应 | sign_callback |
| version | 是 | string | 固定值 1.0。回调版本，用于开发者识别回调参数的变更 | 1.0 |
| msg | 是 | string | 具体回调内容，JSON 字符串，解析后结构见下方 |  |

### msg 字段解析（type = sign_callback）

当 type 为 sign_callback 时，msg 字段为 JSON 字符串，解析后包含以下字段：

| 字段名 | 必填 | 类型 | 说明 | 示例 |
|-|-|-|-|-|
| app_id | 是 | string | 应用 ID | tt1234567890\*\*\*\*\*\* |
| auth_order_id | 是 | string | 平台侧签约单号 | 80214322040407612\*\*\* |
| out_auth_order_no | 是 | string | 外部签约单号，与 auth_order_id 一一对应 | SIGN2024010100\*\*\* |
| status | 是 | string | 签约单状态，枚举值： TOBESERVED - 待签约 SERVING - 签约成功 CANCEL - 签约取消 TIMEOUT - 签约超时 | SERVING |
| unsign_source | 否 | integer | 解约来源，仅在 status 为 UNSIGN 时返回。枚举值：1 - 用户主动解约；2 - 商户解约 | 1 |
| event_time | 是 | string | 签约事件发生时间，精度：毫秒 | 1692775192000 |

## 请求示例

以下为豆包平台发送到开发者回调地址的完整请求 Body 示例：

### 签约成功回调

```json
{
    "type": "sign_callback",
    "version": "1.0",
    "msg": "{\"app_id\":\"tt1234567890******\",\"auth_order_id\":\"80214322040407612***\",\"out_auth_order_no\":\"SIGN2024010100***\",\"status\":\"SIGN\",\"event_time\":\"1692775192000\"}"
}
```

### 解约回调

```json
{
    "type": "sign_callback",
    "version": "1.0",
    "msg": "{\"app_id\":\"tt1234567890******\",\"auth_order_id\":\"80214322040407612***\",\"out_auth_order_no\":\"SIGN2024010100***\",\"status\":\"UNSIGN\",\"unsign_source\":1,\"event_time\":\"1692776192000\"}"
}
```

## 开发者响应

开发者在收到回调并处理完成后，需返回以下标准响应。

重要： 正常返回时一定要保证 code 和 msg 按示例标准返回，不然都认为失败，将会重试。

### 响应参数

| 字段名 | 必填 | 类型 | 说明 | 示例 |
|-|-|-|-|-|
| code | 是 | int32 | 响应状态码，成功时为 0 | 0 |
| msg | 是 | string | 响应消息，成功时为 success | success |

### 响应示例

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

## 代码示例

以下示例展示开发者服务端如何接收并处理豆包平台发送的签约结果回调，包括签名验证、Body 解析、业务处理及标准响应返回。

### Go

```go
package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "encoding/json"
    "fmt"
    "io"
    "log"
    "net/http"
)

// CallbackRequest 回调外层结构
type CallbackRequest struct {
    Type    string `json:"type"`
    Version string `json:"version"`
    Msg     string `json:"msg"`
}

// SignCallbackMsg 签约回调 msg 解析结构
type SignCallbackMsg struct {
    AppID           string `json:"app_id"`
    AuthOrderID     string `json:"auth_order_id"`
    OutAuthOrderNo  string `json:"out_auth_order_no"`
    Status          string `json:"status"`
    UnsignSource    int32  `json:"unsign_source,omitempty"`
    EventTime       string `json:"event_time"`
}

// CallbackResponse 开发者标准响应
type CallbackResponse struct {
    Code int32  `json:"code"`
    Msg  string `json:"msg"`
}

// verifySignature 验证 X-DB-Authorization 签名
func verifySignature(authorization string, body []byte, secret string) bool {
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write(body)
    expectedSig := hex.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(expectedSig), []byte(authorization))
}

func signCallbackHandler(w http.ResponseWriter, r *http.Request) {
    // 1. 读取请求 Body
    body, err := io.ReadAll(r.Body)
    if err != nil {
        log.Printf("读取请求 Body 失败: %v", err)
        w.WriteHeader(http.StatusInternalServerError)
        return
    }
    defer r.Body.Close()

    // 2. 验证签名
    authorization := r.Header.Get("X-DB-Authorization")
    logID := r.Header.Get("X-DB-Logid")
    log.Printf("收到回调请求, LogID: %s", logID)

    appSecret := "your_app_secret" // 替换为实际的应用密钥
    if !verifySignature(authorization, body, appSecret) {
        log.Printf("签名验证失败, LogID: %s", logID)
        w.WriteHeader(http.StatusUnauthorized)
        json.NewEncoder(w).Encode(CallbackResponse{Code: 500000003, Msg: "VerifySignFailError"})
        return
    }

    // 3. 解析外层结构
    var callbackReq CallbackRequest
    if err := json.Unmarshal(body, &callbackReq); err != nil {
        log.Printf("解析回调请求失败: %v", err)
        w.WriteHeader(http.StatusBadRequest)
        json.NewEncoder(w).Encode(CallbackResponse{Code: 500000001, Msg: "ParamError"})
        return
    }

    // 4. 根据 type 处理不同回调
    if callbackReq.Type == "sign_callback" {
        // 5. 解析 msg JSON 字符串
        var signMsg SignCallbackMsg
        if err := json.Unmarshal([]byte(callbackReq.Msg), &signMsg); err != nil {
            log.Printf("解析 msg 字段失败: %v", err)
            w.WriteHeader(http.StatusBadRequest)
            json.NewEncoder(w).Encode(CallbackResponse{Code: 500000001, Msg: "ParamError"})
            return
        }

        // 6. 处理业务逻辑（幂等处理）
        log.Printf("签约回调: AuthOrderID=%s, OutAuthOrderNo=%s, Status=%s, UnsignSource=%d, EventTime=%s",
            signMsg.AuthOrderID, signMsg.OutAuthOrderNo, signMsg.Status,
            signMsg.UnsignSource, signMsg.EventTime)

        // TODO: 根据 auth_order_id 查询本地签约单，判断是否已处理（幂等）
        // TODO: 根据 status 更新签约单状态
        // TODO: 如果 status 为 UNSIGN，记录 unsign_source 解约来源
    }

    // 7. 返回标准成功响应
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusOK)
    json.NewEncoder(w).Encode(CallbackResponse{Code: 0, Msg: "success"})
}

func main() {
    http.HandleFunc("/api/sign/callback", signCallbackHandler)
    fmt.Println("服务启动，监听端口 8080...")
    log.Fatal(http.ListenAndServe(":8080", nil))
}
```

### Java

```java
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import javax.servlet.http.HttpServletRequest;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;

@RestController
@RequestMapping("/api/sign")
public class SignCallbackController {

    private static final ObjectMapper objectMapper = new ObjectMapper();
    private static final String APP_SECRET = "your_app_secret"; // 替换为实际的应用密钥

    /**
     * 验证 X-DB-Authorization 签名
     */
    private boolean verifySignature(String authorization, String body, String secret) {
        try {
            Mac mac = Mac.getInstance("HmacSHA256");
            SecretKeySpec secretKeySpec = new SecretKeySpec(
                secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
            mac.init(secretKeySpec);
            byte[] hash = mac.doFinal(body.getBytes(StandardCharsets.UTF_8));
            StringBuilder hexString = new StringBuilder();
            for (byte b : hash) {
                String hex = Integer.toHexString(0xff & b);
                if (hex.length() == 1) hexString.append('0');
                hexString.append(hex);
            }
            return hexString.toString().equals(authorization);
        } catch (Exception e) {
            return false;
        }
    }

    @PostMapping("/callback")
    public ResponseEntity<Map<String, Object>> handleCallback(
            HttpServletRequest request,
            @RequestBody String body) {

        Map<String, Object> response = new HashMap<>();

        // 1. 获取 Header 信息
        String authorization = request.getHeader("X-DB-Authorization");
        String logId = request.getHeader("X-DB-Logid");
        System.out.println("收到回调请求, LogID: " + logId);

        // 2. 验证签名
        if (!verifySignature(authorization, body, APP_SECRET)) {
            System.out.println("签名验证失败, LogID: " + logId);
            response.put("code", 500000003);
            response.put("msg", "VerifySignFailError");
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(response);
        }

        try {
            // 3. 解析外层结构
            JsonNode callbackReq = objectMapper.readTree(body);
            String type = callbackReq.get("type").asText();
            String version = callbackReq.get("version").asText();
            String msg = callbackReq.get("msg").asText();

            // 4. 根据 type 处理不同回调
            if ("sign_callback".equals(type)) {
                // 5. 解析 msg JSON 字符串
                JsonNode signMsg = objectMapper.readTree(msg);

                String appId = signMsg.get("app_id").asText();
                String authOrderId = signMsg.get("auth_order_id").asText();
                String outAuthOrderNo = signMsg.get("out_auth_order_no").asText();
                String status = signMsg.get("status").asText();
                String eventTime = signMsg.get("event_time").asText();
                int unsignSource = signMsg.has("unsign_source") ? signMsg.get("unsign_source").asInt() : 0;

                // 6. 处理业务逻辑（幂等处理）
                System.out.printf("签约回调: AuthOrderID=%s, OutAuthOrderNo=%s, Status=%s, " +
                        "UnsignSource=%d, EventTime=%s%n",
                        authOrderId, outAuthOrderNo, status, unsignSource, eventTime);

                // TODO: 根据 auth_order_id 查询本地签约单，判断是否已处理（幂等）
                // TODO: 根据 status 更新签约单状态
                // TODO: 如果 status 为 UNSIGN，记录 unsign_source 解约来源
            }

            // 7. 返回标准成功响应
            response.put("code", 0);
            response.put("msg", "success");
            return ResponseEntity.ok(response);

        } catch (Exception e) {
            System.out.println("解析回调请求失败: " + e.getMessage());
            response.put("code", 500000001);
            response.put("msg", "ParamError");
            return ResponseEntity.badRequest().body(response);
        }
    }
}
```

### Node.js

```javascript
const express = require('express');
const crypto = require('crypto');

const app = express();
app.use(express.json({ type: 'application/json' }));

const APP_SECRET = 'your_app_secret'; // 替换为实际的应用密钥

/**
 * 验证 X-DB-Authorization 签名
 */
function verifySignature(authorization, body, secret) {
    const hmac = crypto.createHmac('sha256', secret);
    hmac.update(JSON.stringify(body));
    const expectedSig = hmac.digest('hex');
    return crypto.timingSafeEqual(
        Buffer.from(expectedSig),
        Buffer.from(authorization)
    );
}

/**
 * 签约结果回调处理
 */
app.post('/api/sign/callback', (req, res) => {
    // 1. 获取 Header 信息
    const authorization = req.headers['x-db-authorization'];
    const logId = req.headers['x-db-logid'];
    console.log(`收到回调请求, LogID: ${logId}`);

    // 2. 验证签名
    try {
        if (!verifySignature(authorization, req.body, APP_SECRET)) {
            console.log(`签名验证失败, LogID: ${logId}`);
            return res.status(401).json({
                code: 500000003,
                msg: 'VerifySignFailError'
            });
        }
    } catch (err) {
        console.log(`签名验证异常: ${err.message}`);
        return res.status(401).json({
            code: 500000003,
            msg: 'VerifySignFailError'
        });
    }

    // 3. 解析外层结构
    const { type, version, msg } = req.body;

    // 4. 根据 type 处理不同回调
    if (type === 'sign_callback') {
        try {
            // 5. 解析 msg JSON 字符串
            const signMsg = JSON.parse(msg);

            const {
                app_id: appId,
                auth_order_id: authOrderId,
                out_auth_order_no: outAuthOrderNo,
                status,
                unsign_source: unsignSource,
                event_time: eventTime
            } = signMsg;

            // 6. 处理业务逻辑（幂等处理）
            console.log(`签约回调: AuthOrderID=${authOrderId}, OutAuthOrderNo=${outAuthOrderNo}, ` +
                `Status=${status}, UnsignSource=${unsignSource || 'N/A'}, EventTime=${eventTime}`);

            // TODO: 根据 auth_order_id 查询本地签约单，判断是否已处理（幂等）
            // TODO: 根据 status 更新签约单状态
            // TODO: 如果 status 为 UNSIGN，记录 unsign_source 解约来源

        } catch (err) {
            console.log(`解析 msg 字段失败: ${err.message}`);
            return res.status(400).json({
                code: 500000001,
                msg: 'ParamError'
            });
        }
    }

    // 7. 返回标准成功响应
    res.status(200).json({
        code: 0,
        msg: 'success'
    });
});

app.listen(8080, () => {
    console.log('服务启动，监听端口 8080...');
});
```

### Python

```python
import hashlib
import hmac
import json
from flask import Flask, request, jsonify

app = Flask(__name__)

APP_SECRET = 'your_app_secret'  # 替换为实际的应用密钥

def verify_signature(authorization: str, body: bytes, secret: str) -> bool:
    """验证 X-DB-Authorization 签名"""
    expected_sig = hmac.new(
        secret.encode('utf-8'),
        body,
        hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected_sig, authorization)

@app.route('/api/sign/callback', methods=['POST'])
def sign_callback():
    # 1. 获取 Header 信息
    authorization = request.headers.get('X-DB-Authorization', '')
    log_id = request.headers.get('X-DB-Logid', '')
    print(f'收到回调请求, LogID: {log_id}')

    # 2. 验证签名
    raw_body = request.get_data()
    if not verify_signature(authorization, raw_body, APP_SECRET):
        print(f'签名验证失败, LogID: {log_id}')
        return jsonify({'code': 500000003, 'msg': 'VerifySignFailError'}), 401

    try:
        # 3. 解析外层结构
        callback_req = request.get_json()
        callback_type = callback_req.get('type')
        version = callback_req.get('version')
        msg = callback_req.get('msg')

        # 4. 根据 type 处理不同回调
        if callback_type == 'sign_callback':
            # 5. 解析 msg JSON 字符串
            sign_msg = json.loads(msg)

            app_id = sign_msg.get('app_id')
            auth_order_id = sign_msg.get('auth_order_id')
            out_auth_order_no = sign_msg.get('out_auth_order_no')
            status = sign_msg.get('status')
            unsign_source = sign_msg.get('unsign_source')
            event_time = sign_msg.get('event_time')

            # 6. 处理业务逻辑（幂等处理）
            print(
                f'签约回调: AuthOrderID={auth_order_id}, OutAuthOrderNo={out_auth_order_no}, '
                f'Status={status}, UnsignSource={unsign_source}, EventTime={event_time}'
            )

            # TODO: 根据 auth_order_id 查询本地签约单，判断是否已处理（幂等）
            # TODO: 根据 status 更新签约单状态
            # TODO: 如果 status 为 UNSIGN，记录 unsign_source 解约来源

    except (json.JSONDecodeError, KeyError) as e:
        print(f'解析回调请求失败: {e}')
        return jsonify({'code': 500000001, 'msg': 'ParamError'}), 400

    # 7. 返回标准成功响应
    return jsonify({'code': 0, 'msg': 'success'}), 200

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=8080)
```

## 注意事项

幂等处理： 回调通知可能会多次发送，开发者需要做好幂等处理，避免重复业务处理。

签名验证： 开发者需验证 X-DB-Authorization 签名，确保请求来自豆包平台。

标准响应： 开发者必须在收到回调后返回 {"code": 0, "msg": "success"}，否则平台会认为回调失败并重试。

签约状态确认： 不可只依赖回调判断签约状态，签约状态需以后端查询签约订单接口为主。

HTTPS 要求： 回调地址需为 HTTPS 类型。

解约来源： 当 status 为 UNSIGN 时，unsign_source 字段标识解约发起方（1=用户主动解约，2=商户解约），开发者可据此进行差异化处理。
