feat: 搭建后端多租户名片服务
- 初始化 Spring Boot 多模块工程与通用基础能力 - 增加租户、组织、用户、名片、文件、统计等业务模块 - 补充数据库迁移脚本与基础测试
This commit is contained in:
50
backend/easycard-common/pom.xml
Normal file
50
backend/easycard-common/pom.xml
Normal file
@@ -0,0 +1,50 @@
|
||||
<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.easycard</groupId>
|
||||
<artifactId>easycard-parent</artifactId>
|
||||
<version>0.1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>easycard-common</artifactId>
|
||||
<name>easycard-common</name>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.security</groupId>
|
||||
<artifactId>spring-security-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-autoconfigure</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-api</artifactId>
|
||||
<version>0.12.6</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-impl</artifactId>
|
||||
<version>0.12.6</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-jackson</artifactId>
|
||||
<version>0.12.6</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.easycard.common.api;
|
||||
|
||||
public record ApiResponse<T>(String code, String message, T data) {
|
||||
|
||||
public static <T> ApiResponse<T> success(T data) {
|
||||
return new ApiResponse<>("0", "success", data);
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> success(String message, T data) {
|
||||
return new ApiResponse<>("0", message, data);
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> fail(String code, String message) {
|
||||
return new ApiResponse<>(code, message, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.easycard.common.auth;
|
||||
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
@Component
|
||||
public class JwtTokenService {
|
||||
|
||||
private final String jwtSecret;
|
||||
private SecretKey secretKey;
|
||||
|
||||
public JwtTokenService(@Value("${easycard.security.jwt-secret}") String jwtSecret) {
|
||||
this.jwtSecret = jwtSecret;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
String normalized = jwtSecret;
|
||||
if (jwtSecret.length() < 32) {
|
||||
normalized = String.format("%-32s", jwtSecret).replace(' ', '0');
|
||||
}
|
||||
byte[] secretBytes = normalized.getBytes(StandardCharsets.UTF_8);
|
||||
this.secretKey = Keys.hmacShaKeyFor(secretBytes);
|
||||
}
|
||||
|
||||
public String generateToken(LoginUser loginUser, Duration ttl) {
|
||||
Instant now = Instant.now();
|
||||
return Jwts.builder()
|
||||
.subject(String.valueOf(loginUser.userId()))
|
||||
.issuedAt(Date.from(now))
|
||||
.expiration(Date.from(now.plus(ttl)))
|
||||
.claims(Map.of(
|
||||
"tenantId", loginUser.tenantId(),
|
||||
"username", loginUser.username(),
|
||||
"realName", loginUser.realName(),
|
||||
"userType", loginUser.userType(),
|
||||
"roles", loginUser.roleCodes()
|
||||
))
|
||||
.signWith(secretKey)
|
||||
.compact();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public Optional<LoginUser> parseToken(String token) {
|
||||
if (!StringUtils.hasText(token)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
Claims claims = Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload();
|
||||
Object rolesObject = claims.get("roles");
|
||||
List<String> roles = rolesObject instanceof List<?> list
|
||||
? list.stream().map(String::valueOf).toList()
|
||||
: List.of();
|
||||
return Optional.of(new LoginUser(
|
||||
Long.valueOf(claims.getSubject()),
|
||||
Long.valueOf(String.valueOf(claims.get("tenantId"))),
|
||||
String.valueOf(claims.get("username")),
|
||||
String.valueOf(claims.get("realName")),
|
||||
String.valueOf(claims.get("userType")),
|
||||
roles
|
||||
));
|
||||
} catch (Exception exception) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.easycard.common.auth;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
|
||||
public record LoginUser(
|
||||
Long userId,
|
||||
Long tenantId,
|
||||
String username,
|
||||
String realName,
|
||||
String userType,
|
||||
List<String> roleCodes
|
||||
) implements Serializable {
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.easycard.common.auth;
|
||||
|
||||
import com.easycard.common.exception.BusinessException;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
|
||||
public final class SecurityUtils {
|
||||
|
||||
private SecurityUtils() {
|
||||
}
|
||||
|
||||
public static LoginUser getRequiredLoginUser() {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (authentication == null || !(authentication.getPrincipal() instanceof LoginUser loginUser)) {
|
||||
throw new BusinessException("UNAUTHORIZED", "登录状态已失效");
|
||||
}
|
||||
return loginUser;
|
||||
}
|
||||
|
||||
public static boolean hasRole(String roleCode) {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
return authentication != null
|
||||
&& authentication.getAuthorities().stream().anyMatch(item -> roleCode.equals(item.getAuthority()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.easycard.common.config;
|
||||
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.util.TimeZone;
|
||||
|
||||
@Configuration
|
||||
public class JacksonConfig {
|
||||
|
||||
@Bean
|
||||
public Jackson2ObjectMapperBuilderCustomizer jackson2ObjectMapperBuilderCustomizer() {
|
||||
return builder -> builder
|
||||
.timeZone(TimeZone.getTimeZone("Asia/Shanghai"))
|
||||
.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.easycard.common.exception;
|
||||
|
||||
public class BusinessException extends RuntimeException {
|
||||
|
||||
private final String code;
|
||||
|
||||
public BusinessException(String code, String message) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public String getCode() {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.easycard.common.storage;
|
||||
|
||||
public final class StorageUrlUtils {
|
||||
|
||||
private StorageUrlUtils() {
|
||||
}
|
||||
|
||||
public static String buildPublicUrl(String publicEndpoint, String bucketName, String objectKey, String fallbackUrl) {
|
||||
if (isBlank(bucketName) || isBlank(objectKey)) {
|
||||
return fallbackUrl == null ? "" : fallbackUrl;
|
||||
}
|
||||
String endpoint = trimTrailingSlash(publicEndpoint);
|
||||
if (endpoint.isEmpty()) {
|
||||
return fallbackUrl == null ? "" : fallbackUrl;
|
||||
}
|
||||
return endpoint + "/" + trimSlashes(bucketName) + "/" + trimLeadingSlash(objectKey);
|
||||
}
|
||||
|
||||
private static boolean isBlank(String value) {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
|
||||
private static String trimTrailingSlash(String value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
int end = value.length();
|
||||
while (end > 0 && value.charAt(end - 1) == '/') {
|
||||
end--;
|
||||
}
|
||||
return value.substring(0, end);
|
||||
}
|
||||
|
||||
private static String trimLeadingSlash(String value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
int start = 0;
|
||||
while (start < value.length() && value.charAt(start) == '/') {
|
||||
start++;
|
||||
}
|
||||
return value.substring(start);
|
||||
}
|
||||
|
||||
private static String trimSlashes(String value) {
|
||||
return trimTrailingSlash(trimLeadingSlash(value));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.easycard.common.tenant;
|
||||
|
||||
public record TenantContext(Long tenantId, String tenantCode, String miniappAppId) {
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.easycard.common.tenant;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public final class TenantContextHolder {
|
||||
|
||||
private static final ThreadLocal<TenantContext> CONTEXT = new ThreadLocal<>();
|
||||
|
||||
private TenantContextHolder() {
|
||||
}
|
||||
|
||||
public static void set(TenantContext tenantContext) {
|
||||
CONTEXT.set(tenantContext);
|
||||
}
|
||||
|
||||
public static Optional<TenantContext> getOptional() {
|
||||
return Optional.ofNullable(CONTEXT.get());
|
||||
}
|
||||
|
||||
public static TenantContext getRequired() {
|
||||
TenantContext tenantContext = CONTEXT.get();
|
||||
if (tenantContext == null) {
|
||||
throw new IllegalStateException("租户上下文不存在");
|
||||
}
|
||||
return tenantContext;
|
||||
}
|
||||
|
||||
public static void clear() {
|
||||
CONTEXT.remove();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.easycard.common.web;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
public final class ClientRequestUtils {
|
||||
|
||||
private ClientRequestUtils() {
|
||||
}
|
||||
|
||||
public static String getClientIp(HttpServletRequest request) {
|
||||
String forwardedFor = request.getHeader("X-Forwarded-For");
|
||||
if (StringUtils.hasText(forwardedFor)) {
|
||||
return forwardedFor.split(",")[0].trim();
|
||||
}
|
||||
String realIp = request.getHeader("X-Real-IP");
|
||||
if (StringUtils.hasText(realIp)) {
|
||||
return realIp.trim();
|
||||
}
|
||||
return request.getRemoteAddr();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.easycard.common.web;
|
||||
|
||||
import com.easycard.common.api.ApiResponse;
|
||||
import com.easycard.common.exception.BusinessException;
|
||||
import jakarta.validation.ConstraintViolationException;
|
||||
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||
import org.springframework.validation.BindException;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.multipart.MaxUploadSizeExceededException;
|
||||
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(BusinessException.class)
|
||||
public ApiResponse<Void> handleBusinessException(BusinessException exception) {
|
||||
return ApiResponse.fail(exception.getCode(), exception.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler({
|
||||
MethodArgumentNotValidException.class,
|
||||
BindException.class,
|
||||
ConstraintViolationException.class,
|
||||
HttpMessageNotReadableException.class
|
||||
})
|
||||
public ApiResponse<Void> handleValidationException(Exception exception) {
|
||||
return ApiResponse.fail("VALIDATION_ERROR", exception.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(MaxUploadSizeExceededException.class)
|
||||
public ApiResponse<Void> handleMaxUploadSizeExceededException(MaxUploadSizeExceededException exception) {
|
||||
return ApiResponse.fail("FILE_TOO_LARGE", "上传图片不能超过 5MB");
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ApiResponse<Void> handleException(Exception exception) {
|
||||
return ApiResponse.fail("INTERNAL_SERVER_ERROR", exception.getMessage());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user