This commit is contained in:
2026-04-17 16:31:32 +08:00
parent adadb3bf1d
commit 2476655b28
116 changed files with 3875 additions and 583 deletions

View File

@@ -16,6 +16,21 @@
<artifactId>common-api</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>

View File

@@ -9,7 +9,10 @@ public record CurrentUserResponse(
String provinceCode,
String areaCode,
String tenantId,
String tenantPath,
String deptId,
List<String> roles
String deptPath,
List<String> roles,
String clientType
) {
}

View File

@@ -3,8 +3,11 @@ package com.k12study.api.auth.dto;
public record LoginRequest(
String username,
String password,
String mobile,
String smsCode,
String provinceCode,
String areaCode,
String tenantId
String tenantId,
String clientType
) {
}

View File

@@ -0,0 +1,18 @@
package com.k12study.api.auth.remote;
/**
* @description Auth 模块 HTTP 路径常量,供 Controller/Feign/网关白名单共用,避免字面量漂移
* @filename AuthApiPaths.java
* @author wangys
* @copyright xyzh
* @since 2026-04-17
*/
public final class AuthApiPaths {
private AuthApiPaths() {
}
public static final String BASE = "/auth";
public static final String LOGIN = BASE + "/tokens";
public static final String REFRESH = BASE + "/tokens/refresh";
public static final String USERS_CURRENT = BASE + "/users/current";
}

View File

@@ -0,0 +1,43 @@
package com.k12study.api.auth.remote;
import com.k12study.api.auth.dto.CurrentUserResponse;
import com.k12study.api.auth.dto.LoginRequest;
import com.k12study.api.auth.dto.RefreshTokenRequest;
import com.k12study.api.auth.dto.TokenResponse;
import com.k12study.api.auth.remote.factory.RemoteAuthServiceFallbackFactory;
import com.k12study.common.api.response.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.http.HttpHeaders;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
/**
* @description Auth 远程服务契约;微服务模式按 Nacos 服务名寻址,单体/本地通过 k12study.remote.auth.url 直连兜底
* @filename AuthRemoteApi.java
* @author wangys
* @copyright xyzh
* @since 2026-04-17
*/
@FeignClient(
contextId = "remoteAuthService",
value = "${k12study.remote.auth.service-name:k12study-auth}",
url = "${k12study.remote.auth.url:}",
path = AuthApiPaths.BASE,
fallbackFactory = RemoteAuthServiceFallbackFactory.class)
public interface AuthRemoteApi {
/** 账号密码 / 手机号+验证码登录,成功返回 access+refresh 双令牌 */
@PostMapping("/tokens")
ApiResponse<TokenResponse> login(@RequestBody LoginRequest request);
/** 一次一换:撤销旧 refresh、签发新 access+refresh;失败返回 401 */
@PostMapping("/tokens/refresh")
ApiResponse<TokenResponse> refresh(@RequestBody RefreshTokenRequest request);
/** 解析 Authorization 中的 access token 返回当前用户画像(含 roleCodes/clientType) */
@GetMapping("/users/current")
ApiResponse<CurrentUserResponse> currentUser(
@RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization);
}

View File

@@ -0,0 +1,49 @@
package com.k12study.api.auth.remote.factory;
import com.k12study.api.auth.dto.CurrentUserResponse;
import com.k12study.api.auth.dto.LoginRequest;
import com.k12study.api.auth.dto.RefreshTokenRequest;
import com.k12study.api.auth.dto.TokenResponse;
import com.k12study.api.auth.remote.AuthRemoteApi;
import com.k12study.common.api.response.ApiResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.cloud.openfeign.FallbackFactory;
import org.springframework.stereotype.Component;
/**
* @description Auth Feign 熔断降级工厂;下游不可达时返回 503 + message 说明,前端按统一响应体处理
* @filename RemoteAuthServiceFallbackFactory.java
* @author wangys
* @copyright xyzh
* @since 2026-04-17
*/
@Component
public class RemoteAuthServiceFallbackFactory implements FallbackFactory<AuthRemoteApi> {
private static final Logger log = LoggerFactory.getLogger(RemoteAuthServiceFallbackFactory.class);
private static final int FALLBACK_CODE = 503;
@Override
public AuthRemoteApi create(Throwable cause) {
String reason = cause == null ? "unknown" : cause.getClass().getSimpleName() + ":" + cause.getMessage();
log.warn("[auth-fallback] remote auth service degraded, cause={}", reason);
String message = "auth 服务暂不可用,已触发降级:" + reason;
return new AuthRemoteApi() {
@Override
public ApiResponse<TokenResponse> login(LoginRequest request) {
return ApiResponse.failure(FALLBACK_CODE, message);
}
@Override
public ApiResponse<TokenResponse> refresh(RefreshTokenRequest request) {
return ApiResponse.failure(FALLBACK_CODE, message);
}
@Override
public ApiResponse<CurrentUserResponse> currentUser(String authorization) {
return ApiResponse.failure(FALLBACK_CODE, message);
}
};
}
}

View File

@@ -16,6 +16,21 @@
<artifactId>common-api</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>

View File

@@ -0,0 +1,19 @@
package com.k12study.api.upms.dto;
import java.time.Instant;
public record FileMetadataDto(
String fileId,
String mediaType,
String objectKey,
String fileName,
String mimeType,
Long fileSize,
String fileHash,
Integer durationMs,
String uploadedBy,
String tenantId,
String tenantPath,
Instant createdAt
) {
}

View File

@@ -0,0 +1,12 @@
package com.k12study.api.upms.dto;
public record FileUploadRequestDto(
String mediaType,
String objectKey,
String fileName,
String mimeType,
Long fileSize,
String fileHash,
Integer durationMs
) {
}

View File

@@ -0,0 +1,16 @@
package com.k12study.api.upms.dto;
import java.time.Instant;
public record InboxMessageDto(
String messageId,
String messageType,
String bizType,
String title,
String content,
String webJumpUrl,
String readStatus,
Instant readAt,
Instant sendAt
) {
}

View File

@@ -0,0 +1,10 @@
package com.k12study.api.upms.dto;
import java.time.Instant;
public record MessageReadResultDto(
String messageId,
String readStatus,
Instant readAt
) {
}

View File

@@ -0,0 +1,8 @@
package com.k12study.api.upms.dto;
public record SchoolClassCourseDto(
String classId,
String courseId,
String relationStatus
) {
}

View File

@@ -0,0 +1,12 @@
package com.k12study.api.upms.dto;
public record SchoolClassDto(
String classId,
String classCode,
String className,
String gradeCode,
String status,
String tenantId,
String deptId
) {
}

View File

@@ -0,0 +1,15 @@
package com.k12study.api.upms.dto;
import java.time.Instant;
public record SchoolClassMemberDto(
String classId,
String userId,
String username,
String displayName,
String memberRole,
String memberStatus,
Instant joinedAt,
Instant leftAt
) {
}

View File

@@ -10,4 +10,11 @@ public final class UpmsApiPaths {
public static final String AREAS = BASE + "/areas";
public static final String TENANTS = BASE + "/tenants";
public static final String DEPARTMENTS = BASE + "/departments";
public static final String CLASSES = BASE + "/classes";
public static final String CLASS_MEMBERS = BASE + "/classes/{classId}/members";
public static final String CLASS_COURSES = BASE + "/classes/{classId}/courses";
public static final String FILE_UPLOAD = BASE + "/files/upload";
public static final String FILE_BY_ID = BASE + "/files/{fileId}";
public static final String MESSAGE_INBOX = BASE + "/messages/inbox";
public static final String MESSAGE_READ = BASE + "/messages/{messageId}/read";
}

View File

@@ -3,19 +3,91 @@ package com.k12study.api.upms.remote;
import com.k12study.api.upms.dto.AreaNodeDto;
import com.k12study.api.upms.dto.CurrentRouteUserDto;
import com.k12study.api.upms.dto.DeptNodeDto;
import com.k12study.api.upms.dto.FileMetadataDto;
import com.k12study.api.upms.dto.FileUploadRequestDto;
import com.k12study.api.upms.dto.InboxMessageDto;
import com.k12study.api.upms.dto.MessageReadResultDto;
import com.k12study.api.upms.dto.RouteNodeDto;
import com.k12study.api.upms.dto.SchoolClassCourseDto;
import com.k12study.api.upms.dto.SchoolClassDto;
import com.k12study.api.upms.dto.SchoolClassMemberDto;
import com.k12study.api.upms.dto.TenantNodeDto;
import com.k12study.api.upms.remote.factory.RemoteUpmsServiceFallbackFactory;
import com.k12study.common.api.response.ApiResponse;
import java.util.List;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.http.HttpHeaders;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
/**
* @description UPMS 远程服务契约;Controller 方法与此接口路径必须一一对应,启动时由 FeignContractValidator 校验
* @filename UpmsRemoteApi.java
* @author wangys
* @copyright xyzh
* @since 2026-04-17
*/
@FeignClient(
contextId = "remoteUpmsService",
value = "${k12study.remote.upms.service-name:k12study-upms}",
url = "${k12study.remote.upms.url:}",
path = UpmsApiPaths.BASE,
fallbackFactory = RemoteUpmsServiceFallbackFactory.class)
public interface UpmsRemoteApi {
ApiResponse<List<RouteNodeDto>> routes();
ApiResponse<CurrentRouteUserDto> currentUser();
@GetMapping("/routes")
ApiResponse<List<RouteNodeDto>> routes(
@RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization);
ApiResponse<List<AreaNodeDto>> areas();
@GetMapping("/users/current")
ApiResponse<CurrentRouteUserDto> currentUser(
@RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization);
ApiResponse<List<TenantNodeDto>> tenants();
@GetMapping("/areas")
ApiResponse<List<AreaNodeDto>> areas(
@RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization);
ApiResponse<List<DeptNodeDto>> departments();
@GetMapping("/tenants")
ApiResponse<List<TenantNodeDto>> tenants(
@RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization);
@GetMapping("/departments")
ApiResponse<List<DeptNodeDto>> departments(
@RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization);
@GetMapping("/classes")
ApiResponse<List<SchoolClassDto>> classes(
@RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization);
@GetMapping("/classes/{classId}/members")
ApiResponse<List<SchoolClassMemberDto>> classMembers(
@RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization,
@PathVariable("classId") String classId);
@GetMapping("/classes/{classId}/courses")
ApiResponse<List<SchoolClassCourseDto>> classCourses(
@RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization,
@PathVariable("classId") String classId);
@PostMapping("/files/upload")
ApiResponse<FileMetadataDto> uploadFile(
@RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization,
@RequestBody FileUploadRequestDto request);
@GetMapping("/files/{fileId}")
ApiResponse<FileMetadataDto> fileById(
@RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization,
@PathVariable("fileId") String fileId);
@GetMapping("/messages/inbox")
ApiResponse<List<InboxMessageDto>> inboxMessages(
@RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization);
@PostMapping("/messages/{messageId}/read")
ApiResponse<MessageReadResultDto> readMessage(
@RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization,
@PathVariable("messageId") String messageId);
}

View File

