fix: preserve application Jackson settings for tenant requests

This commit is contained in:
ageerle
2026-09-21 11:07:36 +08:00
parent 6f19c86fe6
commit ce9228c5bb
3 changed files with 193 additions and 2 deletions

47
docs/tenant-management.md Normal file
View File

@@ -0,0 +1,47 @@
# 新增租户接口
开启 `tenant.enable: true` 后,超级管理员可通过 `POST /system/tenant` 创建租户。请求需要登录凭证和 `system:tenant:add` 权限;使用反向代理时,请按部署配置添加接口前缀。
## 请求参数
请求使用 `Content-Type: application/json`。
| 参数 | 说明 |
| --- | --- |
| `companyName` | 企业名称,必填且不能与已有租户重复。 |
| `contactUserName` | 联系人,必填。 |
| `contactPhone` | 联系电话,必填。 |
| `username` | 租户初始管理员用户名,必填。 |
| `password` | 租户初始管理员密码,必填。 |
| `packageId` | 已存在的租户套餐 ID,必填。建议使用字符串传输,避免浏览器对大整数的精度损失。 |
| `expireTime` | 过期时间,使用 `yyyy-MM-dd HH:mm:ss`,例如 `2027-09-11 00:00:00`;不传或传 `null` 表示不限制有效期。未带时区的时间按后端运行时区解释。 |
| `accountCount` | 可创建的用户数量,`-1` 表示不限制。 |
## 请求示例
请将套餐 ID 替换为当前系统中的有效套餐,并设置实际使用的管理员账号和密码。
```json
{
"companyName": "示例企业",
"contactUserName": "联系人",
"contactPhone": "13800000000",
"username": "tenant_admin",
"password": "ChangeMe_123456!",
"packageId": "2018611998196109314",
"expireTime": "2027-09-11 00:00:00",
"accountCount": -1
}
```
## 日期配置
后端默认日期格式配置位于 `ruoyi-admin/src/main/resources/application.yml`:
```yaml
spring:
jackson:
date-format: yyyy-MM-dd HH:mm:ss
```
扩展应用的 `ObjectMapper` 时,使用 Spring 注入的 `Jackson2ObjectMapperBuilder`,以保留全局日期格式、自定义 Jackson 模块和构建器配置。

View File

@@ -0,0 +1,143 @@
package org.ruoyi.config;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.ruoyi.common.json.config.JacksonConfig;
import org.ruoyi.system.domain.bo.SysTenantBo;
import org.ruoyi.workflow.config.BeanConfig;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration;
import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.mock.http.MockHttpInputMessage;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Date;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@Tag("dev")
class TenantRequestJacksonTest {
private static final String TENANT_REQUEST = """
{
"contactPhone": "13700000000",
"expireTime": "2027-09-11 00:00:00",
"accountCount": -1,
"companyName": "第一集",
"contactUserName": "刘备",
"username": "user",
"password": "123456",
"packageId": "2018611998196109314"
}
""";
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(JacksonConfig.class, JacksonAutoConfiguration.class,
HttpMessageConvertersAutoConfiguration.class))
.withUserConfiguration(BeanConfig.class);
@Test
void readsTenantRequestWithApplicationDateFormat() {
contextRunner.withPropertyValues("spring.jackson.date-format=yyyy-MM-dd HH:mm:ss").run(context -> {
MappingJackson2HttpMessageConverter converter = context.getBean(MappingJackson2HttpMessageConverter.class);
assertSame(context.getBean(ObjectMapper.class), converter.getObjectMapper());
SysTenantBo tenant = readTenant(converter, TENANT_REQUEST);
assertEquals(expirationDate(), tenant.getExpireTime());
assertEquals(2018611998196109314L, tenant.getPackageId());
assertEquals(-1L, tenant.getAccountCount());
assertEquals("第一集", tenant.getCompanyName());
assertEquals("user", tenant.getUsername());
});
}
@Test
void keepsRegisteredDateDeserializerWithoutDateFormatProperty() {
contextRunner.run(context -> {
SysTenantBo tenant = readTenant(context.getBean(MappingJackson2HttpMessageConverter.class), TENANT_REQUEST);
assertEquals(expirationDate(), tenant.getExpireTime());
});
}
@Test
void honorsConfiguredDateSerializationFormat() {
contextRunner.withPropertyValues("spring.jackson.date-format=yyyy/MM/dd HH:mm:ss").run(context -> {
ObjectMapper mapper = context.getBean(ObjectMapper.class);
String json = mapper.writeValueAsString(new DateValue(expirationDate()));
assertEquals("{\"expireTime\":\"2027/09/11 00:00:00\"}", json);
});
}
@Test
void preservesWorkflowJsonRoundTripAndNullOmission() {
contextRunner.run(context -> {
ObjectMapper mapper = context.getBean(ObjectMapper.class);
WorkflowValue value = new WorkflowValue(2018611998196109314L,
LocalDateTime.of(2027, 9, 11, 0, 0), Optional.of("workflow"), null);
String json = mapper.writeValueAsString(value);
JsonNode tree = mapper.readTree(json);
assertTrue(tree.get("id").isTextual());
assertEquals("2018611998196109314", tree.get("id").asText());
assertEquals("2027-09-11 00:00:00", tree.get("createdAt").asText());
assertEquals("workflow", tree.get("name").asText());
assertFalse(tree.has("remark"));
assertEquals(value, mapper.readValue(json, WorkflowValue.class));
});
}
@Test
void acceptsNullExpirationDate() {
contextRunner.run(context -> {
String request = TENANT_REQUEST.replace("\"2027-09-11 00:00:00\"", "null");
SysTenantBo tenant = readTenant(context.getBean(MappingJackson2HttpMessageConverter.class), request);
assertNull(tenant.getExpireTime());
});
}
@Test
void rejectsInvalidExpirationDate() {
contextRunner.run(context -> {
String request = TENANT_REQUEST.replace("2027-09-11 00:00:00", "invalid-date");
MappingJackson2HttpMessageConverter converter = context.getBean(MappingJackson2HttpMessageConverter.class);
assertThrows(HttpMessageNotReadableException.class, () -> readTenant(converter, request));
});
}
private static SysTenantBo readTenant(MappingJackson2HttpMessageConverter converter, String json) throws Exception {
MockHttpInputMessage input = new MockHttpInputMessage(json.getBytes(StandardCharsets.UTF_8));
input.getHeaders().setContentType(MediaType.APPLICATION_JSON);
return (SysTenantBo) converter.read(SysTenantBo.class, input);
}
private static Date expirationDate() {
return Date.from(LocalDateTime.of(2027, 9, 11, 0, 0).atZone(ZoneId.systemDefault()).toInstant());
}
private record DateValue(Date expireTime) {
}
private record WorkflowValue(Long id, LocalDateTime createdAt, Optional<String> name, String remark) {
}
}

View File

@@ -39,9 +39,10 @@ public class BeanConfig {
@Bean
@Primary
public ObjectMapper objectMapper() {
public ObjectMapper objectMapper(Jackson2ObjectMapperBuilder builder) {
log.info("Configuration:create objectMapper");
ObjectMapper objectMapper = new Jackson2ObjectMapperBuilder().createXmlMapper(false).build();
// 使用容器中的构建器,保留全局日期格式、Module 和 Customizer 配置。
ObjectMapper objectMapper = builder.createXmlMapper(false).build();
objectMapper.registerModules(LocalDateTimeUtil.getSimpleModule(), new JavaTimeModule(), new Jdk8Module());
//设置null值不参与序列化(字段不被显示)
objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);