From ce9228c5bb08e97434f49291780b93546e2a4071 Mon Sep 17 00:00:00 2001 From: ageerle Date: Mon, 21 Sep 2026 11:07:36 +0800 Subject: [PATCH] fix: preserve application Jackson settings for tenant requests --- docs/tenant-management.md | 47 ++++++ .../config/TenantRequestJacksonTest.java | 143 ++++++++++++++++++ .../org/ruoyi/workflow/config/BeanConfig.java | 5 +- 3 files changed, 193 insertions(+), 2 deletions(-) create mode 100644 docs/tenant-management.md create mode 100644 ruoyi-admin/src/test/java/org/ruoyi/config/TenantRequestJacksonTest.java diff --git a/docs/tenant-management.md b/docs/tenant-management.md new file mode 100644 index 00000000..101462ae --- /dev/null +++ b/docs/tenant-management.md @@ -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 模块和构建器配置。 diff --git a/ruoyi-admin/src/test/java/org/ruoyi/config/TenantRequestJacksonTest.java b/ruoyi-admin/src/test/java/org/ruoyi/config/TenantRequestJacksonTest.java new file mode 100644 index 00000000..55a65b27 --- /dev/null +++ b/ruoyi-admin/src/test/java/org/ruoyi/config/TenantRequestJacksonTest.java @@ -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 name, String remark) { + } +} diff --git a/ruoyi-modules/ruoyi-aiflow/src/main/java/org/ruoyi/workflow/config/BeanConfig.java b/ruoyi-modules/ruoyi-aiflow/src/main/java/org/ruoyi/workflow/config/BeanConfig.java index b36da9e7..77fcc766 100644 --- a/ruoyi-modules/ruoyi-aiflow/src/main/java/org/ruoyi/workflow/config/BeanConfig.java +++ b/ruoyi-modules/ruoyi-aiflow/src/main/java/org/ruoyi/workflow/config/BeanConfig.java @@ -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);