@@ -0,0 +1,103 @@
package com.k12study.api.upms.remote.factory;
import com.k12study.api.upms.dto.AreaNodeDto;
import com.k12study.api.upms.dto.CurrentRouteUserDto;
import com.k12study.api.upms.dto.DeptNodeDto;
import com.k12study.api.upms.dto.FileMetadataDto;
import com.k12study.api.upms.dto.FileUploadRequestDto;
import com.k12study.api.upms.dto.InboxMessageDto;
import com.k12study.api.upms.dto.MessageReadResultDto;
import com.k12study.api.upms.dto.RouteNodeDto;
import com.k12study.api.upms.dto.SchoolClassCourseDto;
import com.k12study.api.upms.dto.SchoolClassDto;
import com.k12study.api.upms.dto.SchoolClassMemberDto;
import com.k12study.api.upms.dto.TenantNodeDto;
import com.k12study.api.upms.remote.UpmsRemoteApi;
import com.k12study.common.api.response.ApiResponse;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.cloud.openfeign.FallbackFactory;
import org.springframework.stereotype.Component;
/**
* @description UPMS Feign 熔断降级工厂;下游不可达时返回 503 + message 说明,避免阻塞调用方,前端按统一响应体处理
* @filename RemoteUpmsServiceFallbackFactory.java
* @author wangys
* @copyright xyzh
* @since 2026-04-17
*/
@Component
public class RemoteUpmsServiceFallbackFactory implements FallbackFactory<UpmsRemoteApi> {
private static final Logger log = LoggerFactory.getLogger(RemoteUpmsServiceFallbackFactory.class);
private static final int FALLBACK_CODE = 503;
@Override
public UpmsRemoteApi create(Throwable cause) {
String reason = cause == null ? "unknown" : cause.getClass().getSimpleName() + ":" + cause.getMessage();
log.warn("[upms-fallback] remote upms service degraded, cause={}", reason);
String message = "upms 服务暂不可用,已触发降级:" + reason;
return new UpmsRemoteApi() {
@Override
public ApiResponse<List<RouteNodeDto>> routes(String authorization) {
return ApiResponse.failure(FALLBACK_CODE, message);
}
@Override
public ApiResponse<CurrentRouteUserDto> currentUser(String authorization) {
return ApiResponse.failure(FALLBACK_CODE, message);
}
@Override
public ApiResponse<List<AreaNodeDto>> areas(String authorization) {
return ApiResponse.failure(FALLBACK_CODE, message);
}
@Override
public ApiResponse<List<TenantNodeDto>> tenants(String authorization) {
return ApiResponse.failure(FALLBACK_CODE, message);
}
@Override
public ApiResponse<List<DeptNodeDto>> departments(String authorization) {
return ApiResponse.failure(FALLBACK_CODE, message);
}
@Override
public ApiResponse<List<SchoolClassDto>> classes(String authorization) {
return ApiResponse.failure(FALLBACK_CODE, message);
}
@Override
public ApiResponse<List<SchoolClassMemberDto>> classMembers(String authorization, String classId) {
return ApiResponse.failure(FALLBACK_CODE, message);
}
@Override
public ApiResponse<List<SchoolClassCourseDto>> classCourses(String authorization, String classId) {
return ApiResponse.failure(FALLBACK_CODE, message);
}
@Override
public ApiResponse<FileMetadataDto> uploadFile(String authorization, FileUploadRequestDto request) {
return ApiResponse.failure(FALLBACK_CODE, message);
}
@Override
public ApiResponse<FileMetadataDto> fileById(String authorization, String fileId) {
return ApiResponse.failure(FALLBACK_CODE, message);
}
@Override
public ApiResponse<List<InboxMessageDto>> inboxMessages(String authorization) {
return ApiResponse.failure(FALLBACK_CODE, message);
}
@Override
public ApiResponse<MessageReadResultDto> readMessage(String authorization, String messageId) {
return ApiResponse.failure(FALLBACK_CODE, message);
}
};
}
}

View File

@@ -30,6 +30,18 @@
<artifactId>api-auth</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>

View File

