merge: downgrade backend to java 8

master
chenyuan 4 weeks ago
commit 454c5cef9e

@ -2,6 +2,12 @@
开发规范与模块目录约定见 [开发规范](docs/DEVELOPMENT_GUIDE.md)。
## 运行环境
- JDK 8已按 Temurin 8 验证)
- Spring Boot 2.7.18、Spring Security 5.7、MyBatis-Plus 3.5.17、Springdoc 1.8.0
- MySQL 8
## 登录方式
- 主平台单点登录:主平台跳转至 `GET /api/v1/auth/sso?token=...`。后端以主平台教师或学生档案的 `add_time` 动态 HMAC 密钥验证 Token生成一次性 `code` 并跳转至前端回调地址。

@ -2,6 +2,10 @@
本文档是本项目新增模块、接口和数据表时的统一约定。目标是在业务持续扩展时保持领域边界清晰、认证安全、数据可演进。
## 运行时版本
项目运行与构建统一使用 JDK 8框架基线为 Spring Boot 2.7.18、Spring Security 5.7、MyBatis-Plus 3.5.17 和 Springdoc 1.8.0。新增代码不得使用 Java 9 及以上的语言特性或标准库 APIServlet 与 Bean Validation 使用 `javax.*` 包。
## 1. 架构总览
项目采用 DDD 的“按限界上下文分包”方式。一个顶层模块代表一个业务能力,而不是一个技术层。
@ -104,7 +108,7 @@ infrastructure → domain / application 定义的端口
## 6. API 与异常规范
- REST Controller 放在 `<module>.interfaces.rest`,路径统一以 `/api/v1/` 开头。
- 请求 DTO 使用 `jakarta.validation` 注解Controller 参数标注 `@Valid`
- 请求 DTO 使用 `javax.validation` 注解Controller 参数标注 `@Valid`
- 对外成功响应统一使用 `ApiResponse<T>`,不要自行定义不一致的响应包装格式。
- 可预期业务错误抛出 `BusinessException` 并使用 `ErrorCode`;不要在 Controller 中 `try/catch` 后吞掉异常。
- 接口响应只返回 DTO不返回 MyBatis Entity、密码哈希、刷新令牌摘要或主平台 Token。

@ -10,7 +10,7 @@
## Global Constraints
- Maven 必须以 `release 8` 编译;不得保留 Java 9+ 或 Java 17 语言/API 依赖。
- Maven 必须以 JDK 8 以及 `source`/`target` 8 编译;不得保留 Java 9+ 或 Java 17 语言/API 依赖。
- 保持本系统 JWT、本地用户名密码登录、SSO/CAS、Flyway V3 用户初始化和 Swagger 地址不变。
- 不修改主平台工程,不输出任何密码或 Token。
@ -23,10 +23,10 @@
- Modify: `src/main/java/com/yau/digitalrmb/shared/infrastructure/persistence/ApplicationDataSourceConfig.java`
- Test: `mvn -DskipTests compile -B`
- [ ] 将父版本切换为 `2.7.18``java.version` 切换为 `1.8`,设置 `maven.compiler.release=8`。
- [ ] 将 Web/Flyway/MyBatis-Plus/Springdoc 依赖替换为 Boot 2 对应 artifact`spring-boot-starter-web`、`flyway-core`、`mybatis-plus-boot-starter`、`springdoc-openapi-ui:1.8.0`;移除 Boot 4 专用依赖。
- [ ] 将 `DataSourceProperties` 导入改为 Boot 2 包路径。
- [ ] 运行 `mvn -DskipTests compile -B`,以编译报错清单驱动后续兼容改造。
- [x] 将父版本切换为 `2.7.18``java.version` 切换为 `1.8`,设置 `maven.compiler.source` 与 `maven.compiler.target``1.8`。
- [x] 将 Web/Flyway/MyBatis-Plus/Springdoc 依赖替换为 Boot 2 对应 artifact`spring-boot-starter-web`、`flyway-core`、`mybatis-plus-boot-starter`、`springdoc-openapi-ui:1.8.0`;移除 Boot 4 专用依赖。
- [x] 将 `DataSourceProperties` 导入改为 Boot 2 包路径。
- [x] 使用 JDK 8 完成编译并按报错清单完成后续兼容改造。
### Task 2: 改造 Java 17、Spring 6 与 Jakarta 代码
@ -34,11 +34,11 @@
- Modify: 所有 `src/main/java/**` 中的 record、text block、`JdbcClient`、`jakarta.*` 和 Java 9+ API 调用
- Modify: 对应 `src/test/java/**` 测试与 Boot 4 测试导入
- [ ] 将所有 record 变为带构造器、getter、`equals`/`hashCode` 的 Java 8 类;请求 DTO 提供 JavaBean getter保留 Bean Validation。
- [ ] 用 `NamedParameterJdbcTemplate` 替换 `JdbcClient`;用普通字符串替换 text block 和 `String.formatted`
- [ ] 将 Jakarta 包改为 `javax.*`,将 Boot 4 `AutoConfigureMockMvc` 包改为 Boot 2 包。
- [ ] 用 `Collections`、显式 `switch`、`StringBuilder`/自定义十六进制转换替换 Java 9+ API。
- [ ] 运行 `mvn test -DforkCount=0 -B`
- [x] 将所有 record 变为带构造器、getter、`equals`/`hashCode` 的 Java 8 类;请求 DTO 提供 JavaBean getter保留 Bean Validation。
- [x] 用 `NamedParameterJdbcTemplate` 替换 `JdbcClient`;用普通字符串替换 text block 和 `String.formatted`
- [x] 将 Jakarta 包改为 `javax.*`,将 Boot 4 `AutoConfigureMockMvc` 包改为 Boot 2 包。
- [x] 用 `Collections`、显式 `switch`、`StringBuilder`/自定义十六进制转换替换 Java 9+ API。
- [x] 使用 JDK 8 运行 `mvn clean test -B`19 项测试通过
### Task 3: Java 8 运行时验证与文档
@ -46,6 +46,6 @@
- Modify: `README.md`
- Modify: `docs/DEVELOPMENT_GUIDE.md`
- [ ] 使用 JDK 8 执行 `java -version`、`mvn test` 和打包后的 JAR 启动验证。
- [ ] 更新 README 与开发规范的 Java/Spring Boot 版本要求。
- [ ] 运行 `mvn package -DskipTests -B`,确认可执行包生成成功。
- [x] 使用 JDK 8 执行 `java -version`、`mvn test` 和 Web 健康检查启动验证。
- [x] 更新 README 与开发规范的 Java/Spring Boot 版本要求。
- [x] 使用 JDK 8 运行 `mvn package -DskipTests -B`,确认可执行包生成成功。

