异步接口调用说明

更新时间:
复制 MD 格式

1、轮训结果

// Result:API的返回结果,
// Status:QUEUED、IN_PROGRESS、COMPLETED、FAILED
// TaskId:TaskId
// UsageMap:UasgeMap
{
  "RequestId": "01A11A0C-40B0-1181-B848-7FF74C3DB014",
  "Message": "Success",
  "Data": {
    "Status": "COMPLETED",
    "TaskId": "TID_3d32692c-ae96-438b-b1cd-3d5046e8280e", 
    "Result": "API返回实际出参结构"
  },
  "Code": "success",
  "Success": true
}

2、ROCKETMQ订阅

1. 接入流程

  1. 向服务提供方申请生产环境的接入点、ConsumerGroup、消费凭据,以及所属 UID 的解密密钥。

  2. 配置 uid-apiType Tag 订阅,启动消费者。

  3. 收到消息后解析加密信封,使用用户密钥进行 AES-256-GCM 解密。

  4. 根据 Data.TaskId 幂等处理,根据 Data.Status 判断任务结果。

  5. 业务数据持久化成功后确认消费;处理异常时返回消费失败。

接入前注意:

  • MQ 登录凭据和消息解密密钥是两套独立凭据,不能混用。

  • 客户端只获取自己的用户密钥,不需要、也不应获取服务端主密钥或复制服务端密钥派生工具。

  • 当前结果通知可能重复,也可能因发送失败而缺失。结果查询接口应作为兜底,不能把“没有收到通知”判断为任务失败。

2. 订阅规则

消息元数据:

字段

当前约定

Topic

服务方交付的结果 Topic

Key

taskId,用于消息检索;不是自动去重机制

Tag

uid + "-" + apiType

Body

UTF-8 编码的加密信封 JSON

示例 UID 为 1234567890,示例 API code 为 api-a:

1234567890-api-a

订阅同一 UID 的多个 API:

1234567890-api-a||1234567890-api-b

一条消息只有一个 Tag;消费表达式中的 || 表示“或”。Tag 精确匹配且区分大小写,1234567890-* 不表示 UID 前缀匹配;不要使用 * 接收整个共享 Topic。同一 ConsumerGroup 内实例的订阅关系必须保持一致,不同订阅需求使用不同 Group。消息过滤

3. 消息格式与加密协议

3.1 收到的消息体:加密信封

{
  "Version": 2,
  "Algorithm": "AES-256-GCM",
  "Iv": "<Base64 编码的 12 字节 IV>",
  "Ciphertext": "<Base64 编码的密文及认证标签>"
}

3.2 解密后的业务结果

{
  "RequestId": null,
  "Code": "success",
  "Success": true,
  "Message": "Success",
  "Data": {
    "Status": "COMPLETED",
    "Result": "{\"text\":\"翻译结果\"}",
    "TaskId": "task-001",
    "UsageMap": {}
  }
}

失败结果示意:

{
  "Status": "FAILED",
  "TaskId": "task-001",
  "Result": "{\"errorCode\":\"<实际错误码>\",\"errorMessage\":\"<失败原因>\"}",
  "UsageMap": null
}

4.接入方案

4.1 POM依赖

<dependencies>
    <dependency>
        <groupId>org.apache.rocketmq</groupId>
        <artifactId>rocketmq-client-java</artifactId>
        <version>5.0.7</version>
    </dependency>
    <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>fastjson</artifactId>
        <version>2.0.30</version>
    </dependency>
</dependencies>

4.2 Java 解密工具(可独立使用)

keySecret 向服务提供方询问获取

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.util.Base64;

public final class AsyncTaskResultDecryptor {
    private AsyncTaskResultDecryptor() {}

    public static JSONObject decryptMessage(byte[] body, String keySecret)
            throws GeneralSecurityException {
        if (body == null || body.length == 0
                || keySecret == null || keySecret.isBlank()) {
            throw new IllegalArgumentException("body and keySecret are required");
        }
        JSONObject envelope = JSON.parseObject(new String(body, StandardCharsets.UTF_8));
        if (envelope == null || !Integer.valueOf(2).equals(envelope.get("Version"))
                || !"AES-256-GCM".equals(envelope.get("Algorithm"))) {
            throw new IllegalArgumentException("Unsupported encryption protocol");
        }
        String ivBase64 = envelope.getString("Iv");
        String ciphertextBase64 = envelope.getString("Ciphertext");
        if (ivBase64 == null || ivBase64.isBlank()
                || ciphertextBase64 == null || ciphertextBase64.isBlank()) {
            throw new IllegalArgumentException("Iv and Ciphertext are required");
        }
        byte[] key = Base64.getDecoder().decode(keySecret);
        byte[] iv = Base64.getDecoder().decode(ivBase64);
        byte[] ciphertext = Base64.getDecoder().decode(ciphertextBase64);
        if (key.length != 32 || iv.length != 12 || ciphertext.length < 16) {
            throw new IllegalArgumentException("Invalid key, IV or ciphertext length");
        }
        Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
        cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key, "AES"),
                new GCMParameterSpec(128, iv));
        byte[] plaintext = cipher.doFinal(ciphertext);
        JSONObject result = JSON.parseObject(new String(plaintext, StandardCharsets.UTF_8));
        if (result == null) {
            throw new IllegalArgumentException("Result JSON is required");
        }
        return result;
    }
}

4.3. Java 消费者接入示例

