fix: 完善操作日志与 Skill 审计
- 记录操作成功失败状态并保留原始业务异常 - Skill 写请求仅审计资源标识,避免正文和配置泄露
This commit is contained in:
@@ -30,5 +30,17 @@
|
|||||||
<artifactId>javassist</artifactId>
|
<artifactId>javassist</artifactId>
|
||||||
<version>3.29.2-GA</version>
|
<version>3.29.2-GA</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>junit</groupId>
|
||||||
|
<artifactId>junit</artifactId>
|
||||||
|
<version>${junit.version}</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.mockito</groupId>
|
||||||
|
<artifactId>mockito-core</artifactId>
|
||||||
|
<version>5.12.0</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
</project>
|
</project>
|
||||||
|
|||||||
@@ -1,34 +1,48 @@
|
|||||||
package tech.easyflow.log;
|
package tech.easyflow.log;
|
||||||
|
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
|
||||||
import tech.easyflow.common.util.RequestUtil;
|
|
||||||
import tech.easyflow.common.util.StringUtil;
|
|
||||||
|
|
||||||
import tech.easyflow.log.annotation.LogRecord;
|
|
||||||
import tech.easyflow.log.entity.WriteLog;
|
|
||||||
import tech.easyflow.log.mapper.WriteLogMapper;
|
|
||||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
|
||||||
import cn.dev33.satoken.stp.StpUtil;
|
import cn.dev33.satoken.stp.StpUtil;
|
||||||
|
import com.alibaba.fastjson2.JSON;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import org.aspectj.lang.ProceedingJoinPoint;
|
import org.aspectj.lang.ProceedingJoinPoint;
|
||||||
import org.aspectj.lang.annotation.Around;
|
import org.aspectj.lang.annotation.Around;
|
||||||
import org.aspectj.lang.annotation.Aspect;
|
import org.aspectj.lang.annotation.Aspect;
|
||||||
import org.aspectj.lang.annotation.Pointcut;
|
import org.aspectj.lang.annotation.Pointcut;
|
||||||
import org.aspectj.lang.reflect.MethodSignature;
|
import org.aspectj.lang.reflect.MethodSignature;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.web.context.request.RequestContextHolder;
|
import org.springframework.web.context.request.RequestContextHolder;
|
||||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||||
|
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||||
|
import tech.easyflow.common.util.RequestUtil;
|
||||||
|
import tech.easyflow.common.util.StringUtil;
|
||||||
|
import tech.easyflow.log.annotation.LogRecord;
|
||||||
|
import tech.easyflow.log.entity.WriteLog;
|
||||||
|
import tech.easyflow.log.mapper.WriteLogMapper;
|
||||||
|
|
||||||
import java.lang.reflect.Method;
|
import java.lang.reflect.Method;
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.Enumeration;
|
import java.util.Enumeration;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录管理端 Controller 操作结果及最小必要请求摘要。
|
||||||
|
*/
|
||||||
@Aspect
|
@Aspect
|
||||||
@Component
|
@Component
|
||||||
public class LogAspect {
|
public class LogAspect {
|
||||||
|
|
||||||
private static final int maxLengthOfParaValue = 512;
|
private static final Logger LOGGER = LoggerFactory.getLogger(LogAspect.class);
|
||||||
|
private static final int MAX_PARAMETER_VALUE_LENGTH = 512;
|
||||||
|
private static final int MAX_IDENTIFIER_LENGTH = 128;
|
||||||
|
private static final int STATUS_SUCCESS = 1;
|
||||||
|
private static final int STATUS_FAILED = 9;
|
||||||
|
private static final String SKILL_API_PREFIX = "/api/v1/skill";
|
||||||
|
private static final List<String> SKILL_IDENTIFIER_KEYS =
|
||||||
|
List.of("id", "resourceId", "skillId", "sourceId");
|
||||||
|
|
||||||
private final WriteLogMapper logService;
|
private final WriteLogMapper logService;
|
||||||
private final LogRecordProperties config;
|
private final LogRecordProperties config;
|
||||||
@@ -59,9 +73,13 @@ public class LogAspect {
|
|||||||
Class<?> controllerClass = signature.getDeclaringType();
|
Class<?> controllerClass = signature.getDeclaringType();
|
||||||
Method method = signature.getMethod();
|
Method method = signature.getMethod();
|
||||||
String params = getRequestParamsString(request);
|
String params = getRequestParamsString(request);
|
||||||
|
Throwable actionFailure = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return proceedingJoinPoint.proceed();
|
return proceedingJoinPoint.proceed();
|
||||||
|
} catch (Throwable throwable) {
|
||||||
|
actionFailure = throwable;
|
||||||
|
throw throwable;
|
||||||
} finally {
|
} finally {
|
||||||
WriteLog sysLog = new WriteLog();
|
WriteLog sysLog = new WriteLog();
|
||||||
LogRecord logRecord = method.getAnnotation(LogRecord.class);
|
LogRecord logRecord = method.getAnnotation(LogRecord.class);
|
||||||
@@ -77,10 +95,31 @@ public class LogAspect {
|
|||||||
sysLog.setActionUrl(request.getRequestURL().toString());
|
sysLog.setActionUrl(request.getRequestURL().toString());
|
||||||
sysLog.setActionIp(RequestUtil.getIpAddress(request));
|
sysLog.setActionIp(RequestUtil.getIpAddress(request));
|
||||||
sysLog.setActionParams(params);
|
sysLog.setActionParams(params);
|
||||||
sysLog.setStatus(1);
|
sysLog.setActionBody(getSkillAuditBody(request));
|
||||||
|
sysLog.setStatus(actionFailure == null ? STATUS_SUCCESS : STATUS_FAILED);
|
||||||
sysLog.setCreated(new Date());
|
sysLog.setCreated(new Date());
|
||||||
|
|
||||||
|
persistLog(sysLog, actionFailure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 持久化操作日志;业务动作已失败时,日志异常不得覆盖原始异常。
|
||||||
|
*
|
||||||
|
* @param sysLog 操作日志
|
||||||
|
* @param actionFailure 业务动作异常,无异常时为 {@code null}
|
||||||
|
*/
|
||||||
|
private void persistLog(WriteLog sysLog, Throwable actionFailure) {
|
||||||
|
try {
|
||||||
logService.insert(sysLog);
|
logService.insert(sysLog);
|
||||||
|
} catch (RuntimeException logFailure) {
|
||||||
|
if (actionFailure == null) {
|
||||||
|
throw logFailure;
|
||||||
|
}
|
||||||
|
if (actionFailure != logFailure) {
|
||||||
|
actionFailure.addSuppressed(logFailure);
|
||||||
|
}
|
||||||
|
LOGGER.error("记录失败操作日志时发生异常,原始业务异常将继续向上传递", logFailure);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,6 +132,73 @@ public class LogAspect {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提取 Skill 写请求中的顶层资源标识,不记录正文、配置或其他业务字段。
|
||||||
|
*
|
||||||
|
* @param request 当前 HTTP 请求
|
||||||
|
* @return 标识摘要 JSON;非 Skill JSON 请求或无可用标识时返回 {@code null}
|
||||||
|
*/
|
||||||
|
private String getSkillAuditBody(HttpServletRequest request) {
|
||||||
|
String servletPath = request.getServletPath();
|
||||||
|
if (!StringUtil.hasText(servletPath)
|
||||||
|
|| !(servletPath.equals(SKILL_API_PREFIX) || servletPath.startsWith(SKILL_API_PREFIX + "/"))
|
||||||
|
|| !isJsonContentType(request.getContentType())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// JsonBodyArgumentResolver 会把已解析对象缓存在请求属性中,正常路径无需重复读取和解析正文。
|
||||||
|
Object parsed = RequestUtil.readJsonObjectOrArray(request);
|
||||||
|
if (!(parsed instanceof Map<?, ?> values)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Map<String, Object> identifiers = new LinkedHashMap<>();
|
||||||
|
for (String key : SKILL_IDENTIFIER_KEYS) {
|
||||||
|
Object value = safeIdentifier(values.get(key));
|
||||||
|
if (value != null) {
|
||||||
|
identifiers.put(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return identifiers.isEmpty() ? null : JSON.toJSONString(identifiers);
|
||||||
|
} catch (RuntimeException ignored) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断请求内容是否为 JSON。
|
||||||
|
*
|
||||||
|
* @param contentType Content-Type 请求头
|
||||||
|
* @return 是否为 JSON 媒体类型
|
||||||
|
*/
|
||||||
|
private boolean isJsonContentType(String contentType) {
|
||||||
|
if (!StringUtil.hasText(contentType)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String normalized = contentType.toLowerCase(java.util.Locale.ROOT);
|
||||||
|
return normalized.startsWith("application/json") || normalized.contains("+json");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将资源标识收敛为安全、有界的日志值。
|
||||||
|
*
|
||||||
|
* @param value 原始标识值
|
||||||
|
* @return 可记录标识;类型不受支持或为空时返回 {@code null}
|
||||||
|
*/
|
||||||
|
private Object safeIdentifier(Object value) {
|
||||||
|
if (value instanceof Number) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (value instanceof CharSequence sequence) {
|
||||||
|
String identifier = sequence.toString().trim();
|
||||||
|
if (identifier.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return identifier.length() <= MAX_IDENTIFIER_LENGTH
|
||||||
|
? identifier : identifier.substring(0, MAX_IDENTIFIER_LENGTH);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private String getRequestParamsString(HttpServletRequest request) {
|
private String getRequestParamsString(HttpServletRequest request) {
|
||||||
StringBuilder sb = new StringBuilder();
|
StringBuilder sb = new StringBuilder();
|
||||||
Enumeration<String> e = request.getParameterNames();
|
Enumeration<String> e = request.getParameterNames();
|
||||||
@@ -102,8 +208,8 @@ public class LogAspect {
|
|||||||
String[] values = request.getParameterValues(name);
|
String[] values = request.getParameterValues(name);
|
||||||
if (values.length == 1) {
|
if (values.length == 1) {
|
||||||
sb.append(name).append("=");
|
sb.append(name).append("=");
|
||||||
if (values[0] != null && values[0].length() > maxLengthOfParaValue) {
|
if (values[0] != null && values[0].length() > MAX_PARAMETER_VALUE_LENGTH) {
|
||||||
sb.append(values[0], 0, maxLengthOfParaValue).append("...");
|
sb.append(values[0], 0, MAX_PARAMETER_VALUE_LENGTH).append("...");
|
||||||
} else {
|
} else {
|
||||||
sb.append(values[0]);
|
sb.append(values[0]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
package tech.easyflow.log;
|
||||||
|
|
||||||
|
import cn.dev33.satoken.stp.StpUtil;
|
||||||
|
import jakarta.servlet.ReadListener;
|
||||||
|
import jakarta.servlet.ServletInputStream;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import org.aspectj.lang.ProceedingJoinPoint;
|
||||||
|
import org.aspectj.lang.reflect.MethodSignature;
|
||||||
|
import org.junit.After;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.MockedStatic;
|
||||||
|
import org.mockito.Mockito;
|
||||||
|
import org.springframework.web.context.request.RequestContextHolder;
|
||||||
|
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||||
|
import tech.easyflow.log.entity.WriteLog;
|
||||||
|
import tech.easyflow.log.mapper.WriteLogMapper;
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Collections;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertNull;
|
||||||
|
import static org.junit.Assert.assertSame;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.doThrow;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link LogAspect} 操作结果与 Skill 标识审计测试。
|
||||||
|
*/
|
||||||
|
public class LogAspectTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理线程绑定的请求上下文。
|
||||||
|
*/
|
||||||
|
@After
|
||||||
|
public void tearDown() {
|
||||||
|
RequestContextHolder.resetRequestAttributes();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证成功操作记录成功状态,且 Skill body 只保留资源标识。
|
||||||
|
*
|
||||||
|
* @throws Throwable 切面执行异常
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void successfulSkillActionShouldRecordIdentifiersOnly() throws Throwable {
|
||||||
|
WriteLogMapper mapper = mock(WriteLogMapper.class);
|
||||||
|
LogAspect aspect = new LogAspect(mapper, properties());
|
||||||
|
HttpServletRequest request = request("""
|
||||||
|
{"id":101,"resourceId":"202","skillId":303,"name":"secret-name","config":{"token":"secret"}}
|
||||||
|
""");
|
||||||
|
ProceedingJoinPoint joinPoint = joinPoint();
|
||||||
|
Object expected = new Object();
|
||||||
|
when(joinPoint.proceed()).thenReturn(expected);
|
||||||
|
|
||||||
|
try (MockedStatic<StpUtil> stp = Mockito.mockStatic(StpUtil.class)) {
|
||||||
|
stp.when(StpUtil::isLogin).thenReturn(false);
|
||||||
|
assertSame(expected, aspect.doAround(joinPoint));
|
||||||
|
}
|
||||||
|
|
||||||
|
ArgumentCaptor<WriteLog> captor = ArgumentCaptor.forClass(WriteLog.class);
|
||||||
|
verify(mapper).insert(captor.capture());
|
||||||
|
WriteLog log = captor.getValue();
|
||||||
|
assertEquals(Integer.valueOf(1), log.getStatus());
|
||||||
|
assertEquals("{\"id\":101,\"resourceId\":\"202\",\"skillId\":303}", log.getActionBody());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证业务异常原样抛出,同时审计记录失败状态。
|
||||||
|
*
|
||||||
|
* @throws Throwable 预期业务异常
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void failedActionShouldKeepOriginalThrowableAndRecordFailure() throws Throwable {
|
||||||
|
WriteLogMapper mapper = mock(WriteLogMapper.class);
|
||||||
|
LogAspect aspect = new LogAspect(mapper, properties());
|
||||||
|
request("{\"id\":101}");
|
||||||
|
ProceedingJoinPoint joinPoint = joinPoint();
|
||||||
|
IllegalStateException expected = new IllegalStateException("business failed");
|
||||||
|
doThrow(expected).when(joinPoint).proceed();
|
||||||
|
|
||||||
|
Throwable actual = null;
|
||||||
|
try (MockedStatic<StpUtil> stp = Mockito.mockStatic(StpUtil.class)) {
|
||||||
|
stp.when(StpUtil::isLogin).thenReturn(false);
|
||||||
|
try {
|
||||||
|
aspect.doAround(joinPoint);
|
||||||
|
} catch (Throwable throwable) {
|
||||||
|
actual = throwable;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assertSame(expected, actual);
|
||||||
|
ArgumentCaptor<WriteLog> captor = ArgumentCaptor.forClass(WriteLog.class);
|
||||||
|
verify(mapper).insert(captor.capture());
|
||||||
|
assertEquals(Integer.valueOf(9), captor.getValue().getStatus());
|
||||||
|
assertEquals("{\"id\":101}", captor.getValue().getActionBody());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证日志落库异常不会覆盖已发生的业务异常。
|
||||||
|
*
|
||||||
|
* @throws Throwable 预期业务异常
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void loggingFailureShouldNotMaskBusinessFailure() throws Throwable {
|
||||||
|
WriteLogMapper mapper = mock(WriteLogMapper.class);
|
||||||
|
RuntimeException loggingFailure = new RuntimeException("log failed");
|
||||||
|
when(mapper.insert(any(WriteLog.class))).thenThrow(loggingFailure);
|
||||||
|
LogAspect aspect = new LogAspect(mapper, properties());
|
||||||
|
request("{\"id\":101}");
|
||||||
|
ProceedingJoinPoint joinPoint = joinPoint();
|
||||||
|
IllegalArgumentException expected = new IllegalArgumentException("business failed");
|
||||||
|
doThrow(expected).when(joinPoint).proceed();
|
||||||
|
|
||||||
|
Throwable actual = null;
|
||||||
|
try (MockedStatic<StpUtil> stp = Mockito.mockStatic(StpUtil.class)) {
|
||||||
|
stp.when(StpUtil::isLogin).thenReturn(false);
|
||||||
|
try {
|
||||||
|
aspect.doAround(joinPoint);
|
||||||
|
} catch (Throwable throwable) {
|
||||||
|
actual = throwable;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assertSame(expected, actual);
|
||||||
|
assertEquals(1, actual.getSuppressed().length);
|
||||||
|
assertSame(loggingFailure, actual.getSuppressed()[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证畸形 JSON 不会影响业务结果,且不会写入不可信正文。
|
||||||
|
*
|
||||||
|
* @throws Throwable 切面执行异常
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void malformedSkillBodyShouldNotAffectAction() throws Throwable {
|
||||||
|
WriteLogMapper mapper = mock(WriteLogMapper.class);
|
||||||
|
LogAspect aspect = new LogAspect(mapper, properties());
|
||||||
|
request("{invalid-json");
|
||||||
|
ProceedingJoinPoint joinPoint = joinPoint();
|
||||||
|
when(joinPoint.proceed()).thenReturn("ok");
|
||||||
|
|
||||||
|
try (MockedStatic<StpUtil> stp = Mockito.mockStatic(StpUtil.class)) {
|
||||||
|
stp.when(StpUtil::isLogin).thenReturn(false);
|
||||||
|
assertEquals("ok", aspect.doAround(joinPoint));
|
||||||
|
}
|
||||||
|
|
||||||
|
ArgumentCaptor<WriteLog> captor = ArgumentCaptor.forClass(WriteLog.class);
|
||||||
|
verify(mapper).insert(captor.capture());
|
||||||
|
assertEquals(Integer.valueOf(1), captor.getValue().getStatus());
|
||||||
|
assertNull(captor.getValue().getActionBody());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建测试日志配置。
|
||||||
|
*
|
||||||
|
* @return 日志配置
|
||||||
|
*/
|
||||||
|
private LogRecordProperties properties() {
|
||||||
|
LogRecordProperties properties = new LogRecordProperties();
|
||||||
|
properties.setRecordActionPrefix("/api/v1");
|
||||||
|
return properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建并绑定测试请求。
|
||||||
|
*
|
||||||
|
* @param body JSON body
|
||||||
|
* @return 测试请求
|
||||||
|
* @throws IOException 输入流创建失败
|
||||||
|
*/
|
||||||
|
private HttpServletRequest request(String body) throws IOException {
|
||||||
|
HttpServletRequest request = mock(HttpServletRequest.class);
|
||||||
|
when(request.getServletPath()).thenReturn("/api/v1/skill/update");
|
||||||
|
when(request.getContentType()).thenReturn("application/json;charset=UTF-8");
|
||||||
|
when(request.getCharacterEncoding()).thenReturn(StandardCharsets.UTF_8.name());
|
||||||
|
when(request.getParameterNames()).thenReturn(Collections.emptyEnumeration());
|
||||||
|
when(request.getRequestURL()).thenReturn(new StringBuffer("http://localhost/api/v1/skill/update"));
|
||||||
|
when(request.getRemoteAddr()).thenReturn("127.0.0.1");
|
||||||
|
when(request.getInputStream()).thenReturn(inputStream(body));
|
||||||
|
RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建测试连接点。
|
||||||
|
*
|
||||||
|
* @return 测试连接点
|
||||||
|
* @throws NoSuchMethodException 测试方法不存在
|
||||||
|
*/
|
||||||
|
private ProceedingJoinPoint joinPoint() throws NoSuchMethodException {
|
||||||
|
ProceedingJoinPoint joinPoint = mock(ProceedingJoinPoint.class);
|
||||||
|
MethodSignature signature = mock(MethodSignature.class);
|
||||||
|
Method method = TestController.class.getMethod("update");
|
||||||
|
when(joinPoint.getSignature()).thenReturn(signature);
|
||||||
|
when(signature.getDeclaringType()).thenReturn(TestController.class);
|
||||||
|
when(signature.getMethod()).thenReturn(method);
|
||||||
|
return joinPoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建基于字节数组的 Servlet 输入流。
|
||||||
|
*
|
||||||
|
* @param body 请求正文
|
||||||
|
* @return Servlet 输入流
|
||||||
|
*/
|
||||||
|
private ServletInputStream inputStream(String body) {
|
||||||
|
ByteArrayInputStream input = new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8));
|
||||||
|
return new ServletInputStream() {
|
||||||
|
@Override
|
||||||
|
public boolean isFinished() {
|
||||||
|
return input.available() == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isReady() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setReadListener(ReadListener readListener) {
|
||||||
|
// 同步测试输入流不需要异步读取监听。
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int read() {
|
||||||
|
return input.read();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试 Controller 签名载体。
|
||||||
|
*/
|
||||||
|
public static class TestController {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模拟更新入口。
|
||||||
|
*
|
||||||
|
* @return 空结果
|
||||||
|
*/
|
||||||
|
public Object update() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user