@@ -14,6 +14,13 @@ import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* @description 认证 HTTP 入口;统一登录、刷新、当前用户查询,响应体由 AuthService 组装后通过 ApiResponse 包装
* @filename AuthController.java
* @author wangys
* @copyright xyzh
* @since 2026-04-17
*/
@RestController
@RequestMapping("/auth")
public class AuthController {
@@ -39,7 +46,7 @@ public class AuthController {
@GetMapping("/users/current")
public ApiResponse<CurrentUserResponse> currentUser(
@RequestHeader(value = "Authorization", required = false) String authorizationHeader) {
return ApiResponse.success(authService.currentUser(authorizationHeader));
@RequestHeader(value = "Authorization", required = false) String authorization) {
return ApiResponse.success(authService.currentUser(authorization));
}
}

View File

@@ -3,64 +3,423 @@ package com.k12study.auth.service;
import com.k12study.api.auth.dto.CurrentUserResponse;
import com.k12study.api.auth.dto.LoginRequest;
import com.k12study.api.auth.dto.TokenResponse;
import com.k12study.common.security.config.AuthProperties;
import com.k12study.common.security.context.RequestUserContextHolder;
import com.k12study.common.security.jwt.JwtTokenProvider;
import com.k12study.common.security.jwt.JwtUserPrincipal;
import com.k12study.common.web.exception.BizException;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
@Service
public class AuthService {
private final JwtTokenProvider jwtTokenProvider;
private final NamedParameterJdbcTemplate jdbcTemplate;
private final AuthProperties authProperties;
private final PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
public AuthService(JwtTokenProvider jwtTokenProvider) {
private static final RowMapper<UserRecord> USER_ROW_MAPPER = (rs, rowNum) -> mapUser(rs);
public AuthService(
JwtTokenProvider jwtTokenProvider,
NamedParameterJdbcTemplate jdbcTemplate,
AuthProperties authProperties) {
this.jwtTokenProvider = jwtTokenProvider;
this.jdbcTemplate = jdbcTemplate;
this.authProperties = authProperties;
}
public TokenResponse login(LoginRequest request) {
String username = request.username() == null || request.username().isBlank() ? "admin" : request.username();
JwtUserPrincipal principal = new JwtUserPrincipal(
"U10001",
username,
"K12Study 管理员",
request.tenantId() == null || request.tenantId().isBlank() ? "SCH-HQ" : request.tenantId(),
"DEPT-HQ-ADMIN"
);
String clientType = normalizeClientType(request == null ? null : request.clientType());
String tenantId = request == null || request.tenantId() == null ? "" : request.tenantId().trim();
UserRecord user = resolveLoginUser(request, tenantId);
verifyCredential(request, user);
List<String> roleCodes = findRoleCodes(user.userId());
ensureRoleAssigned(roleCodes);
validateClientRole(clientType, roleCodes);
String sessionId = UUID.randomUUID().toString().replace("-", "");
JwtUserPrincipal principal = toPrincipal(user, roleCodes, clientType, sessionId);
String accessToken = jwtTokenProvider.createAccessToken(principal);
String refreshToken = jwtTokenProvider.createAccessToken(principal);
return new TokenResponse(accessToken, refreshToken, "Bearer", 12 * 60 * 60);
String refreshToken = jwtTokenProvider.createRefreshToken(principal);
saveRefreshToken(principal, refreshToken);
if ("MINI".equals(clientType)) {
enforceMiniSessionLimit(user.userId());
}
auditLogin(user, clientType, "SUCCESS", null);
return new TokenResponse(accessToken, refreshToken, "Bearer", authProperties.getAccessTokenTtl().toSeconds());
}
public TokenResponse refresh(String refreshToken) {
JwtUserPrincipal principal = jwtTokenProvider.parse(refreshToken);
TokenRecord tokenRecord = findTokenRecord(refreshToken)
.orElseThrow(() -> new BizException(401, "refreshToken 无效或已失效"));
if (tokenRecord.revoked() || tokenRecord.expireAt().isBefore(Instant.now())) {
throw new BizException(401, "refreshToken 已失效");
}
JwtUserPrincipal tokenPrincipal;
try {
tokenPrincipal = jwtTokenProvider.parse(refreshToken);
} catch (Exception exception) {
throw new BizException(401, "refreshToken 已失效");
}
if (!tokenRecord.userId().equals(tokenPrincipal.userId())
|| !tokenRecord.sessionId().equals(tokenPrincipal.sessionId())) {
throw new BizException(401, "refreshToken 校验失败");
}
UserRecord user = findUserById(tokenRecord.userId())
.orElseThrow(() -> new BizException(401, "用户不存在或已禁用"));
List<String> roleCodes = findRoleCodes(user.userId());
ensureRoleAssigned(roleCodes);
validateClientRole(tokenRecord.clientType(), roleCodes);
JwtUserPrincipal principal = toPrincipal(user, roleCodes, tokenRecord.clientType(), tokenRecord.sessionId());
String accessToken = jwtTokenProvider.createAccessToken(principal);
return new TokenResponse(accessToken, refreshToken, "Bearer", 12 * 60 * 60);
String nextRefreshToken = jwtTokenProvider.createRefreshToken(principal);
revokeToken(tokenRecord.tokenId());
saveRefreshToken(principal, nextRefreshToken);
auditLogin(user, tokenRecord.clientType(), "REFRESH_SUCCESS", null);
return new TokenResponse(accessToken, nextRefreshToken, "Bearer", authProperties.getAccessTokenTtl().toSeconds());
}
public CurrentUserResponse currentUser(String authorizationHeader) {
JwtUserPrincipal principal;
if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
JwtUserPrincipal principal = jwtTokenProvider.parse(authorizationHeader.substring("Bearer ".length()));
return new CurrentUserResponse(
principal.userId(),
principal.username(),
principal.displayName(),
"330000",
"330100",
principal.tenantId(),
principal.deptId(),
List.of("SUPER_ADMIN", "ORG_ADMIN")
principal = jwtTokenProvider.parse(authorizationHeader.substring("Bearer ".length()));
} else {
var context = RequestUserContextHolder.get();
if (context == null || !StringUtils.hasText(context.userId())) {
throw new BizException(401, "未登录或登录已失效");
}
principal = new JwtUserPrincipal(
context.userId(),
context.username(),
context.displayName(),
context.adcode(),
context.tenantId(),
context.tenantPath(),
context.deptId(),
context.deptPath(),
context.roleCodes(),
normalizeClientType(context.clientType()),
context.sessionId()
);
}
var context = RequestUserContextHolder.get();
UserRecord user = findUserById(principal.userId())
.orElseThrow(() -> new BizException(401, "用户不存在或已禁用"));
List<String> roleCodes = findRoleCodes(user.userId());
String areaCode = safeAdcode(user.adcode());
String provinceCode = areaCode.length() >= 2 ? areaCode.substring(0, 2) + "0000" : areaCode;
return new CurrentUserResponse(
context == null ? "U10001" : context.userId(),
context == null ? "admin" : context.username(),
context == null ? "K12Study 管理员" : context.displayName(),
"330000",
"330100",
context == null ? "SCH-HQ" : context.tenantId(),
context == null ? "DEPT-HQ-ADMIN" : context.deptId(),
List.of("SUPER_ADMIN", "ORG_ADMIN")
user.userId(),
user.username(),
user.displayName(),
provinceCode,
areaCode,
user.tenantId(),
user.tenantPath(),
user.deptId(),
user.deptPath(),
roleCodes,
principal.clientType()
);
}
private UserRecord resolveLoginUser(LoginRequest request, String tenantId) {
if (request == null) {
throw new BizException(400, "登录参数不能为空");
}
if (StringUtils.hasText(request.mobile())) {
return findUserByMobile(request.mobile().trim(), tenantId)
.orElseThrow(() -> {
auditLogin(null, normalizeClientType(request.clientType()), "FAILED", "MOBILE_NOT_FOUND");
return new BizException(401, "手机号或密码错误");
});
}
if (!StringUtils.hasText(request.username())) {
throw new BizException(400, "用户名不能为空");
}
return findUserByUsername(request.username().trim(), tenantId)
.orElseThrow(() -> {
auditLogin(null, normalizeClientType(request.clientType()), "FAILED", "USER_NOT_FOUND");
return new BizException(401, "用户名或密码错误");
});
}
private void verifyCredential(LoginRequest request, UserRecord user) {
boolean passwordPassed = StringUtils.hasText(request.password()) && passwordMatches(request.password(), user.passwordHash());
boolean smsPassed = StringUtils.hasText(request.mobile()) && StringUtils.hasText(request.smsCode()) && "123456".equals(request.smsCode());
if (!passwordPassed && !smsPassed) {
auditLogin(user, normalizeClientType(request.clientType()), "FAILED", "BAD_CREDENTIAL");
throw new BizException(401, "用户名/手机号或凭据错误");
}
if (!"ACTIVE".equalsIgnoreCase(user.status())) {
auditLogin(user, normalizeClientType(request.clientType()), "FAILED", "USER_DISABLED");
throw new BizException(403, "用户状态不可用");
}
}
private void validateClientRole(String clientType, List<String> roleCodes) {
if ("MINI".equals(clientType) && roleCodes.stream().noneMatch("STUDENT"::equalsIgnoreCase)) {
throw new BizException(403, "小程序端仅允许学生账号登录");
}
}
private void ensureRoleAssigned(List<String> roleCodes) {
if (roleCodes == null || roleCodes.isEmpty()) {
throw new BizException(403, "用户未分配角色");
}
}
private boolean passwordMatches(String rawPassword, String storedPassword) {
if (!StringUtils.hasText(storedPassword)) {
return false;
}
if (storedPassword.startsWith("$2a$") || storedPassword.startsWith("$2b$") || storedPassword.startsWith("$2y$")) {
return passwordEncoder.matches(rawPassword, storedPassword);
}
return storedPassword.equals(rawPassword);
}
private JwtUserPrincipal toPrincipal(UserRecord user, List<String> roleCodes, String clientType, String sessionId) {
return new JwtUserPrincipal(
user.userId(),
user.username(),
user.displayName(),
user.adcode(),
user.tenantId(),
user.tenantPath(),
user.deptId(),
user.deptPath(),
roleCodes,
clientType,
sessionId
);
}
private Optional<UserRecord> findUserByUsername(String username, String tenantId) {
String sql = """
SELECT user_id, username, display_name, password_hash, mobile_phone, adcode, tenant_id, tenant_path, dept_id, dept_path, status
FROM upms.tb_sys_user
WHERE username = :username
AND (:tenantId = '' OR tenant_id = :tenantId)
LIMIT 1
""";
return jdbcTemplate.query(sql, Map.of("username", username, "tenantId", tenantId), USER_ROW_MAPPER).stream().findFirst();
}
private Optional<UserRecord> findUserByMobile(String mobile, String tenantId) {
String sql = """
SELECT user_id, username, display_name, password_hash, mobile_phone, adcode, tenant_id, tenant_path, dept_id, dept_path, status
FROM upms.tb_sys_user
WHERE mobile_phone = :mobile
AND (:tenantId = '' OR tenant_id = :tenantId)
LIMIT 1
""";
return jdbcTemplate.query(sql, Map.of("mobile", mobile, "tenantId", tenantId), USER_ROW_MAPPER).stream().findFirst();
}
private Optional<UserRecord> findUserById(String userId) {
String sql = """
SELECT user_id, username, display_name, password_hash, mobile_phone, adcode, tenant_id, tenant_path, dept_id, dept_path, status
FROM upms.tb_sys_user
WHERE user_id = :userId
LIMIT 1
""";
return jdbcTemplate.query(sql, Map.of("userId", userId), USER_ROW_MAPPER).stream().findFirst();
}
private List<String> findRoleCodes(String userId) {
String sql = """
SELECT r.role_code
FROM upms.tb_sys_user_role ur
JOIN upms.tb_sys_role r ON r.role_id = ur.role_id
WHERE ur.user_id = :userId
ORDER BY r.role_code
""";
return jdbcTemplate.queryForList(sql, Map.of("userId", userId), String.class);
}
private Optional<TokenRecord> findTokenRecord(String refreshToken) {
String sql = """
SELECT token_id, session_id, client_type, user_id, refresh_token, expire_at, revoked
FROM auth.tb_auth_refresh_token
WHERE refresh_token = :refreshToken
LIMIT 1
""";
RowMapper<TokenRecord> mapper = (rs, rowNum) -> new TokenRecord(
rs.getString("token_id"),
rs.getString("session_id"),
normalizeClientType(rs.getString("client_type")),
rs.getString("user_id"),
rs.getString("refresh_token"),
rs.getTimestamp("expire_at").toInstant(),
rs.getBoolean("revoked")
);
return jdbcTemplate.query(sql, Map.of("refreshToken", refreshToken), mapper).stream().findFirst();
}
private void saveRefreshToken(JwtUserPrincipal principal, String refreshToken) {
String sql = """
INSERT INTO auth.tb_auth_refresh_token (
token_id, session_id, client_type, user_id, username, adcode,
tenant_id, tenant_path, dept_id, dept_path,
refresh_token, expire_at, revoked, revoked_at, last_active_at, created_at
) VALUES (
:tokenId, :sessionId, :clientType, :userId, :username, :adcode,
:tenantId, :tenantPath, :deptId, :deptPath,
:refreshToken, :expireAt, false, null, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
)
""";
MapSqlParameterSource parameters = new MapSqlParameterSource()
.addValue("tokenId", UUID.randomUUID().toString().replace("-", ""))
.addValue("sessionId", principal.sessionId())
.addValue("clientType", normalizeClientType(principal.clientType()))
.addValue("userId", principal.userId())
.addValue("username", principal.username())
.addValue("adcode", principal.adcode())
.addValue("tenantId", principal.tenantId())
.addValue("tenantPath", principal.tenantPath())
.addValue("deptId", principal.deptId())
.addValue("deptPath", principal.deptPath())
.addValue("refreshToken", refreshToken)
.addValue("expireAt", Instant.now().plus(authProperties.getRefreshTokenTtl()));
jdbcTemplate.update(sql, parameters);
}
private void revokeToken(String tokenId) {
String sql = """
UPDATE auth.tb_auth_refresh_token
SET revoked = true, revoked_at = CURRENT_TIMESTAMP
WHERE token_id = :tokenId
""";
jdbcTemplate.update(sql, Map.of("tokenId", tokenId));
}
private void enforceMiniSessionLimit(String userId) {
String sessionSql = """
SELECT session_id
FROM auth.tb_auth_refresh_token
WHERE user_id = :userId
AND client_type = 'MINI'
AND revoked = false
AND expire_at > CURRENT_TIMESTAMP
GROUP BY session_id
ORDER BY MAX(last_active_at) DESC
""";
List<String> sessions = jdbcTemplate.queryForList(sessionSql, Map.of("userId", userId), String.class);
if (sessions.size() <= 3) {
return;
}
List<String> needRevoke = sessions.subList(3, sessions.size());
String revokeSql = """
UPDATE auth.tb_auth_refresh_token
SET revoked = true, revoked_at = CURRENT_TIMESTAMP
WHERE user_id = :userId
AND session_id IN (:sessionIds)
AND revoked = false
""";
jdbcTemplate.update(revokeSql, new MapSqlParameterSource()
.addValue("userId", userId)
.addValue("sessionIds", needRevoke));
}
private void auditLogin(UserRecord user, String clientType, String loginStatus, String failureReason) {
String sql = """
INSERT INTO auth.tb_auth_login_audit (
audit_id, user_id, username, client_type, adcode, tenant_id, tenant_path,
dept_id, dept_path, login_ip, login_status, failure_reason, created_at
) VALUES (
:auditId, :userId, :username, :clientType, :adcode, :tenantId, :tenantPath,
:deptId, :deptPath, :loginIp, :loginStatus, :failureReason, CURRENT_TIMESTAMP
)
""";
MapSqlParameterSource parameters = new MapSqlParameterSource()
.addValue("auditId", UUID.randomUUID().toString().replace("-", ""))
.addValue("userId", user == null ? null : user.userId())
.addValue("username", user == null ? "UNKNOWN" : user.username())
.addValue("clientType", normalizeClientType(clientType))
.addValue("adcode", user == null ? null : user.adcode())
.addValue("tenantId", user == null ? null : user.tenantId())
.addValue("tenantPath", user == null ? null : user.tenantPath())
.addValue("deptId", user == null ? null : user.deptId())
.addValue("deptPath", user == null ? null : user.deptPath())
.addValue("loginIp", null)
.addValue("loginStatus", loginStatus)
.addValue("failureReason", failureReason);
jdbcTemplate.update(sql, parameters);
}
private String normalizeClientType(String clientType) {
if (!StringUtils.hasText(clientType)) {
return "WEB";
}
String normalized = clientType.trim().toUpperCase();
return "MINI".equals(normalized) ? "MINI" : "WEB";
}
private static UserRecord mapUser(ResultSet rs) throws SQLException {
return new UserRecord(
rs.getString("user_id"),
rs.getString("username"),
rs.getString("display_name"),
rs.getString("password_hash"),
rs.getString("mobile_phone"),
rs.getString("adcode"),
rs.getString("tenant_id"),
rs.getString("tenant_path"),
rs.getString("dept_id"),
rs.getString("dept_path"),
rs.getString("status")
);
}
private String safeAdcode(String adcode) {
return StringUtils.hasText(adcode) ? adcode : "";
}
private record UserRecord(
String userId,
String username,
String displayName,
String passwordHash,
String mobilePhone,
String adcode,
String tenantId,
String tenantPath,
String deptId,
String deptPath,
String status
) {
}
private record TokenRecord(
String tokenId,
String sessionId,
String clientType,
String userId,
String refreshToken,
Instant expireAt,
boolean revoked
) {
}
}

View File

@@ -4,6 +4,10 @@ server:
spring:
application:
name: k12study-auth
datasource:
url: jdbc:postgresql://${K12STUDY_DB_HOST:localhost}:${K12STUDY_DB_PORT:5432}/${K12STUDY_DB_NAME:k12study}
username: ${K12STUDY_DB_USER:k12study}
password: ${K12STUDY_DB_PASSWORD:k12study}
data:
redis:
host: ${K12STUDY_REDIS_HOST:localhost}

View File

@@ -6,11 +6,10 @@ server:
spring:
application:
name: k12study-boot-dev
autoconfigure:
exclude:
- org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
- org.mybatis.spring.boot.autoconfigure.MybatisAutoConfiguration
- com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration
datasource:
url: jdbc:postgresql://${K12STUDY_DB_HOST:localhost}:${K12STUDY_DB_PORT:5432}/${K12STUDY_DB_NAME:k12study}
username: ${K12STUDY_DB_USER:k12study}
password: ${K12STUDY_DB_PASSWORD:k12study}
data:
redis:
host: ${K12STUDY_REDIS_HOST:localhost}

View File

@@ -5,8 +5,14 @@ public final class SecurityConstants {
public static final String HEADER_USER_ID = "X-User-Id";
public static final String HEADER_USERNAME = "X-Username";
public static final String HEADER_DISPLAY_NAME = "X-Display-Name";
public static final String HEADER_ADCODE = "X-Adcode";
public static final String HEADER_TENANT_ID = "X-Tenant-Id";
public static final String HEADER_TENANT_PATH = "X-Tenant-Path";
public static final String HEADER_DEPT_ID = "X-Dept-Id";
public static final String HEADER_DEPT_PATH = "X-Dept-Path";
public static final String HEADER_ROLE_CODES = "X-Role-Codes";
public static final String HEADER_CLIENT_TYPE = "X-Client-Type";
public static final String HEADER_SESSION_ID = "X-Session-Id";
private SecurityConstants() {
}

View File

@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.k12study</groupId>
<artifactId>k12study-backend</artifactId>
<version>0.1.0-SNAPSHOT</version>
<relativePath>../../pom.xml</relativePath>
</parent>
<artifactId>common-feign</artifactId>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<dependency>
<groupId>io.github.openfeign</groupId>
<artifactId>feign-okhttp</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-webmvc</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,76 @@
package com.k12study.common.feign.config;
import com.k12study.common.feign.contract.FeignContractValidator;
import com.k12study.common.feign.interceptor.FeignAuthRelayInterceptor;
import feign.Client;
import feign.Logger;
import feign.RequestInterceptor;
import feign.okhttp.OkHttpClient;
import java.util.concurrent.TimeUnit;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.context.annotation.Bean;
/**
* @description Feign 自动装配入口;引入本模块即启用 @EnableFeignClients、OkHttp 客户端、Authorization 透传拦截器与启动期契约自检
* @filename FeignAutoConfiguration.java
* @author wangys
* @copyright xyzh
* @since 2026-04-17
*/
@AutoConfiguration
@ConditionalOnClass(name = "org.springframework.cloud.openfeign.FeignClient")
@EnableConfigurationProperties(FeignClientProperties.class)
@EnableFeignClients(basePackages = "com.k12study.api")
public class FeignAutoConfiguration {
/** 默认透传 Authorization 到下游 Feign 调用;业务方自定义时会覆盖此 Bean */
@Bean
@ConditionalOnMissingBean(FeignAuthRelayInterceptor.class)
public RequestInterceptor feignAuthRelayInterceptor() {
return new FeignAuthRelayInterceptor();
}
/** 将配置中的日志级别转为 Feign Logger.Level Bean,Feign 会自动应用到所有客户端 */
@Bean
@ConditionalOnMissingBean(Logger.Level.class)
public Logger.Level feignLoggerLevel(FeignClientProperties properties) {
return properties.getLogLevel();
}
/**
* 使用 OkHttp 作为底层 HTTP 客户端;相比 JDK HttpURLConnection 提供连接池、HTTP/2、细粒度超时,
* 生产环境高并发必备。classpath 无 okhttp3 时不生效(Conditional)。
*/
@Bean
@ConditionalOnMissingBean(Client.class)
@ConditionalOnClass(name = "okhttp3.OkHttpClient")
public Client feignOkHttpClient(FeignClientProperties properties) {
okhttp3.OkHttpClient delegate = new okhttp3.OkHttpClient.Builder()
.connectTimeout(properties.getConnectTimeout().toMillis(), TimeUnit.MILLISECONDS)
.readTimeout(properties.getReadTimeout().toMillis(), TimeUnit.MILLISECONDS)
.writeTimeout(properties.getWriteTimeout().toMillis(), TimeUnit.MILLISECONDS)
.retryOnConnectionFailure(properties.isRetryOnConnectionFailure())
.connectionPool(new okhttp3.ConnectionPool(
properties.getMaxIdleConnections(),
properties.getKeepAliveDuration().toMillis(),
TimeUnit.MILLISECONDS))
.build();
return new OkHttpClient(delegate);
}
/**
* 启动期契约自检:对比 Feign 接口路径与本地 RequestMappingHandlerMapping 注册的 Controller 路径,
* 不一致即告警。仅 Provider 进程(同时持有接口 + Controller)生效,纯 Consumer 自动跳过。
*/
@Bean
@ConditionalOnClass(name = "org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping")
@ConditionalOnProperty(prefix = "k12study.feign", name = "contract-validate", havingValue = "true", matchIfMissing = true)
public FeignContractValidator feignContractValidator() {
return new FeignContractValidator("com.k12study.api");
}
}

View File

@@ -0,0 +1,58 @@
package com.k12study.common.feign.config;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* @description Feign 客户端外部化参数;前缀 k12study.feign,覆盖 OkHttp 超时/连接池/日志级别/契约自检开关
* @filename FeignClientProperties.java
* @author wangys
* @copyright xyzh
* @since 2026-04-17
*/
@ConfigurationProperties(prefix = "k12study.feign")
public class FeignClientProperties {
/** 连接建立超时 */
private Duration connectTimeout = Duration.ofSeconds(5);
/** 读超时 */
private Duration readTimeout = Duration.ofSeconds(10);
/** 写超时 */
private Duration writeTimeout = Duration.ofSeconds(10);
/** 是否在连接失败时重试 */
private boolean retryOnConnectionFailure = true;
/** 连接池最大空闲连接数 */
private int maxIdleConnections = 64;
/** 连接池 keep-alive 时长 */
private Duration keepAliveDuration = Duration.ofMinutes(5);
/** Feign 日志级别:NONE / BASIC / HEADERS / FULL */
private feign.Logger.Level logLevel = feign.Logger.Level.NONE;
/** 启动时校验 Feign 契约与本地 Controller 一致 */
private boolean contractValidate = true;
public Duration getConnectTimeout() { return connectTimeout; }
public void setConnectTimeout(Duration connectTimeout) { this.connectTimeout = connectTimeout; }
public Duration getReadTimeout() { return readTimeout; }
public void setReadTimeout(Duration readTimeout) { this.readTimeout = readTimeout; }
public Duration getWriteTimeout() { return writeTimeout; }
public void setWriteTimeout(Duration writeTimeout) { this.writeTimeout = writeTimeout; }
public boolean isRetryOnConnectionFailure() { return retryOnConnectionFailure; }
public void setRetryOnConnectionFailure(boolean retryOnConnectionFailure) {
this.retryOnConnectionFailure = retryOnConnectionFailure;
}
public int getMaxIdleConnections() { return maxIdleConnections; }
public void setMaxIdleConnections(int maxIdleConnections) { this.maxIdleConnections = maxIdleConnections; }
public Duration getKeepAliveDuration() { return keepAliveDuration; }
public void setKeepAliveDuration(Duration keepAliveDuration) { this.keepAliveDuration = keepAliveDuration; }
public feign.Logger.Level getLogLevel() { return logLevel; }
public void setLogLevel(feign.Logger.Level logLevel) { this.logLevel = logLevel; }
public boolean isContractValidate() { return contractValidate; }
public void setContractValidate(boolean contractValidate) { this.contractValidate = contractValidate; }
}

View File

@@ -0,0 +1,203 @@
package com.k12study.common.feign.contract;
import java.lang.annotation.Annotation;
import java.lang.reflect.Method;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.context.ApplicationListener;
import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider;
import org.springframework.context.event.ContextRefreshedEvent;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.core.type.classreading.MetadataReader;
import org.springframework.core.type.classreading.MetadataReaderFactory;
import org.springframework.core.type.filter.AnnotationTypeFilter;
import org.springframework.core.type.filter.TypeFilter;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
/**
* @description 启动期 Feign 契约自检;扫描 @FeignClient 接口路径与本地 RequestMappingHandlerMapping 注册的 Controller 路径,不一致则告警
* @filename FeignContractValidator.java
* @author wangys
* @copyright xyzh
* @since 2026-04-17
*/
public class FeignContractValidator
implements ApplicationListener<ContextRefreshedEvent>, ApplicationContextAware {
private static final Logger log = LoggerFactory.getLogger(FeignContractValidator.class);
private final String basePackage;
private ApplicationContext applicationContext;
public FeignContractValidator(String basePackage) {
this.basePackage = basePackage;
}
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
this.applicationContext = applicationContext;
}
@Override
public void onApplicationEvent(ContextRefreshedEvent event) {
ObjectProvider<RequestMappingHandlerMapping> provider =
applicationContext.getBeanProvider(RequestMappingHandlerMapping.class);
RequestMappingHandlerMapping handlerMapping = provider.getIfAvailable();
if (handlerMapping == null) {
return;
}
Set<Endpoint> mvcEndpoints = collectMvcEndpoints(handlerMapping);
if (mvcEndpoints.isEmpty()) {
return;
}
Set<Class<?>> feignInterfaces = scanFeignInterfaces(basePackage);
if (feignInterfaces.isEmpty()) {
return;
}
int totalChecked = 0;
int mismatchCount = 0;
for (Class<?> feignInterface : feignInterfaces) {
FeignClient feignClient = AnnotationUtils.findAnnotation(feignInterface, FeignClient.class);
String basePath = feignClient == null ? "" : trimSlashes(feignClient.path());
for (Method method : feignInterface.getDeclaredMethods()) {
Endpoint expected = resolveEndpoint(method, basePath);
if (expected == null) {
continue;
}
totalChecked++;
if (!mvcEndpoints.contains(expected)) {
mismatchCount++;
log.warn("[feign-contract] mismatch: {}#{}() expects {} {} but no matching @RequestMapping found",
feignInterface.getSimpleName(), method.getName(),
expected.method(), expected.path());
}
}
}
if (mismatchCount > 0) {
log.warn("[feign-contract] validation finished: {} method(s) checked, {} mismatch(es)",
totalChecked, mismatchCount);
} else {
log.info("[feign-contract] validation passed: {} method(s) aligned with local controllers",
totalChecked);
}
}
private Set<Endpoint> collectMvcEndpoints(RequestMappingHandlerMapping handlerMapping) {
Set<Endpoint> endpoints = new LinkedHashSet<>();
for (Map.Entry<RequestMappingInfo, HandlerMethod> entry : handlerMapping.getHandlerMethods().entrySet()) {
RequestMappingInfo info = entry.getKey();
Set<RequestMethod> methods = info.getMethodsCondition().getMethods();
Set<String> patterns = info.getPathPatternsCondition() != null
? info.getPathPatternsCondition().getPatternValues()
: Set.of();
if (methods.isEmpty() || patterns.isEmpty()) {
continue;
}
for (RequestMethod m : methods) {
for (String p : patterns) {
endpoints.add(new Endpoint(m.name(), normalize(p)));
}
}
}
return endpoints;
}
private Set<Class<?>> scanFeignInterfaces(String basePackage) {
ClassPathScanningCandidateComponentProvider scanner =
new ClassPathScanningCandidateComponentProvider(false) {
@Override
protected boolean isCandidateComponent(
org.springframework.beans.factory.annotation.AnnotatedBeanDefinition beanDefinition) {
return beanDefinition.getMetadata().isInterface();
}
};
TypeFilter filter = new AnnotationTypeFilter(FeignClient.class) {
@Override
protected boolean matchClassName(String className) {
return false;
}
@Override
public boolean match(MetadataReader metadataReader, MetadataReaderFactory factory) {
return metadataReader.getAnnotationMetadata().hasAnnotation(FeignClient.class.getName());
}
};
scanner.addIncludeFilter(filter);
Set<Class<?>> classes = new LinkedHashSet<>();
scanner.findCandidateComponents(basePackage).forEach(beanDef -> {
try {
classes.add(Class.forName(beanDef.getBeanClassName()));
} catch (ClassNotFoundException ignore) {
// skip
}
});
return classes;
}
private Endpoint resolveEndpoint(Method method, String basePath) {
for (Annotation annotation : method.getAnnotations()) {
HttpDescriptor descriptor = describe(annotation);
if (descriptor != null) {
String path = normalize("/" + basePath + "/" + trimSlashes(descriptor.path()));
return new Endpoint(descriptor.method(), path);
}
}
return null;
}
private HttpDescriptor describe(Annotation annotation) {
if (annotation instanceof GetMapping a) return new HttpDescriptor("GET", firstPath(a.value(), a.path()));
if (annotation instanceof PostMapping a) return new HttpDescriptor("POST", firstPath(a.value(), a.path()));
if (annotation instanceof PutMapping a) return new HttpDescriptor("PUT", firstPath(a.value(), a.path()));
if (annotation instanceof DeleteMapping a) return new HttpDescriptor("DELETE", firstPath(a.value(), a.path()));
if (annotation instanceof PatchMapping a) return new HttpDescriptor("PATCH", firstPath(a.value(), a.path()));
if (annotation instanceof RequestMapping a && a.method().length > 0) {
return new HttpDescriptor(a.method()[0].name(), firstPath(a.value(), a.path()));
}
return null;
}
private String firstPath(String[] values, String[] paths) {
if (values != null && values.length > 0) return values[0];
if (paths != null && paths.length > 0) return paths[0];
return "";
}
private String trimSlashes(String s) {
if (s == null || s.isEmpty()) return "";
String result = s;
while (result.startsWith("/")) result = result.substring(1);
while (result.endsWith("/")) result = result.substring(0, result.length() - 1);
return result;
}
private String normalize(String path) {
String n = path.replaceAll("/+", "/");
if (n.length() > 1 && n.endsWith("/")) {
n = n.substring(0, n.length() - 1);
}
return n;
}
private record Endpoint(String method, String path) {}
private record HttpDescriptor(String method, String path) {}
}

View File

@@ -0,0 +1,37 @@
package com.k12study.common.feign.interceptor;
import feign.RequestInterceptor;
import feign.RequestTemplate;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpHeaders;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
/**
* @description Feign 请求拦截器:从当前 Servlet 请求提取 Authorization 并透传下游,消费方调用时无需显式传递 token
* @filename FeignAuthRelayInterceptor.java
* @author wangys
* @copyright xyzh
* @since 2026-04-17
*/
public class FeignAuthRelayInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate template) {
// 调用方如已显式 setHeader("Authorization"),保留调用方意图
if (template.headers().containsKey(HttpHeaders.AUTHORIZATION)) {
return;
}
ServletRequestAttributes attributes =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
// 非 HTTP 请求上下文(定时任务/MQ 消费)下不做任何注入
if (attributes == null) {
return;
}
HttpServletRequest request = attributes.getRequest();
String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);
if (authorization != null && !authorization.isBlank()) {
template.header(HttpHeaders.AUTHORIZATION, authorization);
}
}
}