@ -7,7 +7,7 @@
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.0</version>
<version>2.7.18</version>
<relativePath/>
</parent>
@ -18,15 +18,17 @@
<description>数字人民币教学仿真后端</description>
<properties>
<java.version>17</java.version>
<java.version>1.8</java.version>
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
<mybatis-plus.version>3.5.17</mybatis-plus.version>
<springdoc.version>3.0.3</springdoc.version>
<springdoc.version>1.8.0</springdoc.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
@ -44,22 +46,18 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-flyway</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-mysql</artifactId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot4-starter</artifactId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-jsqlparser</artifactId>
<artifactId>mybatis-plus-jsqlparser-4.9</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<dependency>
@ -69,7 +67,7 @@
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<artifactId>springdoc-openapi-ui</artifactId>
<version>${springdoc.version}</version>
</dependency>
<dependency>
@ -87,11 +85,6 @@
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>

@ -80,9 +80,6 @@ public class PlatformIdentityProjectionService {
}
private long roleId(PlatformActor actor) {
return switch (actor.role()) {
case TEACHER -> 1001L;
case STUDENT -> 1002L;
};
return actor.role().name().equals("TEACHER") ? 1001L : 1002L;
}
}

@ -1,4 +1,14 @@
package com.yau.digitalrmb.issuance.domain.model;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import java.util.Objects;
import java.util.UUID;
public record IssuanceApplicationId(UUID value) { public IssuanceApplicationId { Objects.requireNonNull(value, "value must not be null"); } }
@Getter
@EqualsAndHashCode
public class IssuanceApplicationId {
private final UUID value;
public IssuanceApplicationId(UUID value) { this.value = Objects.requireNonNull(value, "value must not be null"); }
public UUID value() { return value; }
}