4.3.1 配置

以下是配置项说明,不要把真实密码或密钥提交到仓库。示例从环境变量读取,生产环境可改为密钥管理系统或受控配置注入。

ASYNC_MQ_ENDPOINTS=rmq-cn-x684z19du01.cn-beijing.rmq.aliyuncs.com:8080
ASYNC_MQ_NAMESPACE=rmq-cn-x684z19du01
ASYNC_MQ_TOPIC=ASYNC_TASK_RESULT_TOPIC
ASYNC_MQ_GROUP=CID_ASYNC_TASK_RESULT_{UID}
ASYNC_MQ_USERNAME=<消费账号:询问服务提供方>
ASYNC_MQ_PASSWORD=<消费密码:询问服务提供方>
ASYNC_MQ_UID=1234567890
ASYNC_MQ_API_TYPES=api-a,api-b
ASYNC_RESULT_KEY_SECRET=<该UID的用户密钥:询问服务提供方>

4.3.2 消费、解密与确认

import com.alibaba.fastjson.JSONObject;
import org.apache.rocketmq.client.apis.ClientConfiguration;
import org.apache.rocketmq.client.apis.ClientServiceProvider;
import org.apache.rocketmq.client.apis.StaticSessionCredentialsProvider;
import org.apache.rocketmq.client.apis.consumer.ConsumeResult;
import org.apache.rocketmq.client.apis.consumer.FilterExpression;
import org.apache.rocketmq.client.apis.consumer.FilterExpressionType;
import org.apache.rocketmq.client.apis.consumer.PushConsumer;
import java.nio.ByteBuffer;
import java.util.Arrays;
import java.util.Base64;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.logging.Logger;
import java.util.stream.Collectors;

public final class AsyncTaskResultConsumer {
    private static final Logger LOG = Logger.getLogger(AsyncTaskResultConsumer.class.getName());

    @FunctionalInterface
    public interface ResultHandler {
        // 必须在业务数据可靠持久化或幂等确认已处理后才正常返回;失败抛异常。
        void handle(String taskId, String tag, JSONObject result) throws Exception;
    }

    public static PushConsumer start(ResultHandler handler) throws Exception {
        Objects.requireNonNull(handler, "handler");
        String uid = required("ASYNC_MQ_UID");
        Set<String> tags = Arrays.stream(required("ASYNC_MQ_API_TYPES").split(",", -1))
                .map(String::trim)
                .map(apiType -> {
                    if (apiType.isEmpty() || apiType.contains("|") || apiType.contains("*")) {
                        throw new IllegalArgumentException("Invalid apiType");
                    }
                    return uid + "-" + apiType;
                })
                .collect(Collectors.toSet());
        String keySecret = required("ASYNC_RESULT_KEY_SECRET");
        if (Base64.getDecoder().decode(keySecret).length != 32) {
            throw new IllegalArgumentException("Expected a 32-byte AES key");
        }
        var config = ClientConfiguration.newBuilder()
                .setEndpoints(required("ASYNC_MQ_ENDPOINTS"))
                .setCredentialProvider(new StaticSessionCredentialsProvider(
                        required("ASYNC_MQ_USERNAME"), required("ASYNC_MQ_PASSWORD")));
        String namespace = System.getenv("ASYNC_MQ_NAMESPACE");
        if (namespace != null && !namespace.isBlank()) {
            config.setNamespace(namespace);
        }
        FilterExpression filter = new FilterExpression(
                tags.stream().sorted().collect(Collectors.joining("||")),
                FilterExpressionType.TAG);

        return ClientServiceProvider.loadService().newPushConsumerBuilder()
                .setClientConfiguration(config.build())
                .setConsumerGroup(required("ASYNC_MQ_GROUP"))
                .setSubscriptionExpressions(Map.of(required("ASYNC_MQ_TOPIC"), filter))
                .setConsumptionThreadCount(8)
                .setMessageListener(message -> {
                    try {
                        String tag = message.getTag().orElseThrow();
                        if (!tags.contains(tag)) {
                            throw new IllegalArgumentException("Unexpected message tag");
                        }
                        ByteBuffer buffer = message.getBody().duplicate();
                        byte[] body = new byte[buffer.remaining()];
                        buffer.get(body); // 不假定 ByteBuffer 一定有可访问的 array。
                        JSONObject result = AsyncTaskResultDecryptor.decryptMessage(body, keySecret);
                        JSONObject data = result.getJSONObject("Data");
                        if (data == null || data.getString("TaskId") == null
                                || data.getString("TaskId").isBlank()) {
                            throw new IllegalArgumentException("Data.TaskId is required");
                        }
                        String status = data.getString("Status");
                        if (status == null || !Set.of("COMPLETED", "FAILED", "CANCELLED").contains(status)) {
                            throw new IllegalArgumentException("Unexpected task status");
                        }
                        handler.handle(data.getString("TaskId"), tag, result);
                        return ConsumeResult.SUCCESS;
                    } catch (Exception e) {
                        // 不记录凭据、消息体、明文或异常中的业务内容。
                        LOG.warning("Async result consumption failed, messageId="
                                + message.getMessageId() + ", errorType=" + e.getClass().getSimpleName());
                        return ConsumeResult.FAILURE;
                    }
                })
                .build();
    }

    private static String required(String name) {
        String value = System.getenv(name);
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException("Missing environment variable: " + name);
        }
        return value;
    }
}