feat: 搭建后端多租户名片服务

- 初始化 Spring Boot 多模块工程与通用基础能力

- 增加租户、组织、用户、名片、文件、统计等业务模块

- 补充数据库迁移脚本与基础测试
This commit is contained in:
2026-03-20 12:43:21 +08:00
parent 1a2a078c0f
commit 9ef50288e9
95 changed files with 6722 additions and 0 deletions

View 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>

View File

@@ -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);
}
}

View File

@@ -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();
}
}
}

View File

@@ -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 {
}

View File

@@ -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()));
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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));
}
}

View File

@@ -0,0 +1,4 @@
package com.easycard.common.tenant;
public record TenantContext(Long tenantId, String tenantCode, String miniappAppId) {
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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());
}
}