@ -5,6 +5,9 @@ import com.yau.digitalrmb.shared.api.ErrorCode;
import com.yau.digitalrmb.shared.exception.BusinessException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
import org.w3c.dom.Document;
import org.xml.sax.InputSource;
@ -16,24 +19,27 @@ import javax.xml.xpath.XPathConstants;
import javax.xml.xpath.XPathFactory;
import java.io.StringReader;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
@Component
public class CasTicketValidator {
private final PlatformIntegrationProperties.Cas properties;
private final HttpClient httpClient;
private final RestTemplate restTemplate;
@Autowired
public CasTicketValidator(PlatformIntegrationProperties properties) {
this(properties.getCas(), HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build());
this(properties.getCas(), createRestTemplate());
}
CasTicketValidator(PlatformIntegrationProperties.Cas properties, HttpClient httpClient) {
private static RestTemplate createRestTemplate() {
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
requestFactory.setConnectTimeout(5000);
requestFactory.setReadTimeout(5000);
return new RestTemplate(requestFactory);
}
CasTicketValidator(PlatformIntegrationProperties.Cas properties, RestTemplate restTemplate) {
this.properties = properties;
this.httpClient = httpClient;
this.restTemplate = restTemplate;
}
public String validate(String ticket) {
@ -42,12 +48,11 @@ public class CasTicketValidator {
.queryParam("service", properties.getCallbackUrl())
.queryParam("ticket", ticket)
.build().encode().toUri();
HttpRequest request = HttpRequest.newBuilder(uri).GET().timeout(Duration.ofSeconds(5)).build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() < 200 || response.statusCode() >= 300) {
ResponseEntity<String> response = restTemplate.getForEntity(uri, String.class);
if (response.getStatusCodeValue() < 200 || response.getStatusCodeValue() >= 300) {
throw rejected();
}
return parseAccount(response.body());
return parseAccount(response.getBody());
} catch (BusinessException exception) {
throw exception;
} catch (Exception exception) {
@ -66,13 +71,13 @@ public class CasTicketValidator {
factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
factory.setXIncludeAware(false);
factory.setExpandEntityReferences(false);
var builder = factory.newDocumentBuilder();
javax.xml.parsers.DocumentBuilder builder = factory.newDocumentBuilder();
builder.setErrorHandler(new DefaultHandler());
Document document = builder.parse(new InputSource(new StringReader(xml)));
String account = (String) XPathFactory.newInstance().newXPath().evaluate(
"string(//*[local-name()='authenticationSuccess']/*[local-name()='user'])",
document, XPathConstants.STRING);
if (account == null || account.isBlank()) {
if (account == null || account.trim().isEmpty()) {
throw rejected();
}
return account.trim();

@ -15,7 +15,6 @@ import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.HexFormat;
@Component
public class PlatformTokenVerifier {
@ -114,10 +113,9 @@ public class PlatformTokenVerifier {
}
private void verifyIdentityClaim(JsonNode payload, PlatformActor actor) {
String expected = switch (actor.role()) {
case TEACHER -> properties.getTeacherClaimValue();
case STUDENT -> properties.getStudentClaimValue();
};
String expected = actor.role().name().equals("TEACHER")
? properties.getTeacherClaimValue()
: properties.getStudentClaimValue();
if (!expected.equals(payload.path(String.valueOf(actor.profileId())).asText())) {
throw invalid();
}
@ -136,7 +134,15 @@ public class PlatformTokenVerifier {
private String fingerprint(String rawToken) throws Exception {
byte[] digest = MessageDigest.getInstance("SHA-256").digest(rawToken.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(digest);
StringBuilder fingerprint = new StringBuilder(digest.length * 2);
for (byte value : digest) {
String hex = Integer.toHexString(value & 0xff);
if (hex.length() == 1) {
fingerprint.append('0');
}
fingerprint.append(hex);
}
return fingerprint.toString();
}
private PlatformTokenException invalid() {

@ -1,6 +1,13 @@
package com.yau.digitalrmb.platformintegration.application;
import com.yau.digitalrmb.platformintegration.domain.PlatformActor;
import lombok.EqualsAndHashCode;
import lombok.Getter;
public record VerifiedPlatformToken(PlatformActor actor, String fingerprint) {
@Getter
@EqualsAndHashCode
public class VerifiedPlatformToken {
private final PlatformActor actor; private final String fingerprint;
public VerifiedPlatformToken(PlatformActor actor, String fingerprint) { this.actor = actor; this.fingerprint = fingerprint; }
public PlatformActor actor() { return actor; } public String fingerprint() { return fingerprint; }
}

@ -1,9 +1,9 @@
package com.yau.digitalrmb.platformintegration.config;
import jakarta.validation.Valid;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import javax.validation.Valid;
import javax.validation.constraints.AssertTrue;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;

@ -1,12 +1,13 @@
package com.yau.digitalrmb.platformintegration.domain;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import java.time.Instant;
public record PlatformActor(
long platformUserId,
long profileId,
String account,
String displayName,
PlatformRole role,
Instant tokenSigningTime) {
@Getter
@EqualsAndHashCode
public class PlatformActor {
private final long platformUserId; private final long profileId; private final String account; private final String displayName; private final PlatformRole role; private final Instant tokenSigningTime;
public PlatformActor(long platformUserId, long profileId, String account, String displayName, PlatformRole role, Instant tokenSigningTime) { this.platformUserId = platformUserId; this.profileId = profileId; this.account = account; this.displayName = displayName; this.role = role; this.tokenSigningTime = tokenSigningTime; }
public long platformUserId() { return platformUserId; } public long profileId() { return profileId; } public String account() { return account; } public String displayName() { return displayName; } public PlatformRole role() { return role; } public Instant tokenSigningTime() { return tokenSigningTime; }
}

@ -3,8 +3,11 @@ package com.yau.digitalrmb.platformintegration.infrastructure;
import com.yau.digitalrmb.platformintegration.application.PlatformIdentityRepository;
import com.yau.digitalrmb.platformintegration.domain.PlatformActor;
import com.yau.digitalrmb.platformintegration.domain.PlatformRole;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.dao.IncorrectResultSizeDataAccessException;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Repository;
import java.sql.ResultSet;
@ -17,23 +20,22 @@ import java.util.Optional;
@Repository
public class JdbcPlatformIdentityRepository implements PlatformIdentityRepository {
private static final String TEACHER_QUERY = """
SELECT cu.ID, cu.CODE, cu.NAME, t.teacher_id AS profile_id, t.add_time AS signing_time
FROM core_user cu JOIN teacher t ON t.user_id = cu.ID
WHERE cu.JOB_TYPE1 = 'JT_S_02' AND cu.STATE = 'S1' AND cu.DEL_FLAG = 0
AND t.teacher_status = 1 AND t.add_time IS NOT NULL AND %s
""";
private static final String STUDENT_QUERY = """
SELECT cu.ID, cu.CODE, cu.NAME, s.student_id AS profile_id, s.add_time AS signing_time
FROM core_user cu JOIN student s ON s.user_id = cu.ID
WHERE cu.JOB_TYPE1 = 'JT_S_03' AND cu.STATE = 'S1' AND cu.DEL_FLAG = 0
AND s.student_status = 1 AND s.add_time IS NOT NULL AND %s
""";
private static final String TEACHER_QUERY =
"SELECT cu.ID, cu.CODE, cu.NAME, t.teacher_id AS profile_id, t.add_time AS signing_time "
+ "FROM core_user cu JOIN teacher t ON t.user_id = cu.ID "
+ "WHERE cu.JOB_TYPE1 = 'JT_S_02' AND cu.STATE = 'S1' AND cu.DEL_FLAG = 0 "
+ "AND t.teacher_status = 1 AND t.add_time IS NOT NULL AND %s";
private static final String STUDENT_QUERY =
"SELECT cu.ID, cu.CODE, cu.NAME, s.student_id AS profile_id, s.add_time AS signing_time "
+ "FROM core_user cu JOIN student s ON s.user_id = cu.ID "
+ "WHERE cu.JOB_TYPE1 = 'JT_S_03' AND cu.STATE = 'S1' AND cu.DEL_FLAG = 0 "
+ "AND s.student_status = 1 AND s.add_time IS NOT NULL AND %s";
private final JdbcClient jdbcClient;
private final NamedParameterJdbcTemplate jdbcTemplate;
public JdbcPlatformIdentityRepository(@Qualifier("platformJdbcClient") JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
public JdbcPlatformIdentityRepository(
@Qualifier("platformNamedParameterJdbcTemplate") NamedParameterJdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Override
@ -50,28 +52,37 @@ public class JdbcPlatformIdentityRepository implements PlatformIdentityRepositor
public List<PlatformActor> findChangedSince(Instant watermark) {
String predicate = "(cu.update_Time > :watermark OR cu.CREATE_TIME > :watermark)";
Timestamp since = Timestamp.from(watermark);
List<PlatformActor> actors = new ArrayList<>(queryAll(TEACHER_QUERY.formatted(predicate), since, PlatformRole.TEACHER));
actors.addAll(queryAll(STUDENT_QUERY.formatted(predicate), since, PlatformRole.STUDENT));
List<PlatformActor> actors = new ArrayList<PlatformActor>(
queryAll(String.format(TEACHER_QUERY, predicate), since, PlatformRole.TEACHER));
actors.addAll(queryAll(String.format(STUDENT_QUERY, predicate), since, PlatformRole.STUDENT));
return actors;
}
private Optional<PlatformActor> findBy(String predicate, Object value) {
Optional<PlatformActor> teacher = query(TEACHER_QUERY.formatted(predicate), value, PlatformRole.TEACHER);
return teacher.isPresent() ? teacher : query(STUDENT_QUERY.formatted(predicate), value, PlatformRole.STUDENT);
Optional<PlatformActor> teacher = query(String.format(TEACHER_QUERY, predicate), value, PlatformRole.TEACHER);
return teacher.isPresent() ? teacher : query(String.format(STUDENT_QUERY, predicate), value, PlatformRole.STUDENT);
}
private Optional<PlatformActor> query(String sql, Object value, PlatformRole role) {
return jdbcClient.sql(sql)
.param("value", value)
.query((resultSet, rowNumber) -> map(resultSet, role))
.optional();
List<PlatformActor> actors = jdbcTemplate.query(sql,
new MapSqlParameterSource("value", value), rowMapper(role));
if (actors.size() > 1) {
throw new IncorrectResultSizeDataAccessException(1, actors.size());
}
return actors.isEmpty() ? Optional.<PlatformActor>empty() : Optional.of(actors.get(0));
}
private List<PlatformActor> queryAll(String sql, Timestamp watermark, PlatformRole role) {
return jdbcClient.sql(sql)
.param("watermark", watermark)
.query((resultSet, rowNumber) -> map(resultSet, role))
.list();
return jdbcTemplate.query(sql, new MapSqlParameterSource("watermark", watermark), rowMapper(role));
}
private RowMapper<PlatformActor> rowMapper(final PlatformRole role) {
return new RowMapper<PlatformActor>() {
@Override
public PlatformActor mapRow(ResultSet resultSet, int rowNumber) throws SQLException {
return map(resultSet, role);
}
};
}
private PlatformActor map(ResultSet resultSet, PlatformRole role) throws SQLException {

@ -6,7 +6,7 @@ import com.yau.digitalrmb.platformintegration.config.PlatformIntegrationProperti
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import javax.sql.DataSource;
@ -24,8 +24,9 @@ public class PlatformReadOnlyDataSourceConfig {
return new HikariDataSource(config);
}
@Bean(name = "platformJdbcClient")
public JdbcClient platformJdbcClient(@Qualifier("platformReadOnlyDataSource") DataSource dataSource) {
return JdbcClient.create(dataSource);
@Bean(name = "platformNamedParameterJdbcTemplate")
public NamedParameterJdbcTemplate platformNamedParameterJdbcTemplate(
@Qualifier("platformReadOnlyDataSource") DataSource dataSource) {
return new NamedParameterJdbcTemplate(dataSource);
}
}

@ -12,6 +12,8 @@ import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.List;
import java.util.Set;
import java.util.Collections;
import java.util.stream.Collectors;
@Service
public class JwtTokenService {
@ -27,15 +29,15 @@ public class JwtTokenService {
Instant issuedAt = Instant.now();
Instant expiresAt = issuedAt.plus(properties.getJwt().getAccessTokenTtl());
JwtClaimsSet claims = JwtClaimsSet.builder().subject(username).issuedAt(issuedAt).expiresAt(expiresAt)
.claim("roles", List.of("ROLE_ADMIN")).build();
.claim("roles", Collections.singletonList("ROLE_ADMIN")).build();
Jwt jwt = jwtEncoder.encode(JwtEncoderParameters.from(JwsHeader.with(MacAlgorithm.HS256).build(), claims));
return new Token(jwt.getTokenValue(), properties.getJwt().getAccessTokenTtl().toSeconds());
return new Token(jwt.getTokenValue(), properties.getJwt().getAccessTokenTtl().getSeconds());
}
public Token issueFor(long platformUserId, String account, Set<String> roles) {
Instant issuedAt = Instant.now();
Instant expiresAt = issuedAt.plus(properties.getJwt().getAccessTokenTtl());
List<String> authorities = roles.stream().sorted().map(role -> "ROLE_" + role).toList();
List<String> authorities = roles.stream().sorted().map(role -> "ROLE_" + role).collect(Collectors.toList());
JwtClaimsSet claims = JwtClaimsSet.builder()
.subject(String.valueOf(platformUserId))
.issuedAt(issuedAt)
@ -44,8 +46,14 @@ public class JwtTokenService {
.claim("roles", authorities)
.build();
Jwt jwt = jwtEncoder.encode(JwtEncoderParameters.from(JwsHeader.with(MacAlgorithm.HS256).build(), claims));
return new Token(jwt.getTokenValue(), properties.getJwt().getAccessTokenTtl().toSeconds());
return new Token(jwt.getTokenValue(), properties.getJwt().getAccessTokenTtl().getSeconds());
}
public record Token(String accessToken, long expiresIn) { }
public static class Token {
private final String accessToken;
private final long expiresIn;
public Token(String accessToken, long expiresIn) { this.accessToken = accessToken; this.expiresIn = expiresIn; }
public String accessToken() { return accessToken; }
public long expiresIn() { return expiresIn; }
}
}

@ -10,7 +10,7 @@ import com.yau.digitalrmb.shared.exception.BusinessException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.Set;
import java.util.Collections;
@Service
public class LocalAccountAuthenticationService {
@ -41,7 +41,7 @@ public class LocalAccountAuthenticationService {
if (snapshot == null) {
throw invalidCredentials();
}
return jwtTokenService.issueFor(user.getId(), snapshot.getAccount(), Set.of(snapshot.getRoleKey()));
return jwtTokenService.issueFor(user.getId(), snapshot.getAccount(), Collections.singleton(snapshot.getRoleKey()));
}
private BusinessException invalidCredentials() {

@ -12,7 +12,6 @@ import java.security.SecureRandom;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.Base64;
import java.util.HexFormat;
import java.util.List;
@Service
@ -53,8 +52,16 @@ public class LoginExchangeCodeService {
static String hash(String value) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8)));
byte[] digest = MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8));
StringBuilder hash = new StringBuilder(digest.length * 2);
for (byte item : digest) {
String hex = Integer.toHexString(item & 0xff);
if (hex.length() == 1) {
hash.append('0');
}
hash.append(hex);
}
return hash.toString();
} catch (Exception exception) {
throw new IllegalStateException("SHA-256 is unavailable", exception);
}

@ -49,8 +49,8 @@ public class SecurityConfig {
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/actuator/health", "/api/v1/auth/login", "/api/v1/auth/sso",
.authorizeRequests(authorize -> authorize
.antMatchers("/actuator/health", "/api/v1/auth/login", "/api/v1/auth/sso",
"/api/v1/auth/cas/**", "/api/v1/auth/session/exchange",
"/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html")
.permitAll()
@ -66,7 +66,7 @@ public class SecurityConfig {
return new SecretKeySpec(properties.getJwt().getSecret().getBytes(StandardCharsets.UTF_8), "HmacSHA256");
}
private static void writeError(jakarta.servlet.http.HttpServletResponse response, ErrorCode code, int status)
private static void writeError(javax.servlet.http.HttpServletResponse response, ErrorCode code, int status)
throws java.io.IOException {
response.setStatus(status);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);

@ -1,6 +1,6 @@
package com.yau.digitalrmb.security.config;
import jakarta.annotation.PostConstruct;
import javax.annotation.PostConstruct;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;

@ -10,7 +10,7 @@ import com.yau.digitalrmb.shared.api.ApiResponse;
import com.yau.digitalrmb.shared.api.ErrorCode;
import com.yau.digitalrmb.shared.exception.BusinessException;
import com.yau.digitalrmb.shared.web.TraceIdFilter;
import jakarta.validation.Valid;
import javax.validation.Valid;
import org.slf4j.MDC;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
@ -22,6 +22,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Set;
import java.util.Collections;
@RestController
@RequestMapping("/api/v1/auth")
@ -57,7 +58,7 @@ public class AuthController {
throw new BusinessException(ErrorCode.UNAUTHORIZED, "用户身份不存在");
}
JwtTokenService.Token accessToken = tokenService.issueFor(platformUserId, snapshot.getAccount(),
Set.of(snapshot.getRoleKey()));
Collections.singleton(snapshot.getRoleKey()));
String refreshToken = refreshTokenService.issue(platformUserId);
return ApiResponse.success(new SessionResponse(accessToken.accessToken(), refreshToken, "Bearer", accessToken.expiresIn()),
MDC.get(TraceIdFilter.MDC_KEY));
@ -71,7 +72,7 @@ public class AuthController {
throw new BusinessException(ErrorCode.UNAUTHORIZED, "用户身份不存在");
}
return ApiResponse.success(new CurrentUserResponse(platformUserId, snapshot.getAccount(), snapshot.getDisplayName(),
List.of(snapshot.getRoleKey())), MDC.get(TraceIdFilter.MDC_KEY));
Collections.singletonList(snapshot.getRoleKey())), MDC.get(TraceIdFilter.MDC_KEY));
}
@PostMapping("/logout")

@ -1,6 +1,4 @@
package com.yau.digitalrmb.security.interfaces;
import java.util.List;
public record CurrentUserResponse(long platformUserId, String account, String displayName, List<String> roles) {
}
import lombok.AllArgsConstructor; import lombok.Getter; import java.util.List;
@Getter @AllArgsConstructor
public class CurrentUserResponse { private final long platformUserId; private final String account; private final String displayName; private final List<String> roles; }

@ -1,6 +1,4 @@
package com.yau.digitalrmb.security.interfaces;
import jakarta.validation.constraints.NotBlank;
public record ExchangeCodeRequest(@NotBlank String code) {
}
import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import javax.validation.constraints.NotBlank;
@Getter @Setter @NoArgsConstructor @AllArgsConstructor
public class ExchangeCodeRequest { @NotBlank private String code; public String code() { return code; } }

@ -1,5 +1,4 @@
package com.yau.digitalrmb.security.interfaces;
import jakarta.validation.constraints.NotBlank;
public record LoginRequest(@NotBlank String username, @NotBlank String password) { }
import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import javax.validation.constraints.NotBlank;
@Getter @Setter @NoArgsConstructor @AllArgsConstructor
public class LoginRequest { @NotBlank private String username; @NotBlank private String password; public String username() { return username; } public String password() { return password; } }

@ -1,3 +1,4 @@
package com.yau.digitalrmb.security.interfaces;
public record LoginResponse(String accessToken, String tokenType, long expiresIn) { }
import lombok.AllArgsConstructor; import lombok.Getter;
@Getter @AllArgsConstructor
public class LoginResponse { private final String accessToken; private final String tokenType; private final long expiresIn; public String accessToken() { return accessToken; } public String tokenType() { return tokenType; } public long expiresIn() { return expiresIn; } }

@ -1,6 +1,4 @@
package com.yau.digitalrmb.security.interfaces;
import jakarta.validation.constraints.NotBlank;
public record LogoutRequest(@NotBlank String refreshToken) {
}
import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; import javax.validation.constraints.NotBlank;
@Getter @Setter @NoArgsConstructor @AllArgsConstructor
public class LogoutRequest { @NotBlank private String refreshToken; public String refreshToken() { return refreshToken; } }

@ -1,4 +1,4 @@
package com.yau.digitalrmb.security.interfaces;
public record SessionResponse(String accessToken, String refreshToken, String tokenType, long expiresIn) {
}
import lombok.AllArgsConstructor; import lombok.Getter;
@Getter @AllArgsConstructor
public class SessionResponse { private final String accessToken; private final String refreshToken; private final String tokenType; private final long expiresIn; }

@ -1,19 +1,29 @@
package com.yau.digitalrmb.shared.api;
import lombok.Getter;
import java.time.Instant;
public record ApiResponse<T>(
String code,
String message,
T data,
String traceId,
Instant timestamp
) {
public static <T> ApiResponse<T> success(T data, String traceId) {
return new ApiResponse<>(ErrorCode.SUCCESS.name(), "success", data, traceId, Instant.now());
}
@Getter
public class ApiResponse<T> {
private final String code;
private final String message;
private final T data;
private final String traceId;
private final Instant timestamp;
public static <T> ApiResponse<T> failure(ErrorCode errorCode, String message, String traceId) {
return new ApiResponse<>(errorCode.name(), message, null, traceId, Instant.now());
public ApiResponse(String code, String message, T data, String traceId, Instant timestamp) {
this.code = code;
this.message = message;
this.data = data;
this.traceId = traceId;
this.timestamp = timestamp;
}
public String code() { return code; }
public String message() { return message; }
public T data() { return data; }
public String traceId() { return traceId; }
public Instant timestamp() { return timestamp; }
public static <T> ApiResponse<T> success(T data, String traceId) { return new ApiResponse<T>(ErrorCode.SUCCESS.name(), "success", data, traceId, Instant.now()); }
public static <T> ApiResponse<T> failure(ErrorCode errorCode, String message, String traceId) { return new ApiResponse<T>(errorCode.name(), message, null, traceId, Instant.now()); }
}

@ -3,7 +3,7 @@ package com.yau.digitalrmb.shared.infrastructure.persistence;
import com.zaxxer.hikari.HikariDataSource;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.jdbc.autoconfigure.DataSourceProperties;
import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;

@ -2,7 +2,7 @@ package com.yau.digitalrmb.shared.interfaces;
import com.yau.digitalrmb.shared.api.ApiResponse;
import com.yau.digitalrmb.shared.web.TraceIdFilter;
import jakarta.validation.constraints.NotBlank;
import javax.validation.constraints.NotBlank;
import org.slf4j.MDC;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;

@ -4,7 +4,7 @@ import com.yau.digitalrmb.shared.api.ApiResponse;
import com.yau.digitalrmb.shared.api.ErrorCode;
import com.yau.digitalrmb.shared.exception.BusinessException;
import com.yau.digitalrmb.platformintegration.application.PlatformTokenException;
import jakarta.validation.ConstraintViolationException;
import javax.validation.ConstraintViolationException;
import org.slf4j.MDC;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

@ -1,9 +1,9 @@
package com.yau.digitalrmb.shared.web;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
@ -20,7 +20,7 @@ public class TraceIdFilter extends OncePerRequestFilter {
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String traceId = request.getHeader(HEADER_NAME);
if (traceId == null || traceId.isBlank()) {
if (traceId == null || traceId.trim().isEmpty()) {
traceId = UUID.randomUUID().toString();
}
MDC.put(MDC_KEY, traceId);

@ -1,12 +1,32 @@
package com.yau.digitalrmb.platformintegration.application;
import com.yau.digitalrmb.platformintegration.config.PlatformIntegrationProperties;
import com.yau.digitalrmb.shared.exception.BusinessException;
import org.junit.jupiter.api.Test;
import org.springframework.util.ReflectionUtils;
import org.springframework.web.client.RestTemplate;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import java.lang.reflect.Field;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class CasTicketValidatorTest {
@Test
void configuresFiveSecondTimeoutsForCasRequests() throws Exception {
PlatformIntegrationProperties properties = new PlatformIntegrationProperties();
CasTicketValidator validator = new CasTicketValidator(properties);
Field restTemplateField = ReflectionUtils.findField(CasTicketValidator.class, "restTemplate");
ReflectionUtils.makeAccessible(restTemplateField);
RestTemplate restTemplate = (RestTemplate) ReflectionUtils.getField(restTemplateField, validator);
assertThat(restTemplate.getRequestFactory()).isInstanceOf(SimpleClientHttpRequestFactory.class);
SimpleClientHttpRequestFactory requestFactory = (SimpleClientHttpRequestFactory) restTemplate.getRequestFactory();
assertThat(readIntField(requestFactory, "connectTimeout")).isEqualTo(5000);
assertThat(readIntField(requestFactory, "readTimeout")).isEqualTo(5000);
}
@Test
void parsesSuccessfulCasAccountAndRejectsExternalEntityPayloads() {
String success = "<cas:serviceResponse xmlns:cas=\"http://www.yale.edu/tp/cas\">"
@ -18,4 +38,10 @@ class CasTicketValidatorTest {
assertThat(CasTicketValidator.parseAccount(success)).isEqualTo("t001");
assertThatThrownBy(() -> CasTicketValidator.parseAccount(xxe)).isInstanceOf(BusinessException.class);
}
private int readIntField(Object target, String fieldName) throws Exception {
Field field = ReflectionUtils.findField(target.getClass(), fieldName);
ReflectionUtils.makeAccessible(field);
return (Integer) ReflectionUtils.getField(field, target);
}
}

@ -13,8 +13,10 @@ import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.Collections;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@ -45,7 +47,7 @@ class PlatformTokenVerifierTest {
@Override
public List<PlatformActor> findChangedSince(Instant watermark) {
return List.of();
return Collections.emptyList();
}
};
verifier = new PlatformTokenVerifier(repository, properties);
@ -78,9 +80,14 @@ class PlatformTokenVerifierTest {
private String issueToken(PlatformActor actor, Instant loginTime, String identityClaimValue) throws Exception {
ObjectMapper mapper = new ObjectMapper();
String header = encode(mapper.writeValueAsBytes(Map.of("alg", "HS256", "typ", "JWT")));
String payload = encode(mapper.writeValueAsBytes(Map.of("aud", new String[]{String.valueOf(actor.platformUserId())},
String.valueOf(actor.profileId()), identityClaimValue)));
Map<String, Object> headerClaims = new HashMap<String, Object>();
headerClaims.put("alg", "HS256");
headerClaims.put("typ", "JWT");
Map<String, Object> payloadClaims = new HashMap<String, Object>();
payloadClaims.put("aud", new String[]{String.valueOf(actor.platformUserId())});
payloadClaims.put(String.valueOf(actor.profileId()), identityClaimValue);
String header = encode(mapper.writeValueAsBytes(headerClaims));
String payload = encode(mapper.writeValueAsBytes(payloadClaims));
String unsigned = header + "." + payload;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(String.valueOf(actor.tokenSigningTime().toEpochMilli()).getBytes(StandardCharsets.UTF_8), "HmacSHA256"));

@ -6,13 +6,16 @@ import com.yau.digitalrmb.platformintegration.domain.PlatformRole;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.dao.IncorrectResultSizeDataAccessException;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import javax.sql.DataSource;
import java.sql.Timestamp;
import java.sql.Connection;
import java.sql.Statement;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class JdbcPlatformIdentityRepositoryTest {
private DataSource dataSource;
@ -26,7 +29,7 @@ class JdbcPlatformIdentityRepositoryTest {
dataSource = source;
execute("DROP ALL OBJECTS");
createSchema();
repository = new JdbcPlatformIdentityRepository(JdbcClient.create(dataSource));
repository = new JdbcPlatformIdentityRepository(new NamedParameterJdbcTemplate(dataSource));
}
@Test
@ -34,7 +37,8 @@ class JdbcPlatformIdentityRepositoryTest {
execute("INSERT INTO core_user(ID, CODE, NAME, STATE, JOB_TYPE1, DEL_FLAG) VALUES (101, 't001', '教师甲', 'S1', 'JT_S_02', 0)");
execute("INSERT INTO teacher(teacher_id, user_id, teacher_status, add_time) VALUES (1, 101, 1, '2026-01-01 00:00:00')");
PlatformActor actor = repository.findByPlatformUserId(101L).orElseThrow();
PlatformActor actor = repository.findByPlatformUserId(101L)
.orElseThrow(() -> new IllegalStateException("teacher not found"));
assertThat(actor.account()).isEqualTo("t001");
assertThat(actor.role()).isEqualTo(PlatformRole.TEACHER);
@ -52,6 +56,17 @@ class JdbcPlatformIdentityRepositoryTest {
assertThat(repository.findByPlatformUserId(202L)).isEmpty();
}
@Test
void rejectsDuplicateSchoolAccounts() throws Exception {
execute("INSERT INTO core_user(ID, CODE, NAME, STATE, JOB_TYPE1, DEL_FLAG) VALUES (301, 'duplicate', 'student one', 'S1', 'JT_S_03', 0)");
execute("INSERT INTO core_user(ID, CODE, NAME, STATE, JOB_TYPE1, DEL_FLAG) VALUES (302, 'duplicate', 'student two', 'S1', 'JT_S_03', 0)");
execute("INSERT INTO student(student_id, user_id, student_status, add_time) VALUES (11, 301, 1, '2026-01-01 00:00:00')");
execute("INSERT INTO student(student_id, user_id, student_status, add_time) VALUES (12, 302, 1, '2026-01-01 00:00:00')");
assertThatThrownBy(() -> repository.findBySchoolAccount("duplicate"))
.isInstanceOf(IncorrectResultSizeDataAccessException.class);
}
@Test
private void createSchema() throws Exception {
execute("CREATE TABLE core_user(ID BIGINT PRIMARY KEY, CODE VARCHAR(64), NAME VARCHAR(64), STATE VARCHAR(16), JOB_TYPE1 VARCHAR(16), DEL_FLAG INT)");
@ -60,7 +75,7 @@ class JdbcPlatformIdentityRepositoryTest {
}
private void execute(String sql) throws Exception {
try (var connection = dataSource.getConnection(); var statement = connection.createStatement()) {
try (Connection connection = dataSource.getConnection(); Statement statement = connection.createStatement()) {
statement.execute(sql);
}
}

@ -7,7 +7,7 @@ import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import static org.assertj.core.api.Assertions.assertThat;
@ -27,12 +27,12 @@ class PlatformReadOnlyDataSourceConfigTest {
"platform-integration.frontend.callback-url=https://rmb.example.edu/sso-callback");
@Test
void createsNamedReadOnlyDatasourceAndJdbcClient() {
void createsNamedReadOnlyDatasourceAndJdbcTemplate() {
contextRunner.run(context -> {
HikariDataSource dataSource = context.getBean("platformReadOnlyDataSource", HikariDataSource.class);
assertThat(dataSource.isReadOnly()).isTrue();
assertThat(context.getBean("platformJdbcClient", JdbcClient.class)).isNotNull();
assertThat(context.getBean("platformNamedParameterJdbcTemplate", NamedParameterJdbcTemplate.class)).isNotNull();
});
}

@ -3,7 +3,7 @@ package com.yau.digitalrmb.security;
import com.jayway.jsonpath.JsonPath;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;

@ -9,13 +9,13 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import java.time.Instant;
import java.util.Set;
import java.util.Collections;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
@ -42,7 +42,7 @@ class CurrentUserAndLogoutTest {
void setUp() {
projectionService.project(new PlatformActor(101L, 1L, "t001", "教师甲", PlatformRole.TEACHER,
Instant.parse("2026-01-01T00:00:00Z")));
teacherJwt = jwtTokenService.issueFor(101L, "t001", Set.of("TEACHER")).accessToken();
teacherJwt = jwtTokenService.issueFor(101L, "t001", Collections.singleton("TEACHER")).accessToken();
refreshToken = refreshTokenService.issue(101L);
}

@ -3,7 +3,7 @@ package com.yau.digitalrmb.shared;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;

Loading…
Cancel
Save