官方维护的多语言代码片段:HMAC 签名、HTTP 客户端、Webhook 验签、Manifest 拉取等
示例:供应商如何接收平台的一键开服 Webhook 推送、按 X-Webhook-Signature 头校验 HMAC-SHA256 签名。
平台会向您在 OpenApiApp 配置的 provisionEndpoint 推送一键开服事件,您需:
X-Webhook-Event、X-Webhook-Signature、X-Request-IdprovisionWebhookSecret 计算 HMAC-SHA256(rawBody),与签名比对manifestToken 用于第二步主动拉取 manifest@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。
示例:零依赖签名工具类,可直接复制到任意 Java 项目使用。算法与 forum-backend OpenApiSignatureUtil 完全对齐。
StringToSign = METHOD.toUpperCase() + "\n" + PATH + "\n" + TIMESTAMP_MILLIS
Signature = Base64(HMAC-SHA256(secretKey, StringToSign))
GET / POST / PUT / DELETE/openapi/v2/resource/mods/123+/=)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;
}
}
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输出一致,即可确认接入正确。
示例:供应商完成部署后,调用回调接口上报 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 字 |
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 为终结态,进入终结态后服务端将拒绝再次修改,请确保最终状态只上报一次。
示例:Java 客户端统一封装,自动在每次请求注入 X-API-Key / X-Timestamp / X-Signature 三个头,无需调用方关心签名细节。
ResponseDTO<T>:ok=false 直接抛业务异常,调用方代码不必到处判 code@ConfigurationProperties,避免硬编码@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);
}
}
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 转发。