实现 M002 订单任务入站与队列规则

This commit is contained in:
andy
2026-07-07 14:29:43 +08:00
parent 7e276469ef
commit f7d77b45bd
78 changed files with 11703 additions and 1 deletions

View File

@@ -0,0 +1,15 @@
package cn.nianxx.thhotel.integrations.ai.superagent.common.request;
/**
* SuperAgent 任务结果入站签名校验请求。只包含鉴权所需上下文,不承载业务 JSON 解析结果。
*/
public record SuperAgentTaskResultSecurityRequest(
String httpMethod,
String requestPath,
String clientId,
String timestamp,
String nonce,
String signature,
String rawBody
) {
}

View File

@@ -0,0 +1,17 @@
package cn.nianxx.thhotel.integrations.ai.superagent.common.result;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
/**
* SuperAgent 任务结果入站错误响应。错误信息必须安全,不返回 Secret、签名原文或完整请求体。
*/
public record SuperAgentTaskResultErrorResponse(
@JsonProperty("request_id")
String requestId,
@JsonProperty("error_code")
String errorCode,
String message,
List<String> details
) {
}

View File

@@ -0,0 +1,207 @@
package cn.nianxx.thhotel.integrations.ai.superagent.control;
import cn.nianxx.thhotel.integrations.ai.superagent.common.result.SuperAgentTaskResultErrorResponse;
import cn.nianxx.thhotel.integrations.ai.superagent.service.impl.SuperAgentTaskResultProperties;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ReadListener;
import jakarta.servlet.ServletInputStream;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import jakarta.servlet.http.HttpServletResponse;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
/**
* SuperAgent 任务结果请求体大小过滤器。进入 Controller 前拦截明显超限请求,并对未知长度请求做读取限流。
*/
@Component
public class SuperAgentTaskResultBodySizeFilter extends OncePerRequestFilter {
private static final String REQUEST_PATH = "/api/integrations/superagent/task-results";
private final SuperAgentTaskResultProperties properties;
private final ObjectMapper objectMapper;
/**
* 注入 SuperAgent 请求大小配置和 JSON 序列化器,用于输出统一安全错误响应。
*/
public SuperAgentTaskResultBodySizeFilter(
SuperAgentTaskResultProperties properties,
ObjectMapper objectMapper) {
this.properties = properties;
this.objectMapper = objectMapper;
}
/**
* 对 SuperAgent 入站接口执行大小预检查和流式限流,超限时不再进入业务处理。
*/
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
long maxBodyBytes = properties.getMaxBodyBytes();
long contentLength = request.getContentLengthLong();
if (matchesTaskResultEndpoint(request) && maxBodyBytes >= 0 && contentLength > maxBodyBytes) {
writePayloadTooLarge(response);
return;
}
if (matchesTaskResultEndpoint(request) && maxBodyBytes >= 0) {
try {
filterChain.doFilter(new LimitedBodyRequest(request, maxBodyBytes), response);
} catch (RequestBodyTooLargeException exception) {
if (!response.isCommitted()) {
writePayloadTooLarge(response);
}
}
return;
}
filterChain.doFilter(request, response);
}
/**
* 判断当前请求是否为 SuperAgent 任务结果入站接口。
*/
private boolean matchesTaskResultEndpoint(HttpServletRequest request) {
return "POST".equalsIgnoreCase(request.getMethod()) && REQUEST_PATH.equals(request.getServletPath());
}
/**
* 写出统一 413 错误,避免响应中包含原始请求体或 Secret。
*/
private void writePayloadTooLarge(HttpServletResponse response) throws IOException {
response.setStatus(HttpStatus.PAYLOAD_TOO_LARGE.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
objectMapper.writeValue(response.getWriter(), new SuperAgentTaskResultErrorResponse(
null,
"REQUEST_BODY_TOO_LARGE",
"请求体超过允许大小。",
List.of()));
}
/**
* 限流请求包装器。用于 Content-Length 不可靠或缺失时,在读取请求体过程中强制限制字节数。
*/
private static final class LimitedBodyRequest extends HttpServletRequestWrapper {
private final long maxBodyBytes;
/**
* 包装原始请求,并记录最大允许读取字节数。
*/
private LimitedBodyRequest(HttpServletRequest request, long maxBodyBytes) {
super(request);
this.maxBodyBytes = maxBodyBytes;
}
/**
* 返回带字节计数的输入流。
*/
@Override
public ServletInputStream getInputStream() throws IOException {
return new LimitedServletInputStream(super.getInputStream(), maxBodyBytes);
}
/**
* 返回带字节计数的字符读取器,保持请求原始字符集。
*/
@Override
public BufferedReader getReader() throws IOException {
String encoding = getCharacterEncoding();
Charset charset = encoding == null ? StandardCharsets.UTF_8 : Charset.forName(encoding);
return new BufferedReader(new InputStreamReader(getInputStream(), charset));
}
}
/**
* 带读取上限的 ServletInputStream。超过限制立即抛出受控 IO 异常。
*/
private static final class LimitedServletInputStream extends ServletInputStream {
private final ServletInputStream delegate;
private final long maxBodyBytes;
private long bytesRead;
/**
* 注入原始输入流和最大允许读取字节数。
*/
private LimitedServletInputStream(ServletInputStream delegate, long maxBodyBytes) {
this.delegate = delegate;
this.maxBodyBytes = maxBodyBytes;
}
/**
* 逐字节读取并累计大小,超过限制则中断。
*/
@Override
public int read() throws IOException {
int value = delegate.read();
if (value != -1) {
countBytes(1);
}
return value;
}
/**
* 批量读取并累计实际读取大小,超过限制则中断。
*/
@Override
public int read(byte[] buffer, int offset, int length) throws IOException {
int count = delegate.read(buffer, offset, length);
if (count > 0) {
countBytes(count);
}
return count;
}
/**
* 委托容器判断流是否结束。
*/
@Override
public boolean isFinished() {
return delegate.isFinished();
}
/**
* 委托容器判断流是否可读。
*/
@Override
public boolean isReady() {
return delegate.isReady();
}
/**
* 委托异步读取监听器。
*/
@Override
public void setReadListener(ReadListener readListener) {
delegate.setReadListener(readListener);
}
/**
* 累计已读字节数,超过上限时抛出专用异常。
*/
private void countBytes(int count) throws IOException {
bytesRead += count;
if (bytesRead > maxBodyBytes) {
throw new RequestBodyTooLargeException();
}
}
}
/**
* 请求体读取超限异常。只在过滤器内部转换为 413 响应。
*/
private static final class RequestBodyTooLargeException extends IOException {
}
}

