语言
场景
  • Java一键开服-Webhook📌 推荐v2
    一键开服 - Webhook 接收与 HMAC 验签 (Java)

    示例:供应商如何接收平台的一键开服 Webhook 推送、按 X-Webhook-Signature 头校验 HMAC-SHA256 签名。

    接入说明

    平台会向您在 OpenApiApp 配置的 provisionEndpoint 推送一键开服事件,您需:

    1. 在请求头读取 X-Webhook-EventX-Webhook-SignatureX-Request-Id
    2. 用应用配置的 provisionWebhookSecret 计算 HMAC-SHA256(rawBody),与签名比对
    3. 取出 manifestToken 用于第二步主动拉取 manifest

    Spring Controller 示例

    @RestController
    @RequestMapping("/provider/webhook")
    public class ProvisionWebhookController {
    
        @Value("${yuul.openapi.provision.secret}")
        private String webhookSecret;
    
        @PostMapping("/provision")
        public ResponseEntity<Void> onProvisionDispatch(
                @RequestHeader("X-Webhook-Event") String event,
                @RequestHeader("X-Webhook-Signature") String signature,
                @RequestBody String rawBody) {
    
            // 1. 验签(注意:使用 raw body,不可经过 Jackson 反序列化后再 toJson)
            String expected = "sha256=" + base64(hmacSha256(webhookSecret, rawBody));
            if (!constantTimeEquals(expected, signature)) {
                return ResponseEntity.status(401).build();
            }
    
            // 2. 解析 payload
            ProvisionDispatchPayload payload = JSON.parseObject(rawBody, ProvisionDispatchPayload.class);
            log.info("[Webhook] received provision dispatch: deploymentId={}, items={}",
                    payload.getDeploymentId(), payload.getItemCount());
    
            // 3. 异步入队,避免阻塞回调(必须在 5s 内返回 2xx)
            deploymentExecutor.submit(() ->
                    deploymentService.fetchManifestAndDeploy(payload.getManifestToken(), payload.getDeploymentId()));
    
            return ResponseEntity.ok().build();
        }
    
        private byte[] hmacSha256(String secret, String body) {
            try {
                Mac mac = Mac.getInstance("HmacSHA256");
                mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
                return mac.doFinal(body.getBytes(StandardCharsets.UTF_8));
            } catch (Exception e) {
                throw new IllegalStateException("HMAC failed", e);
            }
        }
    }
    

    ⚠️ 验签必须使用 constant-time 比较,避免 timing attack。

    更新于 2026-07-15 19:22:05
  • Java签名📌 推荐v2
    签名 - HMAC-SHA256 工具类 (Java)

    示例:零依赖签名工具类,可直接复制到任意 Java 项目使用。算法与 forum-backend OpenApiSignatureUtil 完全对齐。

    算法约定

    StringToSign = METHOD.toUpperCase() + "\n" + PATH + "\n" + TIMESTAMP_MILLIS
    Signature    = Base64(HMAC-SHA256(secretKey, StringToSign))
    
    • METHOD 必须大写:GET / POST / PUT / DELETE
    • PATH 不带 query string、不带 baseUrl,如 /openapi/v2/resource/mods/123
    • TIMESTAMP 为 Unix 毫秒,服务端容忍 5 分钟时钟偏差(超出直接 401)
    • HMAC 使用 secretKey 的 UTF-8 字节,签名结果走标准 Base64(含 +/=)
    • Webhook 验签使用同一 HMAC 算法,但 StringToSign = rawBody,且 header 形如 sha256=<base64>

    完整工具类

    public final class YuulOpenApiSignatureUtil {
    
        private static final String HMAC_SHA256 = "HmacSHA256";
        private static final long SKEW_TOLERANCE_MILLIS = 5 * 60 * 1000L;
        private static final String WEBHOOK_PREFIX = "sha256=";
    
        private YuulOpenApiSignatureUtil() {
        }
    
        public static String sign(String method, String path, long timestamp, String secretKey) {
            if (method == null || path == null || secretKey == null) {
                throw new IllegalArgumentException("method / path / secretKey 不能为空");
            }
            String stringToSign = method.toUpperCase() + "\n" + path + "\n" + timestamp;
            return Base64.getEncoder().encodeToString(
                    hmacSha256(secretKey.getBytes(StandardCharsets.UTF_8),
                              stringToSign.getBytes(StandardCharsets.UTF_8)));
        }
    
        public static boolean verifyWebhookSignature(String secret, String rawBody, String header) {
            if (header == null || !header.startsWith(WEBHOOK_PREFIX)) {
                return false;
            }
            String expected = Base64.getEncoder().encodeToString(
                    hmacSha256(secret.getBytes(StandardCharsets.UTF_8),
                              rawBody == null ? new byte[0] : rawBody.getBytes(StandardCharsets.UTF_8)));
            return constantTimeEquals(expected, header.substring(WEBHOOK_PREFIX.length()));
        }
    
        public static boolean isTimestampValid(long timestamp) {
            return Math.abs(System.currentTimeMillis() - timestamp) <= SKEW_TOLERANCE_MILLIS;
        }
    
        private static byte[] hmacSha256(byte[] key, byte[] data) {
            try {
                Mac mac = Mac.getInstance(HMAC_SHA256);
                mac.init(new SecretKeySpec(key, HMAC_SHA256));
                return mac.doFinal(data);
            } catch (NoSuchAlgorithmException | InvalidKeyException e) {
                throw new IllegalStateException("HMAC-SHA256 不可用,JDK 损坏?", e);
            }
        }
    
        /** 常量时间比较,避免 timing attack */
        private static boolean constantTimeEquals(String a, String b) {
            if (a == null || b == null || a.length() != b.length()) return false;
            int r = 0;
            for (int i = 0; i < a.length(); i++) r |= a.charAt(i) ^ b.charAt(i);
            return r == 0;
        }
    }
    

    单元测试 vector

    secretKey = "test-secret"
    method    = "GET"
    path      = "/openapi/v2/resource/mods/8001"
    timestamp = 1718614800000
    --
    stringToSign = "GET\n/openapi/v2/resource/mods/8001\n1718614800000"
    signature    = "wTl0wQpHGgCfV1n8KZh+aF8Z3RTW4P9nJ4Lp..." (固定结果)
    

    验证拿到的 signature 与 forum-backend OpenApiSignatureUtil#generateSignature 输出一致,即可确认接入正确。

    更新于 2026-07-15 19:22:05
  • Java一键开服-Callbackv2
    一键开服 - 上报部署状态 (Java)

    示例:供应商完成部署后,调用回调接口上报 SUCCESS / FAILED / IN_PROGRESS。仅一次进入终态后不可再修改。

    接入说明

    POST /openapi/v2/provision-deployment/callback/{deploymentId}
    Content-Type: application/json
    X-API-Key: <你的应用 AppKey>
    X-Timestamp: <Unix 毫秒>
    X-Signature: <按平台签名规范计算>
    
    {
      "status": "SUCCESS",
      "failureReason": null,
      "callbackPayload": "{\"serverAddr\":\"play.example.com:25565\",\"loginUrl\":\"https://...\"}"
    }
    

    字段说明

    字段 必填 说明
    status IN_PROGRESS / SUCCESS / FAILED
    failureReason 失败原因,最多 500 字
    callbackPayload 自定义回执 JSON(如服务器地址、登录入口),最多 4000 字

    Java 示例

    public void reportStatus(long deploymentId, String status, String reason, Object extra) {
        Map<String, Object> body = new HashMap<>();
        body.put("status", status);
        if (StringUtils.hasText(reason)) {
            body.put("failureReason", reason);
        }
        if (extra != null) {
            body.put("callbackPayload", JSON.toJSONString(extra));
        }
    
        String json = JSON.toJSONString(body);
        HttpHeaders headers = openApiSigner.signRequest(
                "POST", "/openapi/v2/provision-deployment/callback/" + deploymentId, json);
        headers.setContentType(MediaType.APPLICATION_JSON);
    
        HttpEntity<String> entity = new HttpEntity<>(json, headers);
        ResponseEntity<ApiResponse<Void>> resp = restTemplate.exchange(
                "https://api.yuul.cn/openapi/v2/provision-deployment/callback/" + deploymentId,
                HttpMethod.POST, entity, new ParameterizedTypeReference<>() {});
    
        if (resp.getBody() == null || resp.getBody().getCode() != 0) {
            log.warn("回调上报失败 deploymentId={} resp={}", deploymentId,
                    resp.getBody() == null ? "null" : resp.getBody().getMsg());
        }
    }
    

    ⚠️ SUCCESS / FAILED / CANCELLED / EXPIRED 为终结态,进入终结态后服务端将拒绝再次修改,请确保最终状态只上报一次。

    更新于 2026-07-15 19:22:05
  • Java认证v2
    认证 - HTTP 客户端注入签名头 (Java)

    示例:Java 客户端统一封装,自动在每次请求注入 X-API-Key / X-Timestamp / X-Signature 三个头,无需调用方关心签名细节。

    设计要点

    • 仅在请求发出前一刻拼接签名头,确保 timestamp 与请求时刻一致(防止重放窗口外被服务端拒绝)
    • 统一解包 ResponseDTO<T>:ok=false 直接抛业务异常,调用方代码不必到处判 code
    • baseUrl / apiKey / secretKey 全部来自 Spring @ConfigurationProperties,避免硬编码

    完整示例 (cloudexplorer-lite 示范供应商)

    @Component
    public class YuulOpenApiClient {
    
        @Resource
        private YuulOpenApiProperties properties;
    
        public <T> T get(String path, Map<String, Object> query, TypeReference<T> typeRef) {
            long ts = System.currentTimeMillis();
            String sign = YuulOpenApiSignatureUtil.sign("GET", path, ts, properties.getSecretKey());
            String url = properties.getBaseUrl() + path;
    
            HttpResponse resp = HttpRequest.get(url)
                    .header("X-API-Key", properties.getApiKey())
                    .header("X-Timestamp", String.valueOf(ts))
                    .header("X-Signature", sign)
                    .form(query)
                    .timeout(properties.getReadTimeout())
                    .execute();
            return parse(resp.body(), typeRef);
        }
    
        public <T> T post(String path, Object body, TypeReference<T> typeRef) {
            long ts = System.currentTimeMillis();
            String sign = YuulOpenApiSignatureUtil.sign("POST", path, ts, properties.getSecretKey());
            String json = body == null ? "" : JSONUtil.toJsonStr(body);
            String url = properties.getBaseUrl() + path;
    
            HttpResponse resp = HttpRequest.post(url)
                    .header("X-API-Key", properties.getApiKey())
                    .header("X-Timestamp", String.valueOf(ts))
                    .header("X-Signature", sign)
                    .header("Content-Type", "application/json")
                    .body(json)
                    .timeout(properties.getReadTimeout())
                    .execute();
            return parse(resp.body(), typeRef);
        }
    
        private <T> T parse(String body, TypeReference<T> typeRef) {
            JSONObject obj = JSONUtil.parseObj(body);
            if (!obj.getBool("ok", false)) {
                throw new YuulOpenApiException(
                        obj.getInt("code", -1), obj.getStr("msg", "remote error"));
            }
            Object data = obj.get("data");
            return data == null ? null : JSONUtil.toBean(JSONUtil.toJsonStr(data), typeRef, false);
        }
    }
    

    配置示例 (application.yml)

    yuul:
      openapi:
        base-url: https://api.yuul.cn
        api-key: ${YUUL_OPENAPI_API_KEY:}
        secret-key: ${YUUL_OPENAPI_SECRET_KEY:}
        connect-timeout: 5000
        read-timeout: 30000
    

    ⚠️ secretKey 仅在后端持有,前端切忌直接持有或暴露,所有调用必须经过你的后端 BFF 转发。

    更新于 2026-07-15 19:22:05