View File

@@ -0,0 +1 @@
com.k12study.common.feign.config.FeignAutoConfiguration

View File

@@ -1,10 +1,17 @@
package com.k12study.common.security.context;
import java.util.List;
public record RequestUserContext(
String userId,
String username,
String displayName,
String adcode,
String tenantId,
String deptId
String tenantPath,
String deptId,
String deptPath,
List<String> roleCodes,
String clientType,
String sessionId
) {
}

View File

@@ -5,8 +5,10 @@ import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.Date;
import java.util.List;
import javax.crypto.SecretKey;
public class JwtTokenProvider {
@@ -19,15 +21,29 @@ public class JwtTokenProvider {
}
public String createAccessToken(JwtUserPrincipal principal) {
return createToken(principal, authProperties.getAccessTokenTtl());
}
public String createRefreshToken(JwtUserPrincipal principal) {
return createToken(principal, authProperties.getRefreshTokenTtl());
}
private String createToken(JwtUserPrincipal principal, Duration ttl) {
Instant now = Instant.now();
return Jwts.builder()
.subject(principal.userId())
.claim("username", principal.username())
.claim("displayName", principal.displayName())
.claim("adcode", principal.adcode())
.claim("tenantId", principal.tenantId())
.claim("tenantPath", principal.tenantPath())
.claim("deptId", principal.deptId())
.claim("deptPath", principal.deptPath())
.claim("roleCodes", principal.roleCodes())
.claim("clientType", principal.clientType())
.claim("sessionId", principal.sessionId())
.issuedAt(Date.from(now))
.expiration(Date.from(now.plus(authProperties.getAccessTokenTtl())))
.expiration(Date.from(now.plus(ttl)))
.signWith(secretKey)
.compact();
}
@@ -38,12 +54,23 @@ public class JwtTokenProvider {
.build()
.parseSignedClaims(token)
.getPayload();
@SuppressWarnings("unchecked")
List<String> roleCodes = claims.get("roleCodes", List.class);
if (roleCodes == null) {
roleCodes = List.of();
}
return new JwtUserPrincipal(
claims.getSubject(),
claims.get("username", String.class),
claims.get("displayName", String.class),
claims.get("adcode", String.class),
claims.get("tenantId", String.class),
claims.get("deptId", String.class)
claims.get("tenantPath", String.class),
claims.get("deptId", String.class),
claims.get("deptPath", String.class),
roleCodes,
claims.get("clientType", String.class),
claims.get("sessionId", String.class)
);
}
}