View File

@@ -0,0 +1,84 @@
package cn.nianxx.thhotel.integrations.ai.superagent.control;
import cn.nianxx.thhotel.integrations.ai.superagent.common.request.SuperAgentTaskResultSecurityRequest;
import cn.nianxx.thhotel.integrations.ai.superagent.service.SuperAgentTaskResultSecurityService;
import cn.nianxx.thhotel.integrations.ai.superagent.service.impl.SuperAgentTaskResultException;
import cn.nianxx.thhotel.integrations.ai.superagent.service.impl.SuperAgentTaskResultProperties;
import cn.nianxx.thhotel.workflows.reservation.common.result.SuperAgentTaskResultResponse;
import cn.nianxx.thhotel.workflows.reservation.service.ReservationAiTaskIntakeService;
import java.nio.charset.StandardCharsets;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* SuperAgent 任务结果入站接口。Controller 只处理外部协议、安全校验和请求分发。
*/
@RestController
@RequestMapping("/api/integrations/superagent/task-results")
public class SuperAgentTaskResultController {
private static final String REQUEST_PATH = "/api/integrations/superagent/task-results";
private final SuperAgentTaskResultSecurityService securityService;
private final SuperAgentTaskResultProperties properties;
private final ReservationAiTaskIntakeService intakeService;
/**
* 注入 SuperAgent 安全服务和 Reservation 接收服务,避免 Controller 直接访问业务持久化层。
*/
public SuperAgentTaskResultController(
SuperAgentTaskResultSecurityService securityService,
SuperAgentTaskResultProperties properties,
ReservationAiTaskIntakeService intakeService) {
this.securityService = securityService;
this.properties = properties;
this.intakeService = intakeService;
}
/**
* 接收 SuperAgent AI 任务结果,先限制请求体大小,再完成 HMAC 鉴权,最后委托业务服务创建 CP1-3 数据。
*/
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<SuperAgentTaskResultResponse> accept(
@RequestBody(required = false) String rawBody,
@RequestHeader(name = "X-TH-Hotel-SuperAgent-Client-Id", required = false) String clientId,
@RequestHeader(name = "X-TH-Hotel-SuperAgent-Timestamp", required = false) String timestamp,
@RequestHeader(name = "X-TH-Hotel-SuperAgent-Nonce", required = false) String nonce,
@RequestHeader(name = "X-TH-Hotel-SuperAgent-Signature", required = false) String signature,
@RequestHeader(name = "X-TH-Hotel-Request-Id", required = false) String requestId) {
String requestBody = rawBody == null ? "" : rawBody;
rejectBodyWhenTooLarge(requestBody);
securityService.verify(new SuperAgentTaskResultSecurityRequest(
"POST",
REQUEST_PATH,
clientId,
timestamp,
nonce,
signature,
requestBody
));
SuperAgentTaskResultResponse response = intakeService.accept(requestBody, clientId, requestId);
HttpStatus status = response.idempotentReplay() ? HttpStatus.OK : HttpStatus.CREATED;
return ResponseEntity.status(status).body(response);
}
/**
* 在解析 JSON 和校验 Header 之前限制请求体大小,避免超限请求进入后续处理。
*/
private void rejectBodyWhenTooLarge(String rawBody) {
long maxBodyBytes = properties.getMaxBodyBytes();
int actualBytes = rawBody.getBytes(StandardCharsets.UTF_8).length;
if (maxBodyBytes >= 0 && actualBytes > maxBodyBytes) {
throw new SuperAgentTaskResultException(
HttpStatus.PAYLOAD_TOO_LARGE,
"REQUEST_BODY_TOO_LARGE",
"请求体超过允许大小。");
}
}
}

