实现 M002 订单任务入站与队列规则
This commit is contained in:
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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 {
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
"请求体超过允许大小。");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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 Mapper,Repository 负责屏蔽唯一键冲突细节。
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user