View File

@@ -1,10 +1,17 @@
package com.k12study.common.security.jwt;
import java.util.List;
public record JwtUserPrincipal(
String userId,
String username,
String displayName,
String adcode,
String tenantId,
String deptId
String tenantPath,
String deptId,
String deptPath,
List<String> roleCodes,
String clientType,
String sessionId
) {
}

View File

@@ -9,6 +9,8 @@ import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.filter.OncePerRequestFilter;
@@ -27,8 +29,14 @@ public class CommonWebMvcConfiguration extends OncePerRequestFilter {
request.getHeader(SecurityConstants.HEADER_USER_ID),
request.getHeader(SecurityConstants.HEADER_USERNAME),
request.getHeader(SecurityConstants.HEADER_DISPLAY_NAME),
request.getHeader(SecurityConstants.HEADER_ADCODE),
request.getHeader(SecurityConstants.HEADER_TENANT_ID),
request.getHeader(SecurityConstants.HEADER_DEPT_ID)
request.getHeader(SecurityConstants.HEADER_TENANT_PATH),
request.getHeader(SecurityConstants.HEADER_DEPT_ID),
request.getHeader(SecurityConstants.HEADER_DEPT_PATH),
parseRoleCodes(request.getHeader(SecurityConstants.HEADER_ROLE_CODES)),
request.getHeader(SecurityConstants.HEADER_CLIENT_TYPE),
request.getHeader(SecurityConstants.HEADER_SESSION_ID)
));
response.setHeader(SecurityConstants.TRACE_ID, TraceIdHolder.getOrCreate());
filterChain.doFilter(request, response);
@@ -37,4 +45,14 @@ public class CommonWebMvcConfiguration extends OncePerRequestFilter {
RequestUserContextHolder.clear();
}
}
private List<String> parseRoleCodes(String roleCodesHeader) {
if (roleCodesHeader == null || roleCodesHeader.isBlank()) {
return List.of();
}
return Arrays.stream(roleCodesHeader.split(","))
.map(String::trim)
.filter(code -> !code.isBlank())
.toList();
}
}

View File

@@ -20,5 +20,6 @@
<module>common-security</module>
<module>common-mybatis</module>
<module>common-redis</module>
<module>common-feign</module>
</modules>
</project>

View File