View File

@@ -0,0 +1,43 @@
package cn.nianxx.thhotel.integrations.ai.superagent.control;
import cn.nianxx.thhotel.integrations.ai.superagent.common.result.SuperAgentTaskResultErrorResponse;
import cn.nianxx.thhotel.integrations.ai.superagent.service.impl.SuperAgentTaskResultException;
import cn.nianxx.thhotel.workflows.reservation.service.impl.ReservationAiTaskIntakeException;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/**
* SuperAgent 任务结果入站统一异常处理。错误响应只返回安全错误码和简短说明。
*/
@RestControllerAdvice(assignableTypes = SuperAgentTaskResultController.class)
public class SuperAgentTaskResultControllerAdvice {
/**
* 处理 SuperAgent 鉴权、请求大小等协议层受控异常。
*/
@ExceptionHandler(SuperAgentTaskResultException.class)
public ResponseEntity<SuperAgentTaskResultErrorResponse> handleSecurityException(
SuperAgentTaskResultException exception) {
return ResponseEntity.status(exception.getStatus())
.body(error(exception.getErrorCode(), exception.getMessage()));
}
/**
* 处理 Reservation 接收阶段的 SourceMessage、幂等和 AI item 技术校验异常。
*/
@ExceptionHandler(ReservationAiTaskIntakeException.class)
public ResponseEntity<SuperAgentTaskResultErrorResponse> handleIntakeException(
ReservationAiTaskIntakeException exception) {
return ResponseEntity.status(exception.getStatus())
.body(error(exception.getErrorCode(), exception.getMessage()));
}
/**
* 构建统一错误响应,第一版不回显 request_id避免异常路径暴露未经校验的外部输入。
*/
private SuperAgentTaskResultErrorResponse error(String errorCode, String message) {
return new SuperAgentTaskResultErrorResponse(null, errorCode, message, List.of());
}
}

View File

@@ -0,0 +1,65 @@
package cn.nianxx.thhotel.integrations.ai.superagent.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import java.time.LocalDateTime;
/**
* SuperAgent 任务结果入站 nonce 实体。用于 HMAC 请求防重放,不保存签名 secret。
*/
@TableName("integration_superagent_task_result_nonce")
public class SuperAgentTaskResultNonceEntity {
/** Nonce 记录 ID。 */
@TableId(type = IdType.ASSIGN_ID)
private Long id;
/** SuperAgent 调用方客户端 ID。 */
private String clientId;
/** 单次请求随机值,和 clientId 组成防重放唯一键。 */
private String nonce;
/** Nonce 过期 UTC 时间。 */
private LocalDateTime expiresAt;
/** 记录创建 UTC 时间。 */
private LocalDateTime createdAt;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getClientId() {
return clientId;
}
public void setClientId(String clientId) {
this.clientId = clientId;
}
public String getNonce() {
return nonce;
}
public void setNonce(String nonce) {
this.nonce = nonce;
}
public LocalDateTime getExpiresAt() {
return expiresAt;
}
public void setExpiresAt(LocalDateTime expiresAt) {
this.expiresAt = expiresAt;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
}

View File

@@ -0,0 +1,12 @@
package cn.nianxx.thhotel.integrations.ai.superagent.mapper;
import cn.nianxx.thhotel.integrations.ai.superagent.domain.SuperAgentTaskResultNonceEntity;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
/**
* SuperAgent 任务结果入站 nonce Mapper只负责防重放表访问。
*/
@Mapper
public interface SuperAgentTaskResultNonceMapper extends BaseMapper<SuperAgentTaskResultNonceEntity> {
}

View File

@@ -0,0 +1,46 @@
package cn.nianxx.thhotel.integrations.ai.superagent.repository;
import cn.nianxx.thhotel.integrations.ai.superagent.domain.SuperAgentTaskResultNonceEntity;
import cn.nianxx.thhotel.integrations.ai.superagent.mapper.SuperAgentTaskResultNonceMapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import java.time.LocalDateTime;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Repository;
/**
* SuperAgent nonce 的 MyBatis-Plus 持久化实现。数据库唯一键是防重放最终防线。
*/
@Repository
public class MybatisSuperAgentTaskResultNonceRepository implements SuperAgentTaskResultNonceRepository {
private final SuperAgentTaskResultNonceMapper nonceMapper;
/**
* 注入 nonce MapperRepository 负责屏蔽唯一键冲突细节。
*/
public MybatisSuperAgentTaskResultNonceRepository(SuperAgentTaskResultNonceMapper nonceMapper) {
this.nonceMapper = nonceMapper;
}
/**
* 插入 nonce 防重放记录;并发重复请求会命中唯一键并返回 false。
*/
@Override
public boolean saveIfAbsent(String clientId, String nonce, LocalDateTime expiresAt, LocalDateTime createdAt) {
nonceMapper.delete(Wrappers.<SuperAgentTaskResultNonceEntity>lambdaQuery()
.eq(SuperAgentTaskResultNonceEntity::getClientId, clientId)
.eq(SuperAgentTaskResultNonceEntity::getNonce, nonce)
.lt(SuperAgentTaskResultNonceEntity::getExpiresAt, createdAt));
SuperAgentTaskResultNonceEntity entity = new SuperAgentTaskResultNonceEntity();
entity.setClientId(clientId);
entity.setNonce(nonce);
entity.setExpiresAt(expiresAt);
entity.setCreatedAt(createdAt);
try {
nonceMapper.insert(entity);
return true;
} catch (DuplicateKeyException exception) {
return false;
}
}
}

View File

@@ -0,0 +1,14 @@
package cn.nianxx.thhotel.integrations.ai.superagent.repository;
import java.time.LocalDateTime;
/**
* SuperAgent 入站 nonce 持久化边界。Service 通过该接口完成防重放,不直接访问 Mapper。
*/
public interface SuperAgentTaskResultNonceRepository {
/**
* 保存本次请求 nonce。若同一调用方 nonce 已存在,返回 false 表示重放请求。
*/
boolean saveIfAbsent(String clientId, String nonce, LocalDateTime expiresAt, LocalDateTime createdAt);
}

View File

@@ -0,0 +1,14 @@
package cn.nianxx.thhotel.integrations.ai.superagent.service;
import cn.nianxx.thhotel.integrations.ai.superagent.common.request.SuperAgentTaskResultSecurityRequest;
/**
* SuperAgent 任务结果入站安全服务。负责 HMAC、时间窗口和 nonce 防重放。
*/
public interface SuperAgentTaskResultSecurityService {
/**
* 校验 SuperAgent 入站请求签名。失败时抛出受控异常,成功时记录 nonce。
*/
void verify(SuperAgentTaskResultSecurityRequest request);
}

View File

@@ -0,0 +1,26 @@
package cn.nianxx.thhotel.integrations.ai.superagent.service.impl;
import org.springframework.http.HttpStatus;
/**
* SuperAgent 任务结果入站受控异常。只携带安全错误码和简短提示。
*/
public class SuperAgentTaskResultException extends RuntimeException {
private final HttpStatus status;
private final String errorCode;
public SuperAgentTaskResultException(HttpStatus status, String errorCode, String message) {
super(message);
this.status = status;
this.errorCode = errorCode;
}
public HttpStatus getStatus() {
return status;
}
public String getErrorCode() {
return errorCode;
}
}

View File

@@ -0,0 +1,53 @@
package cn.nianxx.thhotel.integrations.ai.superagent.service.impl;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* SuperAgent 任务结果入站配置。Secret 只能来自环境变量或部署平台 Secret。
*/
@Component
@ConfigurationProperties(prefix = "superagent.task-result")
public class SuperAgentTaskResultProperties {
/** HMAC 签名密钥,生产环境不能为空。 */
private String hmacSecret = "";
/** 请求时间允许偏移秒数。 */
private long clockSkewSeconds = 300;
/** nonce 防重放保存秒数。 */
private long nonceTtlSeconds = 600;
/** 请求体最大字节数。 */
private long maxBodyBytes = 1048576;
public String getHmacSecret() {
return hmacSecret;
}
public void setHmacSecret(String hmacSecret) {
this.hmacSecret = hmacSecret;
}
public long getClockSkewSeconds() {
return clockSkewSeconds;
}
public void setClockSkewSeconds(long clockSkewSeconds) {
this.clockSkewSeconds = clockSkewSeconds;
}
public long getNonceTtlSeconds() {
return nonceTtlSeconds;
}
public void setNonceTtlSeconds(long nonceTtlSeconds) {
this.nonceTtlSeconds = nonceTtlSeconds;
}
public long getMaxBodyBytes() {
return maxBodyBytes;
}
public void setMaxBodyBytes(long maxBodyBytes) {
this.maxBodyBytes = maxBodyBytes;
}
}

View File

@@ -0,0 +1,174 @@
package cn.nianxx.thhotel.integrations.ai.superagent.service.impl;
import cn.nianxx.thhotel.integrations.ai.superagent.common.request.SuperAgentTaskResultSecurityRequest;
import cn.nianxx.thhotel.integrations.ai.superagent.repository.SuperAgentTaskResultNonceRepository;
import cn.nianxx.thhotel.integrations.ai.superagent.service.SuperAgentTaskResultSecurityService;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.HexFormat;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
/**
* SuperAgent HMAC 签名校验实现。该服务不解析业务 JSON避免鉴权前处理外部业务内容。
*/
@Service
public class SuperAgentTaskResultSecurityServiceImpl implements SuperAgentTaskResultSecurityService {
private static final String SIGNATURE_PREFIX = "sha256=";
private static final int CLIENT_ID_MAX_LENGTH = 128;
private static final int TIMESTAMP_MAX_LENGTH = 64;
private static final int NONCE_MAX_LENGTH = 256;
private static final int SIGNATURE_MAX_LENGTH = 71;
private final SuperAgentTaskResultProperties properties;
private final SuperAgentTaskResultNonceRepository nonceRepository;
private final Clock clock;
/**
* 注入配置、nonce 持久化边界和 UTC 时钟,便于后续测试时间窗口。
*/
public SuperAgentTaskResultSecurityServiceImpl(
SuperAgentTaskResultProperties properties,
SuperAgentTaskResultNonceRepository nonceRepository) {
this.properties = properties;
this.nonceRepository = nonceRepository;
this.clock = Clock.systemUTC();
}
/**
* 按 Header 完整性、时间窗口、签名和 nonce 顺序校验请求;失败时只返回安全错误码。
*/
@Override
public void verify(SuperAgentTaskResultSecurityRequest request) {
String clientId = requireHeader(request.clientId(), CLIENT_ID_MAX_LENGTH);
String timestamp = requireHeader(request.timestamp(), TIMESTAMP_MAX_LENGTH);
String nonce = requireHeader(request.nonce(), NONCE_MAX_LENGTH);
String signature = requireHeader(request.signature(), SIGNATURE_MAX_LENGTH);
String secret = trimToNull(properties.getHmacSecret());
if (secret == null) {
throw error(HttpStatus.UNAUTHORIZED, "AUTH_SIGNATURE_INVALID", "签名校验失败。");
}
Instant requestInstant = parseTimestamp(timestamp);
long skew = Math.max(properties.getClockSkewSeconds(), 0L);
if (Math.abs(Duration.between(Instant.now(clock), requestInstant).toSeconds()) > skew) {
throw error(HttpStatus.UNAUTHORIZED, "AUTH_TIMESTAMP_INVALID", "请求时间无效或超出窗口。");
}
String expectedSignature = SIGNATURE_PREFIX + hmac(secret, canonicalString(request, timestamp, nonce, clientId));
if (!constantTimeEquals(expectedSignature, signature)) {
throw error(HttpStatus.UNAUTHORIZED, "AUTH_SIGNATURE_INVALID", "签名校验失败。");
}
LocalDateTime now = LocalDateTime.ofInstant(Instant.now(clock), ZoneOffset.UTC);
LocalDateTime expiresAt = now.plusSeconds(Math.max(properties.getNonceTtlSeconds(), 1L));
if (!nonceRepository.saveIfAbsent(clientId, nonce, expiresAt, now)) {
throw error(HttpStatus.CONFLICT, "AUTH_NONCE_REPLAY", "Nonce 已被使用。");
}
}
/**
* 校验必填 Header 文本,避免空白调用方或 nonce 进入签名逻辑。
*/
private String requireHeader(String value, int maxLength) {
String trimmed = trimToNull(value);
if (trimmed == null) {
throw error(HttpStatus.UNAUTHORIZED, "AUTH_HEADER_MISSING", "鉴权 Header 缺失。");
}
if (trimmed.length() > maxLength) {
throw error(HttpStatus.UNAUTHORIZED, "AUTH_HEADER_INVALID", "鉴权 Header 无效。");
}
return trimmed;
}
/**
* 解析 ISO-8601 UTC 时间,格式错误统一返回时间非法。
*/
private Instant parseTimestamp(String timestamp) {
try {
return Instant.parse(timestamp);
} catch (RuntimeException exception) {
throw error(HttpStatus.UNAUTHORIZED, "AUTH_TIMESTAMP_INVALID", "请求时间无效或超出窗口。");
}
}
/**
* 按契约拼接规范签名串,确保双方签名输入一致。
*/
private String canonicalString(
SuperAgentTaskResultSecurityRequest request,
String timestamp,
String nonce,
String clientId) {
return request.httpMethod() + "\n"
+ request.requestPath() + "\n"
+ timestamp + "\n"
+ nonce + "\n"
+ clientId + "\n"
+ sha256(request.rawBody() == null ? "" : request.rawBody());
}
/**
* 对规范签名串计算 HMAC-SHA256。
*/
private String hmac(String secret, String canonical) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return HexFormat.of().formatHex(mac.doFinal(canonical.getBytes(StandardCharsets.UTF_8)));
} catch (Exception exception) {
throw new IllegalStateException("当前 Java 运行时不支持 HmacSHA256", exception);
}
}
/**
* 计算原始请求体 SHA-256签名只使用哈希不把请求体写入日志。
*/
private String sha256(String value) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("当前 Java 运行时不支持 SHA-256", exception);
}
}
/**
* 常量时间比较签名,避免因字符串比较提前退出泄漏签名差异。
*/
private boolean constantTimeEquals(String expected, String actual) {
if (expected == null || actual == null) {
return false;
}
return MessageDigest.isEqual(
expected.getBytes(StandardCharsets.UTF_8),
actual.getBytes(StandardCharsets.UTF_8));
}
/**
* 构建安全受控异常,响应不包含外部请求原文。
*/
private SuperAgentTaskResultException error(HttpStatus status, String errorCode, String message) {
return new SuperAgentTaskResultException(status, errorCode, message);
}
/**
* 将空白字符串统一为 null。
*/
private String trimToNull(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
}