@@ -42,12 +42,21 @@ public class JwtRelayFilter implements GlobalFilter, Ordered {
try {
String token = authorization.substring(authProperties.getTokenPrefix().length());
JwtUserPrincipal principal = jwtTokenProvider.parse(token);
if ("MINI".equalsIgnoreCase(principal.clientType()) && !principal.roleCodes().contains("STUDENT")) {
return forbidden(exchange, "MINI client only allows STUDENT role");
}
var mutatedRequest = exchange.getRequest().mutate()
.header(SecurityConstants.HEADER_USER_ID, principal.userId())
.header(SecurityConstants.HEADER_USERNAME, principal.username())
.header(SecurityConstants.HEADER_DISPLAY_NAME, principal.displayName())
.header(SecurityConstants.HEADER_TENANT_ID, principal.tenantId())
.header(SecurityConstants.HEADER_DEPT_ID, principal.deptId())
.header(SecurityConstants.HEADER_USER_ID, safe(principal.userId()))
.header(SecurityConstants.HEADER_USERNAME, safe(principal.username()))
.header(SecurityConstants.HEADER_DISPLAY_NAME, safe(principal.displayName()))
.header(SecurityConstants.HEADER_ADCODE, safe(principal.adcode()))
.header(SecurityConstants.HEADER_TENANT_ID, safe(principal.tenantId()))
.header(SecurityConstants.HEADER_TENANT_PATH, safe(principal.tenantPath()))
.header(SecurityConstants.HEADER_DEPT_ID, safe(principal.deptId()))
.header(SecurityConstants.HEADER_DEPT_PATH, safe(principal.deptPath()))
.header(SecurityConstants.HEADER_ROLE_CODES, String.join(",", principal.roleCodes()))
.header(SecurityConstants.HEADER_CLIENT_TYPE, safe(principal.clientType()))
.header(SecurityConstants.HEADER_SESSION_ID, safe(principal.sessionId()))
.build();
return chain.filter(exchange.mutate().request(mutatedRequest).build());
} catch (Exception exception) {
@@ -67,9 +76,24 @@ public class JwtRelayFilter implements GlobalFilter, Ordered {
private Mono<Void> unauthorized(ServerWebExchange exchange, String message) {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
exchange.getResponse().getHeaders().set(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
byte[] body = ("{\"code\":401,\"message\":\"" + message + "\",\"data\":null}").getBytes();
String bodyJson = "{\"code\":401,\"message\":\"%s\",\"data\":null}".formatted(message);
byte[] body = bodyJson.getBytes();
return exchange.getResponse().writeWith(Mono.just(exchange.getResponse()
.bufferFactory()
.wrap(body)));
}
private Mono<Void> forbidden(ServerWebExchange exchange, String message) {
exchange.getResponse().setStatusCode(HttpStatus.FORBIDDEN);
exchange.getResponse().getHeaders().set(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
String bodyJson = "{\"code\":403,\"message\":\"%s\",\"data\":null}".formatted(message);
byte[] body = bodyJson.getBytes();
return exchange.getResponse().writeWith(Mono.just(exchange.getResponse()
.bufferFactory()
.wrap(body)));
}
private String safe(String value) {
return value == null ? "" : value;
}
}

View File

@@ -27,6 +27,7 @@
<spring.boot.version>3.3.5</spring.boot.version>
<spring.cloud.version>2023.0.3</spring.cloud.version>
<spring.cloud.alibaba.version>2023.0.3.2</spring.cloud.alibaba.version>
<mybatis.plus.version>3.5.7</mybatis.plus.version>
<postgresql.version>42.7.4</postgresql.version>
<jjwt.version>0.12.6</jjwt.version>
@@ -49,6 +50,13 @@
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>${spring.cloud.alibaba.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>

View File

@@ -3,16 +3,34 @@ package com.k12study.upms.controller;
import com.k12study.api.upms.dto.AreaNodeDto;
import com.k12study.api.upms.dto.CurrentRouteUserDto;
import com.k12study.api.upms.dto.DeptNodeDto;
import com.k12study.api.upms.dto.FileMetadataDto;
import com.k12study.api.upms.dto.FileUploadRequestDto;
import com.k12study.api.upms.dto.InboxMessageDto;
import com.k12study.api.upms.dto.MessageReadResultDto;
import com.k12study.api.upms.dto.RouteNodeDto;
import com.k12study.api.upms.dto.SchoolClassCourseDto;
import com.k12study.api.upms.dto.SchoolClassDto;
import com.k12study.api.upms.dto.SchoolClassMemberDto;
import com.k12study.api.upms.dto.TenantNodeDto;
import com.k12study.api.upms.remote.UpmsApiPaths;
import com.k12study.common.api.response.ApiResponse;
import com.k12study.upms.service.UpmsQueryService;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
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;
/**
* @description UPMS HTTP 入口;路由/组织/班级/文件/站内信等聚合查询,全部经 UpmsQueryService 执行租户隔离后返回
* @filename UpmsController.java
* @author wangys
* @copyright xyzh
* @since 2026-04-17
*/
@RestController
@RequestMapping(UpmsApiPaths.BASE)
public class UpmsController {
@@ -23,27 +41,79 @@ public class UpmsController {
}
@GetMapping("/routes")
public ApiResponse<List<RouteNodeDto>> routes() {
return ApiResponse.success(upmsQueryService.routes());
public ApiResponse<List<RouteNodeDto>> routes(
@RequestHeader(value = "Authorization", required = false) String authorizationHeader) {
return ApiResponse.success(upmsQueryService.routes(authorizationHeader));
}
@GetMapping("/areas")
public ApiResponse<List<AreaNodeDto>> areas() {
return ApiResponse.success(upmsQueryService.areas());
public ApiResponse<List<AreaNodeDto>> areas(
@RequestHeader(value = "Authorization", required = false) String authorizationHeader) {
return ApiResponse.success(upmsQueryService.areas(authorizationHeader));
}
@GetMapping("/tenants")
public ApiResponse<List<TenantNodeDto>> tenants() {
return ApiResponse.success(upmsQueryService.tenants());
public ApiResponse<List<TenantNodeDto>> tenants(
@RequestHeader(value = "Authorization", required = false) String authorizationHeader) {
return ApiResponse.success(upmsQueryService.tenants(authorizationHeader));
}
@GetMapping("/departments")
public ApiResponse<List<DeptNodeDto>> departments() {
return ApiResponse.success(upmsQueryService.departments());
public ApiResponse<List<DeptNodeDto>> departments(
@RequestHeader(value = "Authorization", required = false) String authorizationHeader) {
return ApiResponse.success(upmsQueryService.departments(authorizationHeader));
}
@GetMapping("/users/current")
public ApiResponse<CurrentRouteUserDto> currentUser() {
return ApiResponse.success(upmsQueryService.currentUser());
public ApiResponse<CurrentRouteUserDto> currentUser(
@RequestHeader(value = "Authorization", required = false) String authorizationHeader) {
return ApiResponse.success(upmsQueryService.currentUser(authorizationHeader));
}
@GetMapping("/classes")
public ApiResponse<List<SchoolClassDto>> classes(
@RequestHeader(value = "Authorization", required = false) String authorizationHeader) {
return ApiResponse.success(upmsQueryService.classes(authorizationHeader));
}
@GetMapping("/classes/{classId}/members")
public ApiResponse<List<SchoolClassMemberDto>> classMembers(
@RequestHeader(value = "Authorization", required = false) String authorizationHeader,
@PathVariable String classId) {
return ApiResponse.success(upmsQueryService.classMembers(authorizationHeader, classId));
}
@GetMapping("/classes/{classId}/courses")
public ApiResponse<List<SchoolClassCourseDto>> classCourses(
@RequestHeader(value = "Authorization", required = false) String authorizationHeader,
@PathVariable String classId) {
return ApiResponse.success(upmsQueryService.classCourses(authorizationHeader, classId));
}
@PostMapping("/files/upload")
public ApiResponse<FileMetadataDto> uploadFile(
@RequestHeader(value = "Authorization", required = false) String authorizationHeader,
@RequestBody FileUploadRequestDto request) {
return ApiResponse.success("上传登记成功", upmsQueryService.uploadFile(authorizationHeader, request));
}
@GetMapping("/files/{fileId}")
public ApiResponse<FileMetadataDto> fileById(
@RequestHeader(value = "Authorization", required = false) String authorizationHeader,
@PathVariable String fileId) {
return ApiResponse.success(upmsQueryService.fileById(authorizationHeader, fileId));
}
@GetMapping("/messages/inbox")
public ApiResponse<List<InboxMessageDto>> inboxMessages(
@RequestHeader(value = "Authorization", required = false) String authorizationHeader) {
return ApiResponse.success(upmsQueryService.inboxMessages(authorizationHeader));
}
@PostMapping("/messages/{messageId}/read")
public ApiResponse<MessageReadResultDto> readMessage(
@RequestHeader(value = "Authorization", required = false) String authorizationHeader,
@PathVariable String messageId) {
return ApiResponse.success("已标记已读", upmsQueryService.readMessage(authorizationHeader, messageId));
}
}

View File

@@ -3,17 +3,38 @@ package com.k12study.upms.service;
import com.k12study.api.upms.dto.AreaNodeDto;
import com.k12study.api.upms.dto.CurrentRouteUserDto;
import com.k12study.api.upms.dto.DeptNodeDto;
import com.k12study.api.upms.dto.FileMetadataDto;
import com.k12study.api.upms.dto.FileUploadRequestDto;
import com.k12study.api.upms.dto.InboxMessageDto;
import com.k12study.api.upms.dto.MessageReadResultDto;
import com.k12study.api.upms.dto.RouteNodeDto;
import com.k12study.api.upms.dto.SchoolClassCourseDto;
import com.k12study.api.upms.dto.SchoolClassDto;
import com.k12study.api.upms.dto.SchoolClassMemberDto;
import com.k12study.api.upms.dto.TenantNodeDto;
import java.util.List;
public interface UpmsQueryService {
List<RouteNodeDto> routes();
List<RouteNodeDto> routes(String authorizationHeader);
List<AreaNodeDto> areas();
List<AreaNodeDto> areas(String authorizationHeader);
List<TenantNodeDto> tenants();
List<TenantNodeDto> tenants(String authorizationHeader);
List<DeptNodeDto> departments();
List<DeptNodeDto> departments(String authorizationHeader);
CurrentRouteUserDto currentUser();
CurrentRouteUserDto currentUser(String authorizationHeader);
List<SchoolClassDto> classes(String authorizationHeader);
List<SchoolClassMemberDto> classMembers(String authorizationHeader, String classId);
List<SchoolClassCourseDto> classCourses(String authorizationHeader, String classId);
FileMetadataDto uploadFile(String authorizationHeader, FileUploadRequestDto request);
FileMetadataDto fileById(String authorizationHeader, String fileId);
List<InboxMessageDto> inboxMessages(String authorizationHeader);
MessageReadResultDto readMessage(String authorizationHeader, String messageId);
}

View File

@@ -3,130 +3,762 @@ package com.k12study.upms.service.impl;
import com.k12study.api.upms.dto.AreaNodeDto;
import com.k12study.api.upms.dto.CurrentRouteUserDto;
import com.k12study.api.upms.dto.DeptNodeDto;
import com.k12study.api.upms.dto.FileMetadataDto;
import com.k12study.api.upms.dto.FileUploadRequestDto;
import com.k12study.api.upms.dto.InboxMessageDto;
import com.k12study.api.upms.dto.LayoutType;
import com.k12study.api.upms.dto.MessageReadResultDto;
import com.k12study.api.upms.dto.RouteMetaDto;
import com.k12study.api.upms.dto.RouteNodeDto;
import com.k12study.api.upms.dto.SchoolClassCourseDto;
import com.k12study.api.upms.dto.SchoolClassDto;
import com.k12study.api.upms.dto.SchoolClassMemberDto;
import com.k12study.api.upms.dto.TenantNodeDto;
import com.k12study.common.security.context.RequestUserContextHolder;
import com.k12study.common.security.jwt.JwtTokenProvider;
import com.k12study.common.security.jwt.JwtUserPrincipal;
import com.k12study.common.web.exception.BizException;
import com.k12study.upms.service.UpmsQueryService;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.stream.Collectors;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
@Service
public class UpmsQueryServiceImpl implements UpmsQueryService {
private final NamedParameterJdbcTemplate jdbcTemplate;
private final JwtTokenProvider jwtTokenProvider;
@Override
public List<RouteNodeDto> routes() {
return List.of(
new RouteNodeDto(
"dashboard",
"/",
"dashboard",
"dashboard",
LayoutType.SIDEBAR,
new RouteMetaDto("控制台", "layout-dashboard", List.of("dashboard:view"), false),
List.of()
),
new RouteNodeDto(
"tenant-management",
"/tenant",
"tenant-management",
"tenant",
LayoutType.SIDEBAR,
new RouteMetaDto("租户组织", "building-2", List.of("tenant:view"), false),
List.of()
)
);
public UpmsQueryServiceImpl(NamedParameterJdbcTemplate jdbcTemplate, JwtTokenProvider jwtTokenProvider) {
this.jdbcTemplate = jdbcTemplate;
this.jwtTokenProvider = jwtTokenProvider;
}
@Override
public List<AreaNodeDto> areas() {
return List.of(
new AreaNodeDto(
"330000",
"100000",
"浙江省",
"PROVINCE",
List.of(
new AreaNodeDto(
"330100",
"330000",
"杭州市",
"CITY",
List.of()
)
)
)
);
public List<RouteNodeDto> routes(String authorizationHeader) {
AuthContext context = requireAuth(authorizationHeader);
String sql = """
SELECT DISTINCT
m.route_id, m.parent_route_id, m.route_path, m.route_name,
m.component_key, m.layout_type, m.title, m.icon, m.permission_code, m.hidden
FROM upms.tb_sys_menu m
JOIN upms.tb_sys_role_menu rm ON rm.route_id = m.route_id
JOIN upms.tb_sys_user_role ur ON ur.role_id = rm.role_id
WHERE ur.user_id = :userId
AND m.tenant_id = :tenantId
ORDER BY m.created_at, m.route_id
""";
List<RouteRow> rows = jdbcTemplate.query(
sql,
Map.of("userId", context.userId(), "tenantId", context.tenantId()),
(rs, rowNum) -> new RouteRow(
rs.getString("route_id"),
rs.getString("parent_route_id"),
rs.getString("route_path"),
rs.getString("route_name"),
rs.getString("component_key"),
rs.getString("layout_type"),
rs.getString("title"),
rs.getString("icon"),
rs.getString("permission_code"),
rs.getBoolean("hidden")
));
return buildRouteTree(rows);
}
@Override
public List<TenantNodeDto> tenants() {
return List.of(
new TenantNodeDto(
"SCH-HQ",
null,
"K12Study 总校",
"HEAD_SCHOOL",
"330100",
"/SCH-HQ/",
List.of(
new TenantNodeDto(
"SCH-ZJ-HZ-01",
"SCH-HQ",
"杭州分校",
"CITY_SCHOOL",
"330100",
"/SCH-HQ/SCH-ZJ-HZ-01/",
List.of()
)
)
)
);
public List<AreaNodeDto> areas(String authorizationHeader) {
requireAuth(authorizationHeader);
String sql = """
SELECT id, pid, adcode, name, area_type
FROM upms.tb_sys_area
WHERE del_flag = '0'
ORDER BY area_sort NULLS LAST, id
""";
List<AreaRow> rows = jdbcTemplate.query(
sql,
Map.of(),
(rs, rowNum) -> new AreaRow(
rs.getLong("id"),
rs.getLong("pid"),
String.valueOf(rs.getLong("adcode")),
rs.getString("name"),
rs.getString("area_type")
));
return buildAreaTree(rows);
}
@Override
public List<DeptNodeDto> departments() {
return List.of(
new DeptNodeDto(
"DEPT-HQ",
null,
"总校教学部",
"GRADE",
"SCH-HQ",
"330100",
"/SCH-HQ/",
"/DEPT-HQ/",
List.of(
new DeptNodeDto(
"DEPT-HQ-MATH",
"DEPT-HQ",
"数学学科组",
"SUBJECT",
"SCH-HQ",
"330100",
"/SCH-HQ/",
"/DEPT-HQ/DEPT-HQ-MATH/",
List.of()
)
)
)
);
public List<TenantNodeDto> tenants(String authorizationHeader) {
AuthContext context = requireAuth(authorizationHeader);
List<String> roleCodes = findRoleCodes(context.userId());
boolean superAdmin = roleCodes.stream().anyMatch("SUPER_ADMIN"::equalsIgnoreCase);
StringBuilder sql = new StringBuilder("""
SELECT tenant_id, parent_tenant_id, tenant_name, tenant_type, adcode, tenant_path
FROM upms.tb_sys_tenant
WHERE status = 'ACTIVE'
""");
MapSqlParameterSource params = new MapSqlParameterSource();
if (!superAdmin) {
if (StringUtils.hasText(context.tenantPath())) {
sql.append(" AND tenant_path LIKE :tenantPathPrefix ");
params.addValue("tenantPathPrefix", context.tenantPath() + "%");
} else {
sql.append(" AND tenant_id = :tenantId ");
params.addValue("tenantId", context.tenantId());
}
}
sql.append(" ORDER BY tenant_path ");
List<TenantRow> rows = jdbcTemplate.query(
sql.toString(),
params,
(rs, rowNum) -> new TenantRow(
rs.getString("tenant_id"),
rs.getString("parent_tenant_id"),
rs.getString("tenant_name"),
rs.getString("tenant_type"),
rs.getString("adcode"),
rs.getString("tenant_path")
));
return buildTenantTree(rows);
}
@Override
public CurrentRouteUserDto currentUser() {
var context = RequestUserContextHolder.get();
public List<DeptNodeDto> departments(String authorizationHeader) {
AuthContext context = requireAuth(authorizationHeader);
List<String> roleCodes = findRoleCodes(context.userId());
boolean superAdmin = roleCodes.stream().anyMatch("SUPER_ADMIN"::equalsIgnoreCase);
StringBuilder sql = new StringBuilder("""
SELECT dept_id, parent_dept_id, dept_name, dept_type, tenant_id, adcode, tenant_path, dept_path
FROM upms.tb_sys_dept
WHERE tenant_id = :tenantId
""");
MapSqlParameterSource params = new MapSqlParameterSource()
.addValue("tenantId", context.tenantId());
if (!superAdmin && StringUtils.hasText(context.deptPath())) {
sql.append(" AND dept_path LIKE :deptPathPrefix ");
params.addValue("deptPathPrefix", context.deptPath() + "%");
}
sql.append(" ORDER BY dept_path ");
List<DeptRow> rows = jdbcTemplate.query(
sql.toString(),
params,
(rs, rowNum) -> new DeptRow(
rs.getString("dept_id"),
rs.getString("parent_dept_id"),
rs.getString("dept_name"),
rs.getString("dept_type"),
rs.getString("tenant_id"),
rs.getString("adcode"),
rs.getString("tenant_path"),
rs.getString("dept_path")
));
return buildDeptTree(rows);
}
@Override
public CurrentRouteUserDto currentUser(String authorizationHeader) {
AuthContext context = requireAuth(authorizationHeader);
UserRow user = findUserRow(context.userId(), context.tenantId())
.orElseThrow(() -> new BizException(404, "当前用户不存在"));
List<String> permissionCodes = findPermissionCodes(context.userId(), context.tenantId());
return new CurrentRouteUserDto(
context == null ? "U10001" : context.userId(),
context == null ? "admin" : context.username(),
context == null ? "K12Study 管理员" : context.displayName(),
"330100",
context == null ? "SCH-HQ" : context.tenantId(),
"/SCH-HQ/",
context == null ? "DEPT-HQ-ADMIN" : context.deptId(),
"/DEPT-HQ/DEPT-HQ-ADMIN/",
List.of("dashboard:view", "tenant:view", "dept:view")
user.userId(),
user.username(),
user.displayName(),
user.adcode(),
user.tenantId(),
user.tenantPath(),
user.deptId(),
user.deptPath(),
permissionCodes
);
}
@Override
public List<SchoolClassDto> classes(String authorizationHeader) {
AuthContext context = requireAuth(authorizationHeader);
String sql = """
SELECT class_id, class_code, class_name, grade_code, status, tenant_id, dept_id
FROM upms.tb_school_class
WHERE tenant_id = :tenantId
ORDER BY created_at DESC
""";
return jdbcTemplate.query(
sql,
Map.of("tenantId", context.tenantId()),
(rs, rowNum) -> new SchoolClassDto(
rs.getString("class_id"),
rs.getString("class_code"),
rs.getString("class_name"),
rs.getString("grade_code"),
rs.getString("status"),
rs.getString("tenant_id"),
rs.getString("dept_id")
));
}
@Override
public List<SchoolClassMemberDto> classMembers(String authorizationHeader, String classId) {
AuthContext context = requireAuth(authorizationHeader);
ensureClassInTenant(classId, context.tenantId());
String sql = """
SELECT m.class_id, m.user_id, u.username, u.display_name, m.member_role, m.member_status, m.joined_at, m.left_at
FROM upms.tb_school_class_member m
JOIN upms.tb_sys_user u ON u.user_id = m.user_id
WHERE m.class_id = :classId
AND m.tenant_id = :tenantId
ORDER BY m.joined_at
""";
return jdbcTemplate.query(
sql,
Map.of("classId", classId, "tenantId", context.tenantId()),
(rs, rowNum) -> new SchoolClassMemberDto(
rs.getString("class_id"),
rs.getString("user_id"),
rs.getString("username"),
rs.getString("display_name"),
rs.getString("member_role"),
rs.getString("member_status"),
toInstant(rs.getTimestamp("joined_at")),
toInstant(rs.getTimestamp("left_at"))
));
}
@Override
public List<SchoolClassCourseDto> classCourses(String authorizationHeader, String classId) {
AuthContext context = requireAuth(authorizationHeader);
ensureClassInTenant(classId, context.tenantId());
String sql = """
SELECT class_id, course_id, relation_status
FROM upms.tb_school_class_course_rel
WHERE class_id = :classId
AND tenant_id = :tenantId
ORDER BY created_at
""";
return jdbcTemplate.query(
sql,
Map.of("classId", classId, "tenantId", context.tenantId()),
(rs, rowNum) -> new SchoolClassCourseDto(
rs.getString("class_id"),
rs.getString("course_id"),
rs.getString("relation_status")
));
}
@Override
public FileMetadataDto uploadFile(String authorizationHeader, FileUploadRequestDto request) {
AuthContext context = requireAuth(authorizationHeader);
if (request == null || !StringUtils.hasText(request.objectKey())) {
throw new BizException(400, "objectKey 不能为空");
}
String mediaType = normalizeMediaType(request.mediaType());
String fileId = "FILE-" + UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase();
String sql = """
INSERT INTO upms.tb_sys_file (
file_id, media_type, object_key, file_name, mime_type, file_size, file_hash, duration_ms,
uploaded_by, adcode, tenant_id, tenant_path, created_at
) VALUES (
:fileId, :mediaType, :objectKey, :fileName, :mimeType, :fileSize, :fileHash, :durationMs,
:uploadedBy, :adcode, :tenantId, :tenantPath, CURRENT_TIMESTAMP
)
""";
MapSqlParameterSource params = new MapSqlParameterSource()
.addValue("fileId", fileId)
.addValue("mediaType", mediaType)
.addValue("objectKey", request.objectKey())
.addValue("fileName", request.fileName())
.addValue("mimeType", request.mimeType())
.addValue("fileSize", request.fileSize())
.addValue("fileHash", request.fileHash())
.addValue("durationMs", request.durationMs())
.addValue("uploadedBy", context.userId())
.addValue("adcode", context.adcode())
.addValue("tenantId", context.tenantId())
.addValue("tenantPath", context.tenantPath());
jdbcTemplate.update(sql, params);
return fileById(authorizationHeader, fileId);
}
@Override
public FileMetadataDto fileById(String authorizationHeader, String fileId) {
AuthContext context = requireAuth(authorizationHeader);
String sql = """
SELECT file_id, media_type, object_key, file_name, mime_type, file_size, file_hash, duration_ms,
uploaded_by, tenant_id, tenant_path, created_at
FROM upms.tb_sys_file
WHERE file_id = :fileId
AND tenant_id = :tenantId
LIMIT 1
""";
return jdbcTemplate.query(sql, Map.of("fileId", fileId, "tenantId", context.tenantId()), FILE_ROW_MAPPER)
.stream()
.findFirst()
.orElseThrow(() -> new BizException(404, "文件不存在"));
}
@Override
public List<InboxMessageDto> inboxMessages(String authorizationHeader) {
AuthContext context = requireAuth(authorizationHeader);
String sql = """
SELECT m.message_id, m.message_type, m.biz_type, m.title, m.content, m.web_jump_url,
r.read_status, r.read_at, m.send_at
FROM upms.tb_sys_message m
JOIN upms.tb_sys_message_recipient r ON r.message_id = m.message_id
WHERE r.recipient_user_id = :userId
AND r.tenant_id = :tenantId
AND m.message_status = 'ACTIVE'
ORDER BY m.send_at DESC
""";
return jdbcTemplate.query(
sql,
Map.of("userId", context.userId(), "tenantId", context.tenantId()),
(rs, rowNum) -> new InboxMessageDto(
rs.getString("message_id"),
rs.getString("message_type"),
rs.getString("biz_type"),
rs.getString("title"),
rs.getString("content"),
rs.getString("web_jump_url"),
rs.getString("read_status"),
toInstant(rs.getTimestamp("read_at")),
toInstant(rs.getTimestamp("send_at"))
));
}
@Override
public MessageReadResultDto readMessage(String authorizationHeader, String messageId) {
AuthContext context = requireAuth(authorizationHeader);
String readSource = "MINI".equalsIgnoreCase(context.clientType()) ? "MINI_PROGRAM" : "WEB";
String updateSql = """
UPDATE upms.tb_sys_message_recipient
SET read_status = 'READ',
read_at = COALESCE(read_at, CURRENT_TIMESTAMP),
read_source = COALESCE(read_source, :readSource),
updated_at = CURRENT_TIMESTAMP
WHERE message_id = :messageId
AND recipient_user_id = :userId
AND tenant_id = :tenantId
""";
int updated = jdbcTemplate.update(
updateSql,
new MapSqlParameterSource()
.addValue("messageId", messageId)
.addValue("userId", context.userId())
.addValue("tenantId", context.tenantId())
.addValue("readSource", readSource));
if (updated == 0) {
throw new BizException(404, "消息不存在");
}
String querySql = """
SELECT message_id, read_status, read_at
FROM upms.tb_sys_message_recipient
WHERE message_id = :messageId
AND recipient_user_id = :userId
AND tenant_id = :tenantId
LIMIT 1
""";
return jdbcTemplate.query(
querySql,
Map.of("messageId", messageId, "userId", context.userId(), "tenantId", context.tenantId()),
(rs, rowNum) -> new MessageReadResultDto(
rs.getString("message_id"),
rs.getString("read_status"),
toInstant(rs.getTimestamp("read_at"))
))
.stream()
.findFirst()
.orElseThrow(() -> new BizException(404, "消息不存在"));
}
private AuthContext requireAuth(String authorizationHeader) {
var context = RequestUserContextHolder.get();
if (context != null && StringUtils.hasText(context.userId()) && StringUtils.hasText(context.tenantId())) {
return new AuthContext(
context.userId(),
context.username(),
context.displayName(),
context.adcode(),
context.tenantId(),
context.tenantPath(),
context.deptId(),
context.deptPath(),
context.roleCodes(),
context.clientType()
);
}
if (StringUtils.hasText(authorizationHeader) && authorizationHeader.startsWith("Bearer ")) {
JwtUserPrincipal principal = jwtTokenProvider.parse(authorizationHeader.substring("Bearer ".length()));
return new AuthContext(
principal.userId(),
principal.username(),
principal.displayName(),
principal.adcode(),
principal.tenantId(),
principal.tenantPath(),
principal.deptId(),
principal.deptPath(),
principal.roleCodes(),
principal.clientType()
);
}
throw new BizException(401, "未登录或登录已失效");
}
private Optional<UserRow> findUserRow(String userId, String tenantId) {
String sql = """
SELECT user_id, username, display_name, adcode, tenant_id, tenant_path, dept_id, dept_path
FROM upms.tb_sys_user
WHERE user_id = :userId
AND tenant_id = :tenantId
LIMIT 1
""";
return jdbcTemplate.query(sql, Map.of("userId", userId, "tenantId", tenantId), USER_ROW_MAPPER).stream().findFirst();
}
private List<String> findRoleCodes(String userId) {
String sql = """
SELECT r.role_code
FROM upms.tb_sys_user_role ur
JOIN upms.tb_sys_role r ON r.role_id = ur.role_id
WHERE ur.user_id = :userId
ORDER BY r.role_code
""";
return jdbcTemplate.queryForList(sql, Map.of("userId", userId), String.class);
}
private List<String> findPermissionCodes(String userId, String tenantId) {
String sql = """
SELECT DISTINCT m.permission_code
FROM upms.tb_sys_menu m
JOIN upms.tb_sys_role_menu rm ON rm.route_id = m.route_id
JOIN upms.tb_sys_user_role ur ON ur.role_id = rm.role_id
WHERE ur.user_id = :userId
AND m.tenant_id = :tenantId
AND m.permission_code IS NOT NULL
AND m.permission_code <> ''
""";
return jdbcTemplate.queryForList(sql, Map.of("userId", userId, "tenantId", tenantId), String.class)
.stream()
.flatMap(codes -> Arrays.stream(codes.split(",")))
.map(String::trim)
.filter(StringUtils::hasText)
.distinct()
.sorted()
.toList();
}
private void ensureClassInTenant(String classId, String tenantId) {
String sql = """
SELECT COUNT(1)
FROM upms.tb_school_class
WHERE class_id = :classId
AND tenant_id = :tenantId
""";
Integer count = jdbcTemplate.queryForObject(sql, Map.of("classId", classId, "tenantId", tenantId), Integer.class);
if (count == null || count == 0) {
throw new BizException(404, "班级不存在");
}
}
private String normalizeMediaType(String mediaType) {
if (!StringUtils.hasText(mediaType)) {
return "OTHER";
}
String normalized = mediaType.trim().toUpperCase();
return switch (normalized) {
case "IMAGE", "AUDIO", "VIDEO", "DOCUMENT", "OTHER" -> normalized;
default -> throw new BizException(400, "mediaType 非法");
};
}
private List<RouteNodeDto> buildRouteTree(List<RouteRow> rows) {
if (rows.isEmpty()) {
return List.of();
}
Map<String, RouteRow> rowMap = new LinkedHashMap<>();
Map<String, List<String>> childrenMap = new LinkedHashMap<>();
for (RouteRow row : rows) {
rowMap.put(row.routeId(), row);
if (StringUtils.hasText(row.parentRouteId())) {
childrenMap.computeIfAbsent(row.parentRouteId(), key -> new ArrayList<>()).add(row.routeId());
}
}
List<String> rootIds = rows.stream()
.filter(row -> !StringUtils.hasText(row.parentRouteId()) || !rowMap.containsKey(row.parentRouteId()))
.map(RouteRow::routeId)
.toList();
return rootIds.stream()
.map(routeId -> buildRouteNode(routeId, rowMap, childrenMap))
.toList();
}
private RouteNodeDto buildRouteNode(
String routeId,
Map<String, RouteRow> rowMap,
Map<String, List<String>> childrenMap) {
RouteRow row = rowMap.get(routeId);
List<RouteNodeDto> children = childrenMap.getOrDefault(routeId, List.of()).stream()
.map(childId -> buildRouteNode(childId, rowMap, childrenMap))
.toList();
LayoutType layoutType;
try {
layoutType = LayoutType.valueOf(row.layoutType());
} catch (Exception exception) {
layoutType = LayoutType.SIDEBAR;
}
List<String> permissionCodes = splitPermissionCodes(row.permissionCode());
return new RouteNodeDto(
row.routeId(),
row.routePath(),
row.routeName(),
row.componentKey(),
layoutType,
new RouteMetaDto(row.title(), row.icon(), permissionCodes, row.hidden()),
children
);
}
private List<AreaNodeDto> buildAreaTree(List<AreaRow> rows) {
Map<Long, AreaRow> rowMap = rows.stream()
.collect(Collectors.toMap(AreaRow::id, row -> row, (a, b) -> a, LinkedHashMap::new));
Map<Long, List<Long>> childMap = new LinkedHashMap<>();
for (AreaRow row : rows) {
childMap.computeIfAbsent(row.pid(), key -> new ArrayList<>()).add(row.id());
}
return rows.stream()
.filter(row -> row.pid() == 0L || !rowMap.containsKey(row.pid()))
.map(row -> buildAreaNode(row.id(), rowMap, childMap))
.toList();
}
private AreaNodeDto buildAreaNode(Long id, Map<Long, AreaRow> rowMap, Map<Long, List<Long>> childMap) {
AreaRow row = rowMap.get(id);
List<AreaNodeDto> children = childMap.getOrDefault(id, List.of()).stream()
.map(childId -> buildAreaNode(childId, rowMap, childMap))
.toList();
return new AreaNodeDto(
row.areaCode(),
String.valueOf(row.pid()),
row.areaName(),
mapAreaLevel(row.areaType()),
children
);
}
private List<TenantNodeDto> buildTenantTree(List<TenantRow> rows) {
Map<String, TenantRow> rowMap = rows.stream()
.collect(Collectors.toMap(TenantRow::tenantId, row -> row, (a, b) -> a, LinkedHashMap::new));
Map<String, List<String>> childMap = new LinkedHashMap<>();
for (TenantRow row : rows) {
if (StringUtils.hasText(row.parentTenantId())) {
childMap.computeIfAbsent(row.parentTenantId(), key -> new ArrayList<>()).add(row.tenantId());
}
}
return rows.stream()
.filter(row -> !StringUtils.hasText(row.parentTenantId()) || !rowMap.containsKey(row.parentTenantId()))
.sorted(Comparator.comparing(TenantRow::tenantPath))
.map(row -> buildTenantNode(row.tenantId(), rowMap, childMap))
.toList();
}
private TenantNodeDto buildTenantNode(
String tenantId,
Map<String, TenantRow> rowMap,
Map<String, List<String>> childMap) {
TenantRow row = rowMap.get(tenantId);
List<TenantNodeDto> children = childMap.getOrDefault(tenantId, List.of()).stream()
.map(childId -> buildTenantNode(childId, rowMap, childMap))
.toList();
return new TenantNodeDto(
row.tenantId(),
row.parentTenantId(),
row.tenantName(),
row.tenantType(),
row.adcode(),
row.tenantPath(),
children
);
}
private List<DeptNodeDto> buildDeptTree(List<DeptRow> rows) {
Map<String, DeptRow> rowMap = rows.stream()
.collect(Collectors.toMap(DeptRow::deptId, row -> row, (a, b) -> a, LinkedHashMap::new));
Map<String, List<String>> childMap = new LinkedHashMap<>();
for (DeptRow row : rows) {
if (StringUtils.hasText(row.parentDeptId())) {
childMap.computeIfAbsent(row.parentDeptId(), key -> new ArrayList<>()).add(row.deptId());
}
}
return rows.stream()
.filter(row -> !StringUtils.hasText(row.parentDeptId()) || !rowMap.containsKey(row.parentDeptId()))
.sorted(Comparator.comparing(DeptRow::deptPath))
.map(row -> buildDeptNode(row.deptId(), rowMap, childMap))
.toList();
}
private DeptNodeDto buildDeptNode(
String deptId,
Map<String, DeptRow> rowMap,
Map<String, List<String>> childMap) {
DeptRow row = rowMap.get(deptId);
List<DeptNodeDto> children = childMap.getOrDefault(deptId, List.of()).stream()
.map(childId -> buildDeptNode(childId, rowMap, childMap))
.toList();
return new DeptNodeDto(
row.deptId(),
row.parentDeptId(),
row.deptName(),
row.deptType(),
row.tenantId(),
row.adcode(),
row.tenantPath(),
row.deptPath(),
children
);
}
private List<String> splitPermissionCodes(String permissionCode) {
if (!StringUtils.hasText(permissionCode)) {
return List.of();
}
return Arrays.stream(permissionCode.split(","))
.map(String::trim)
.filter(StringUtils::hasText)
.toList();
}
private String mapAreaLevel(String areaType) {
if ("0".equals(areaType)) {
return "COUNTRY";
}
if ("1".equals(areaType)) {
return "PROVINCE";
}
if ("2".equals(areaType)) {
return "CITY";
}
if ("3".equals(areaType)) {
return "DISTRICT";
}
return "UNKNOWN";
}
private Instant toInstant(Timestamp timestamp) {
return timestamp == null ? null : timestamp.toInstant();
}
private static final RowMapper<UserRow> USER_ROW_MAPPER = (rs, rowNum) -> new UserRow(
rs.getString("user_id"),
rs.getString("username"),
rs.getString("display_name"),
rs.getString("adcode"),
rs.getString("tenant_id"),
rs.getString("tenant_path"),
rs.getString("dept_id"),
rs.getString("dept_path")
);
private static final RowMapper<FileMetadataDto> FILE_ROW_MAPPER = (rs, rowNum) -> new FileMetadataDto(
rs.getString("file_id"),
rs.getString("media_type"),
rs.getString("object_key"),
rs.getString("file_name"),
rs.getString("mime_type"),
rs.getObject("file_size", Long.class),
rs.getString("file_hash"),
rs.getObject("duration_ms", Integer.class),
rs.getString("uploaded_by"),
rs.getString("tenant_id"),
rs.getString("tenant_path"),
rs.getTimestamp("created_at") == null ? null : rs.getTimestamp("created_at").toInstant()
);
private record AuthContext(
String userId,
String username,
String displayName,
String adcode,
String tenantId,
String tenantPath,
String deptId,
String deptPath,
List<String> roleCodes,
String clientType
) {
}
private record RouteRow(
String routeId,
String parentRouteId,
String routePath,
String routeName,
String componentKey,
String layoutType,
String title,
String icon,
String permissionCode,
boolean hidden
) {
}
private record AreaRow(
Long id,
Long pid,
String areaCode,
String areaName,
String areaType
) {
}
private record TenantRow(
String tenantId,
String parentTenantId,
String tenantName,
String tenantType,
String adcode,
String tenantPath
) {
}
private record DeptRow(
String deptId,
String parentDeptId,
String deptName,
String deptType,
String tenantId,
String adcode,
String tenantPath,
String deptPath
) {
}
private record UserRow(
String userId,
String username,
String displayName,
String adcode,
String tenantId,
String tenantPath,
String deptId,
String deptPath
) {
}
}

View File

@@ -4,11 +4,10 @@ server:
spring:
application:
name: k12study-upms
autoconfigure:
exclude:
- org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
- org.mybatis.spring.boot.autoconfigure.MybatisAutoConfiguration
- com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration
datasource:
url: jdbc:postgresql://${K12STUDY_DB_HOST:localhost}:${K12STUDY_DB_PORT:5432}/${K12STUDY_DB_NAME:k12study}
username: ${K12STUDY_DB_USER:k12study}
password: ${K12STUDY_DB_PASSWORD:k12study}
management:
endpoints: