增加品牌方案管理

This commit is contained in:
2026-07-23 18:56:35 +08:00
parent 4c07ec5455
commit b689656faf
67 changed files with 5572 additions and 231 deletions
+113
View File
@@ -0,0 +1,113 @@
<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>gym-manage-api</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>gym-brand</artifactId>
<packaging>jar</packaging>
<name>Gym Brand</name>
<description>Brand Customization Module - Logo Upload, Color Settings, Real-time Preview</description>
<dependencies>
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>manage-common</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>manage-sys</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webflux-ui</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Aliyun OSS SDK -->
<dependency>
<groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.4.2</version>
<executions>
<execution>
<id>default-jar</id>
<phase>package</phase>
<goals>
<goal>jar</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<source>21</source>
<target>21</target>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.12</version>
<executions>
<execution>
<id>prepare-agent</id>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>verify</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,39 @@
package cn.novalon.gym.manage.brand.config;
import cn.novalon.gym.manage.brand.websocket.BrandWebSocketHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.web.reactive.HandlerMapping;
import org.springframework.web.reactive.handler.SimpleUrlHandlerMapping;
import org.springframework.web.reactive.socket.WebSocketHandler;
import org.springframework.web.reactive.socket.server.support.WebSocketHandlerAdapter;
import java.util.HashMap;
import java.util.Map;
/**
* 品牌预览 WebSocket 配置
*
* @author 张翔
* @date 2026-07-23
*/
@Configuration
public class BrandWebSocketConfig {
@Bean
public HandlerMapping brandWebSocketHandlerMapping(BrandWebSocketHandler brandWebSocketHandler) {
Map<String, WebSocketHandler> map = new HashMap<>();
map.put("/ws/brand", brandWebSocketHandler);
SimpleUrlHandlerMapping handlerMapping = new SimpleUrlHandlerMapping();
handlerMapping.setOrder(Ordered.HIGHEST_PRECEDENCE + 1);
handlerMapping.setUrlMap(map);
return handlerMapping;
}
@Bean
public WebSocketHandlerAdapter brandWebSocketHandlerAdapter() {
return new WebSocketHandlerAdapter();
}
}
@@ -0,0 +1,105 @@
package cn.novalon.gym.manage.brand.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 阿里云 OSS 配置属性
* <p>
* 配置前缀: brand.oss
*
* @author 张翔
* @date 2026-07-23
*/
@Component
@ConfigurationProperties(prefix = "brand.oss")
public class OssProperties {
/** 是否启用 OSS(默认关闭,仅使用本地存储) */
private boolean enabled = false;
/** OSS Endpoint(如 oss-cn-hangzhou.aliyuncs.com */
private String endpoint;
/** AccessKey ID */
private String accessKeyId;
/** AccessKey Secret */
private String accessKeySecret;
/** Bucket 名称 */
private String bucketName;
/** 自定义域名/CDN域名(可选,用于生成访问URL) */
private String customDomain;
/** 文件存储基础路径(默认 brand) */
private String basePath = "brand";
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public String getEndpoint() {
return endpoint;
}
public void setEndpoint(String endpoint) {
this.endpoint = endpoint;
}
public String getAccessKeyId() {
return accessKeyId;
}
public void setAccessKeyId(String accessKeyId) {
this.accessKeyId = accessKeyId;
}
public String getAccessKeySecret() {
return accessKeySecret;
}
public void setAccessKeySecret(String accessKeySecret) {
this.accessKeySecret = accessKeySecret;
}
public String getBucketName() {
return bucketName;
}
public void setBucketName(String bucketName) {
this.bucketName = bucketName;
}
public String getCustomDomain() {
return customDomain;
}
public void setCustomDomain(String customDomain) {
this.customDomain = customDomain;
}
public String getBasePath() {
return basePath;
}
public void setBasePath(String basePath) {
this.basePath = basePath;
}
/**
* 判断 OSS 配置是否完整可用
*/
public boolean isConfigured() {
return enabled
&& endpoint != null && !endpoint.isBlank()
&& accessKeyId != null && !accessKeyId.isBlank()
&& accessKeySecret != null && !accessKeySecret.isBlank()
&& bucketName != null && !bucketName.isBlank();
}
}
@@ -0,0 +1,56 @@
package cn.novalon.gym.manage.brand.core.domain;
import java.time.LocalDateTime;
/**
* 品牌配置领域对象
*
* @author 张翔
* @date 2026-07-23
*/
public class BrandConfig {
private Long id;
private String tenantId;
private String logoUrl;
private String backgroundImageUrl;
private String primaryColor;
private String primaryColorRgb;
private String secondaryColor;
private String secondaryColorRgb;
private String fontFamily;
private String brandName;
private String slogan;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private LocalDateTime deletedAt;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getTenantId() { return tenantId; }
public void setTenantId(String tenantId) { this.tenantId = tenantId; }
public String getLogoUrl() { return logoUrl; }
public void setLogoUrl(String logoUrl) { this.logoUrl = logoUrl; }
public String getBackgroundImageUrl() { return backgroundImageUrl; }
public void setBackgroundImageUrl(String backgroundImageUrl) { this.backgroundImageUrl = backgroundImageUrl; }
public String getPrimaryColor() { return primaryColor; }
public void setPrimaryColor(String primaryColor) { this.primaryColor = primaryColor; }
public String getPrimaryColorRgb() { return primaryColorRgb; }
public void setPrimaryColorRgb(String primaryColorRgb) { this.primaryColorRgb = primaryColorRgb; }
public String getSecondaryColor() { return secondaryColor; }
public void setSecondaryColor(String secondaryColor) { this.secondaryColor = secondaryColor; }
public String getSecondaryColorRgb() { return secondaryColorRgb; }
public void setSecondaryColorRgb(String secondaryColorRgb) { this.secondaryColorRgb = secondaryColorRgb; }
public String getFontFamily() { return fontFamily; }
public void setFontFamily(String fontFamily) { this.fontFamily = fontFamily; }
public String getBrandName() { return brandName; }
public void setBrandName(String brandName) { this.brandName = brandName; }
public String getSlogan() { return slogan; }
public void setSlogan(String slogan) { this.slogan = slogan; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
public LocalDateTime getDeletedAt() { return deletedAt; }
public void setDeletedAt(LocalDateTime deletedAt) { this.deletedAt = deletedAt; }
}
@@ -0,0 +1,17 @@
package cn.novalon.gym.manage.brand.core.repository;
import cn.novalon.gym.manage.brand.core.domain.BrandConfig;
import reactor.core.publisher.Mono;
/**
* 品牌配置仓储接口
*
* @author 张翔
* @date 2026-07-23
*/
public interface IBrandConfigRepository {
Mono<BrandConfig> findByTenantId(String tenantId);
Mono<BrandConfig> save(BrandConfig brandConfig);
}
@@ -0,0 +1,27 @@
package cn.novalon.gym.manage.brand.core.service;
import org.springframework.http.codec.multipart.FilePart;
import reactor.core.publisher.Mono;
/**
* 文件存储服务接口(OSS + 本地兜底)
*
* @author 张翔
* @date 2026-07-23
*/
public interface FileStorageService {
/**
* 上传图片文件,返回访问URL
*
* @param filePart 文件数据
* @param directory 存储目录(如 "logo", "background"
* @return 文件访问URL
*/
Mono<String> uploadImage(FilePart filePart, String directory);
/**
* 根据URL删除文件
*/
Mono<Void> deleteFile(String fileUrl);
}
@@ -0,0 +1,49 @@
package cn.novalon.gym.manage.brand.core.service;
import cn.novalon.gym.manage.brand.core.domain.BrandConfig;
import org.springframework.http.codec.multipart.FilePart;
import reactor.core.publisher.Mono;
/**
* 品牌配置服务接口
*
* @author 张翔
* @date 2026-07-23
*/
public interface IBrandConfigService {
/**
* 根据租户ID获取品牌配置
*/
Mono<BrandConfig> getBrandConfig(String tenantId);
/**
* 上传Logo
*/
Mono<BrandConfig> uploadLogo(String tenantId, FilePart filePart);
/**
* 上传背景图
*/
Mono<BrandConfig> uploadBackgroundImage(String tenantId, FilePart filePart);
/**
* 更新品牌配色
*/
Mono<BrandConfig> updateColorConfig(String tenantId, BrandConfig config);
/**
* 删除Logo(恢复默认)
*/
Mono<BrandConfig> removeLogo(String tenantId);
/**
* 删除背景图(恢复默认)
*/
Mono<BrandConfig> removeBackgroundImage(String tenantId);
/**
* 更新品牌信息(名称、口号)
*/
Mono<BrandConfig> updateBrandInfo(String tenantId, BrandConfig config);
}
@@ -0,0 +1,172 @@
package cn.novalon.gym.manage.brand.core.service.impl;
import cn.novalon.gym.manage.brand.core.domain.BrandConfig;
import cn.novalon.gym.manage.brand.core.repository.IBrandConfigRepository;
import cn.novalon.gym.manage.brand.core.service.FileStorageService;
import cn.novalon.gym.manage.brand.core.service.IBrandConfigService;
import cn.novalon.gym.manage.brand.websocket.BrandWebSocketHandler;
import org.springframework.http.codec.multipart.FilePart;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
/**
* 品牌配置服务实现
*
* @author 张翔
* @date 2026-07-23
*/
@Service
public class BrandConfigServiceImpl implements IBrandConfigService {
private final IBrandConfigRepository brandConfigRepository;
private final FileStorageService fileStorageService;
private final BrandWebSocketHandler brandWebSocketHandler;
public BrandConfigServiceImpl(
IBrandConfigRepository brandConfigRepository,
FileStorageService fileStorageService,
BrandWebSocketHandler brandWebSocketHandler) {
this.brandConfigRepository = brandConfigRepository;
this.fileStorageService = fileStorageService;
this.brandWebSocketHandler = brandWebSocketHandler;
}
@Override
public Mono<BrandConfig> getBrandConfig(String tenantId) {
return brandConfigRepository.findByTenantId(tenantId)
.switchIfEmpty(Mono.defer(() -> createDefaultConfig(tenantId)));
}
@Override
public Mono<BrandConfig> uploadLogo(String tenantId, FilePart filePart) {
return fileStorageService.uploadImage(filePart, "logo")
.flatMap(logoUrl -> getOrCreateConfig(tenantId)
.flatMap(config -> {
// 删除旧Logo
return fileStorageService.deleteFile(config.getLogoUrl())
.then(Mono.defer(() -> {
config.setLogoUrl(logoUrl);
config.setUpdatedAt(LocalDateTime.now());
return brandConfigRepository.save(config);
}));
}))
.doOnSuccess(config -> notifyPreviewUpdate(config));
}
@Override
public Mono<BrandConfig> uploadBackgroundImage(String tenantId, FilePart filePart) {
return fileStorageService.uploadImage(filePart, "background")
.flatMap(bgUrl -> getOrCreateConfig(tenantId)
.flatMap(config -> {
return fileStorageService.deleteFile(config.getBackgroundImageUrl())
.then(Mono.defer(() -> {
config.setBackgroundImageUrl(bgUrl);
config.setUpdatedAt(LocalDateTime.now());
return brandConfigRepository.save(config);
}));
}))
.doOnSuccess(config -> notifyPreviewUpdate(config));
}
@Override
public Mono<BrandConfig> updateColorConfig(String tenantId, BrandConfig updatedConfig) {
return getOrCreateConfig(tenantId)
.flatMap(config -> {
if (updatedConfig.getPrimaryColor() != null) {
config.setPrimaryColor(updatedConfig.getPrimaryColor());
}
if (updatedConfig.getPrimaryColorRgb() != null) {
config.setPrimaryColorRgb(updatedConfig.getPrimaryColorRgb());
}
if (updatedConfig.getSecondaryColor() != null) {
config.setSecondaryColor(updatedConfig.getSecondaryColor());
}
if (updatedConfig.getSecondaryColorRgb() != null) {
config.setSecondaryColorRgb(updatedConfig.getSecondaryColorRgb());
}
if (updatedConfig.getFontFamily() != null) {
config.setFontFamily(updatedConfig.getFontFamily());
}
config.setUpdatedAt(LocalDateTime.now());
return brandConfigRepository.save(config);
})
.doOnSuccess(config -> notifyPreviewUpdate(config));
}
@Override
public Mono<BrandConfig> removeLogo(String tenantId) {
return getOrCreateConfig(tenantId)
.flatMap(config -> fileStorageService.deleteFile(config.getLogoUrl())
.then(Mono.defer(() -> {
config.setLogoUrl(null);
config.setUpdatedAt(LocalDateTime.now());
return brandConfigRepository.save(config);
})))
.doOnSuccess(config -> notifyPreviewUpdate(config));
}
@Override
public Mono<BrandConfig> removeBackgroundImage(String tenantId) {
return getOrCreateConfig(tenantId)
.flatMap(config -> fileStorageService.deleteFile(config.getBackgroundImageUrl())
.then(Mono.defer(() -> {
config.setBackgroundImageUrl(null);
config.setUpdatedAt(LocalDateTime.now());
return brandConfigRepository.save(config);
})))
.doOnSuccess(config -> notifyPreviewUpdate(config));
}
@Override
public Mono<BrandConfig> updateBrandInfo(String tenantId, BrandConfig updatedConfig) {
return getOrCreateConfig(tenantId)
.flatMap(config -> {
if (updatedConfig.getBrandName() != null) {
config.setBrandName(updatedConfig.getBrandName());
}
if (updatedConfig.getSlogan() != null) {
config.setSlogan(updatedConfig.getSlogan());
}
config.setUpdatedAt(LocalDateTime.now());
return brandConfigRepository.save(config);
})
.doOnSuccess(config -> notifyPreviewUpdate(config));
}
/**
* 获取或创建品牌配置
*/
private Mono<BrandConfig> getOrCreateConfig(String tenantId) {
return brandConfigRepository.findByTenantId(tenantId)
.switchIfEmpty(Mono.defer(() -> createDefaultConfig(tenantId)));
}
/**
* 为租户创建默认品牌配置
*/
private Mono<BrandConfig> createDefaultConfig(String tenantId) {
BrandConfig config = new BrandConfig();
config.setTenantId(tenantId);
config.setPrimaryColor("#00E676");
config.setPrimaryColorRgb("0,230,118");
config.setSecondaryColor("#1A1A1A");
config.setSecondaryColorRgb("26,26,26");
config.setFontFamily("default");
config.setCreatedAt(LocalDateTime.now());
config.setUpdatedAt(LocalDateTime.now());
return brandConfigRepository.save(config);
}
/**
* 通过WebSocket通知前端预览更新
*/
private void notifyPreviewUpdate(BrandConfig config) {
try {
brandWebSocketHandler.broadcastBrandUpdate(config);
} catch (Exception e) {
System.err.println("Failed to broadcast brand update: " + e.getMessage());
}
}
}
@@ -0,0 +1,71 @@
package cn.novalon.gym.manage.brand.core.service.impl;
import cn.novalon.gym.manage.brand.config.OssProperties;
import cn.novalon.gym.manage.brand.core.service.FileStorageService;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Primary;
import org.springframework.http.codec.multipart.FilePart;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
/**
* 双轨文件存储服务(OSS 优先 + 本地兜底)
* <p>
* 作为 FileStorageService 的 @Primary 实现,编排 OSS 和本地存储:
* <ul>
* <li>上传:优先 OSS,失败则回退到本地存储</li>
* <li>删除:同时删除 OSS 和本地副本(尽力而为)</li>
* </ul>
* 当 OSS 未启用或未配置时,直接使用本地存储。
*
* @author 张翔
* @date 2026-07-23
*/
@Service
@Primary
public class DualFileStorageService implements FileStorageService {
private final FileStorageService ossStorage;
private final FileStorageService localStorage;
private final OssProperties ossProperties;
public DualFileStorageService(
@Qualifier("ossFileStorage") FileStorageService ossStorage,
@Qualifier("localFileStorage") FileStorageService localStorage,
OssProperties ossProperties) {
this.ossStorage = ossStorage;
this.localStorage = localStorage;
this.ossProperties = ossProperties;
}
@Override
public Mono<String> uploadImage(FilePart filePart, String directory) {
if (ossProperties.isConfigured()) {
return ossStorage.uploadImage(filePart, directory)
.onErrorResume(e -> {
System.err.println("OSS upload failed, falling back to local storage: " + e.getMessage());
return localStorage.uploadImage(filePart, directory);
});
}
return localStorage.uploadImage(filePart, directory);
}
@Override
public Mono<Void> deleteFile(String fileUrl) {
if (fileUrl == null || fileUrl.isBlank()) {
return Mono.empty();
}
// 本地文件总是尝试删除
Mono<Void> localDelete = localStorage.deleteFile(fileUrl);
if (ossProperties.isConfigured()) {
// OSS 删除:忽略失败(尽力而为)
return ossStorage.deleteFile(fileUrl)
.onErrorResume(e -> Mono.empty())
.then(localDelete);
}
return localDelete;
}
}
@@ -0,0 +1,111 @@
package cn.novalon.gym.manage.brand.core.service.impl;
import cn.novalon.gym.manage.brand.core.service.FileStorageService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.codec.multipart.FilePart;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Set;
import java.util.UUID;
/**
* 本地文件存储服务(兜底实现)
* <p>
* 当 OSS 不可用时,文件存储到服务器本地磁盘。
* 文件访问通过 /api/files/preview/ 路径提供。
*
* @author 张翔
* @date 2026-07-23
*/
@Service("localFileStorage")
public class LocalFileStorageService implements FileStorageService {
private static final Set<String> ALLOWED_EXTENSIONS = Set.of(".png", ".jpg", ".jpeg", ".gif", ".webp");
private static final long MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB
private final String uploadDir;
private final String baseUrl;
public LocalFileStorageService(
@Value("${file.upload.dir:/tmp/uploads}") String uploadDir,
@Value("${brand.file.base-url:http://localhost:8084/api/files}") String baseUrl) {
this.uploadDir = uploadDir;
this.baseUrl = baseUrl;
}
@Override
public Mono<String> uploadImage(FilePart filePart, String directory) {
String originalFilename = filePart.filename();
String fileExtension = getFileExtension(originalFilename);
if (!ALLOWED_EXTENSIONS.contains(fileExtension.toLowerCase())) {
return Mono.error(new IllegalArgumentException(
"不支持的文件格式,仅支持 PNG/JPG/JPEG/GIF/WEBP"));
}
String newFileName = directory + "_" + UUID.randomUUID().toString() + fileExtension;
Path targetDir = Paths.get(uploadDir, "brand", directory);
return Mono.fromCallable(() -> {
if (!Files.exists(targetDir)) {
Files.createDirectories(targetDir);
}
return targetDir;
}).flatMap(dir -> {
Path filePath = dir.resolve(newFileName);
return filePart.transferTo(filePath.toFile()).thenReturn(filePath);
}).flatMap(filePath -> {
try {
long fileSize = Files.size(filePath);
if (fileSize > MAX_FILE_SIZE) {
Files.deleteIfExists(filePath);
return Mono.error(new IllegalArgumentException("文件大小超过2MB限制"));
}
String relativePath = "brand/" + directory + "/" + newFileName;
return Mono.just(baseUrl + "/preview/" + relativePath);
} catch (IOException e) {
return Mono.error(e);
}
});
}
@Override
public Mono<Void> deleteFile(String fileUrl) {
if (fileUrl == null || fileUrl.isBlank()) {
return Mono.empty();
}
return Mono.fromRunnable(() -> {
try {
String relativePath = extractRelativePath(fileUrl);
if (relativePath != null) {
Path filePath = Paths.get(uploadDir, relativePath);
Files.deleteIfExists(filePath);
}
} catch (IOException e) {
System.err.println("Failed to delete local file: " + fileUrl + ", error: " + e.getMessage());
}
});
}
private String getFileExtension(String filename) {
if (filename == null || !filename.contains(".")) {
return ".png";
}
return filename.substring(filename.lastIndexOf("."));
}
private String extractRelativePath(String fileUrl) {
if (fileUrl.contains("/files/preview/")) {
return fileUrl.substring(fileUrl.indexOf("/files/preview/") + "/files/preview/".length());
}
if (fileUrl.contains("/files/")) {
return fileUrl.substring(fileUrl.indexOf("/files/") + "/files/".length());
}
return null;
}
}
@@ -0,0 +1,161 @@
package cn.novalon.gym.manage.brand.core.service.impl;
import cn.novalon.gym.manage.brand.config.OssProperties;
import cn.novalon.gym.manage.brand.core.service.FileStorageService;
import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSClientBuilder;
import com.aliyun.oss.model.PutObjectRequest;
import org.springframework.http.codec.multipart.FilePart;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Set;
import java.util.UUID;
/**
* 阿里云 OSS 文件存储服务
* <p>
* 当 brand.oss.enabled=true 且配置完整时启用,
* 将品牌图片上传至阿里云 OSS。
*
* @author 张翔
* @date 2026-07-23
*/
@Service("ossFileStorage")
public class OssFileStorageService implements FileStorageService {
private static final Set<String> ALLOWED_EXTENSIONS = Set.of(".png", ".jpg", ".jpeg", ".gif", ".webp");
private static final long MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB
private final OssProperties ossProperties;
private OSS ossClient;
public OssFileStorageService(OssProperties ossProperties) {
this.ossProperties = ossProperties;
}
/**
* 懒加载 OSS 客户端,避免未配置时启动失败
*/
private OSS getOssClient() {
if (ossClient == null && ossProperties.isConfigured()) {
synchronized (this) {
if (ossClient == null) {
ossClient = new OSSClientBuilder().build(
ossProperties.getEndpoint(),
ossProperties.getAccessKeyId(),
ossProperties.getAccessKeySecret());
}
}
}
return ossClient;
}
@Override
public Mono<String> uploadImage(FilePart filePart, String directory) {
if (getOssClient() == null) {
return Mono.error(new IllegalStateException("OSS 未配置或未启用"));
}
String originalFilename = filePart.filename();
String fileExtension = getFileExtension(originalFilename);
if (!ALLOWED_EXTENSIONS.contains(fileExtension.toLowerCase())) {
return Mono.error(new IllegalArgumentException(
"不支持的文件格式,仅支持 PNG/JPG/JPEG/GIF/WEBP"));
}
String objectName = ossProperties.getBasePath() + "/" + directory + "/"
+ directory + "_" + UUID.randomUUID().toString() + fileExtension;
String bucketName = ossProperties.getBucketName();
return Mono.fromCallable(() -> {
Path tempFile = Files.createTempFile("oss-upload-", fileExtension);
return tempFile;
}).flatMap(tempFile -> filePart.transferTo(tempFile.toFile()).thenReturn(tempFile))
.flatMap(tempFile -> {
try {
long fileSize = Files.size(tempFile);
if (fileSize > MAX_FILE_SIZE) {
Files.deleteIfExists(tempFile);
return Mono.error(new IllegalArgumentException("文件大小超过2MB限制"));
}
PutObjectRequest putRequest = new PutObjectRequest(bucketName, objectName, tempFile.toFile());
getOssClient().putObject(putRequest);
// 删除临时文件
Files.deleteIfExists(tempFile);
// 生成访问URL
String fileUrl = buildAccessUrl(objectName);
return Mono.just(fileUrl);
} catch (Exception e) {
try {
Files.deleteIfExists(tempFile);
} catch (Exception ignored) {
}
return Mono.error(new RuntimeException("OSS 上传失败: " + e.getMessage(), e));
}
});
}
@Override
public Mono<Void> deleteFile(String fileUrl) {
if (fileUrl == null || fileUrl.isBlank()) {
return Mono.empty();
}
if (getOssClient() == null) {
return Mono.empty();
}
return Mono.fromRunnable(() -> {
try {
String objectName = extractObjectName(fileUrl);
if (objectName != null) {
getOssClient().deleteObject(ossProperties.getBucketName(), objectName);
}
} catch (Exception e) {
System.err.println("Failed to delete OSS file: " + fileUrl + ", error: " + e.getMessage());
}
});
}
/**
* 构建文件访问 URL
* 优先使用自定义域名/CDN域名,否则使用 OSS 默认域名
*/
private String buildAccessUrl(String objectName) {
String domain;
if (ossProperties.getCustomDomain() != null && !ossProperties.getCustomDomain().isBlank()) {
domain = ossProperties.getCustomDomain();
if (domain.endsWith("/")) {
domain = domain.substring(0, domain.length() - 1);
}
} else {
domain = "https://" + ossProperties.getBucketName() + "." + ossProperties.getEndpoint();
}
return domain + "/" + objectName;
}
/**
* 从文件 URL 中提取 OSS Object Name
*/
private String extractObjectName(String fileUrl) {
String basePath = ossProperties.getBasePath();
int idx = fileUrl.indexOf(basePath);
if (idx >= 0) {
return fileUrl.substring(idx);
}
return null;
}
private String getFileExtension(String filename) {
if (filename == null || !filename.contains(".")) {
return ".png";
}
return filename.substring(filename.lastIndexOf("."));
}
}
@@ -0,0 +1,206 @@
package cn.novalon.gym.manage.brand.handler;
import cn.novalon.gym.manage.brand.core.domain.BrandConfig;
import cn.novalon.gym.manage.brand.core.service.IBrandConfigService;
import cn.novalon.gym.manage.sys.util.AuthUtil;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.codec.multipart.FilePart;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
import java.util.Map;
/**
* 品牌配置 HTTP Handler
* <p>
* tenantId 从 JWT Token 中提取,不再通过 URL 路径参数传递,
* 确保每个用户只能操作自己租户的品牌配置。
*
* @author 张翔
* @date 2026-07-23
*/
@Component
@Tag(name = "品牌定制", description = "Logo上传、背景图上传、品牌配色设置、实时预览")
public class BrandConfigHandler {
private final IBrandConfigService brandConfigService;
private final AuthUtil authUtil;
public BrandConfigHandler(IBrandConfigService brandConfigService, AuthUtil authUtil) {
this.brandConfigService = brandConfigService;
this.authUtil = authUtil;
}
@Operation(summary = "获取品牌配置", description = "根据当前租户获取品牌配置信息")
public Mono<ServerResponse> getBrandConfig(ServerRequest request) {
String tenantId = authUtil.getTenantId(request);
return brandConfigService.getBrandConfig(tenantId)
.flatMap(config -> ServerResponse.ok().bodyValue(config))
.switchIfEmpty(ServerResponse.ok().bodyValue(Map.of(
"message", "未找到品牌配置,将使用默认配置"
)));
}
@Operation(summary = "上传Logo", description = "上传品牌Logo图片,支持PNG/JPG格式,限制2MB以内")
public Mono<ServerResponse> uploadLogo(ServerRequest request) {
String tenantId = authUtil.getTenantId(request);
return request.multipartData()
.flatMap(multipartData -> {
var part = multipartData.getFirst("file");
if (part == null) {
return ServerResponse.badRequest()
.bodyValue(Map.of("code", 400, "message", "未上传文件"));
}
if (!(part instanceof FilePart filePart)) {
return ServerResponse.badRequest()
.bodyValue(Map.of("code", 400, "message", "无效的文件格式"));
}
return brandConfigService.uploadLogo(tenantId, filePart)
.flatMap(config -> ServerResponse.ok().bodyValue(config));
})
.switchIfEmpty(ServerResponse.badRequest()
.bodyValue(Map.of("code", 400, "message", "请求数据为空")))
.onErrorResume(IllegalArgumentException.class, ex ->
ServerResponse.badRequest().bodyValue(Map.of(
"code", 400,
"message", ex.getMessage()
))
);
}
@Operation(summary = "上传背景图", description = "上传品牌背景图,支持PNG/JPG格式,限制2MB以内")
public Mono<ServerResponse> uploadBackgroundImage(ServerRequest request) {
String tenantId = authUtil.getTenantId(request);
return request.multipartData()
.flatMap(multipartData -> {
var part = multipartData.getFirst("file");
if (part == null) {
return ServerResponse.badRequest()
.bodyValue(Map.of("code", 400, "message", "未上传文件"));
}
if (!(part instanceof FilePart filePart)) {
return ServerResponse.badRequest()
.bodyValue(Map.of("code", 400, "message", "无效的文件格式"));
}
return brandConfigService.uploadBackgroundImage(tenantId, filePart)
.flatMap(config -> ServerResponse.ok().bodyValue(config));
})
.switchIfEmpty(ServerResponse.badRequest()
.bodyValue(Map.of("code", 400, "message", "请求数据为空")))
.onErrorResume(IllegalArgumentException.class, ex ->
ServerResponse.badRequest().bodyValue(Map.of(
"code", 400,
"message", ex.getMessage()
))
);
}
@Operation(summary = "更新品牌配色", description = "设置品牌主色调、辅助色等配色方案")
public Mono<ServerResponse> updateColorConfig(ServerRequest request) {
String tenantId = authUtil.getTenantId(request);
return request.bodyToMono(Map.class)
.map(this::mapToBrandConfig)
.flatMap(config -> brandConfigService.updateColorConfig(tenantId, config))
.flatMap(config -> ServerResponse.ok().bodyValue(config))
.onErrorResume(IllegalArgumentException.class, ex ->
ServerResponse.badRequest().bodyValue(Map.of(
"code", 400,
"message", ex.getMessage(),
"timestamp", LocalDateTime.now()
))
);
}
@Operation(summary = "删除Logo", description = "删除品牌Logo,恢复默认")
public Mono<ServerResponse> removeLogo(ServerRequest request) {
String tenantId = authUtil.getTenantId(request);
return brandConfigService.removeLogo(tenantId)
.flatMap(config -> ServerResponse.ok().bodyValue(config));
}
@Operation(summary = "删除背景图", description = "删除品牌背景图,恢复默认")
public Mono<ServerResponse> removeBackgroundImage(ServerRequest request) {
String tenantId = authUtil.getTenantId(request);
return brandConfigService.removeBackgroundImage(tenantId)
.flatMap(config -> ServerResponse.ok().bodyValue(config));
}
@Operation(summary = "更新品牌信息", description = "设置品牌名称和口号")
public Mono<ServerResponse> updateBrandInfo(ServerRequest request) {
String tenantId = authUtil.getTenantId(request);
return request.bodyToMono(Map.class)
.map(body -> {
BrandConfig config = new BrandConfig();
if (body.containsKey("brandName")) {
String name = (String) body.get("brandName");
if (name.length() > 100) {
throw new IllegalArgumentException("品牌名称不能超过100个字符");
}
config.setBrandName(name);
}
if (body.containsKey("slogan")) {
String slogan = (String) body.get("slogan");
if (slogan.length() > 200) {
throw new IllegalArgumentException("口号不能超过200个字符");
}
config.setSlogan(slogan);
}
return config;
})
.flatMap(config -> brandConfigService.updateBrandInfo(tenantId, config))
.flatMap(config -> ServerResponse.ok().bodyValue(config))
.onErrorResume(IllegalArgumentException.class, ex ->
ServerResponse.badRequest().bodyValue(Map.of(
"code", 400,
"message", ex.getMessage()
))
);
}
/**
* 将前端传来的 Map 转换为 BrandConfig(仅用于颜色配置)
*/
private BrandConfig mapToBrandConfig(Map<String, Object> body) {
BrandConfig config = new BrandConfig();
if (body.containsKey("primaryColor")) {
String color = (String) body.get("primaryColor");
validateHexColor(color);
config.setPrimaryColor(color);
}
if (body.containsKey("primaryColorRgb")) {
config.setPrimaryColorRgb((String) body.get("primaryColorRgb"));
}
if (body.containsKey("secondaryColor")) {
String color = (String) body.get("secondaryColor");
validateHexColor(color);
config.setSecondaryColor(color);
}
if (body.containsKey("secondaryColorRgb")) {
config.setSecondaryColorRgb((String) body.get("secondaryColorRgb"));
}
if (body.containsKey("fontFamily")) {
config.setFontFamily((String) body.get("fontFamily"));
}
return config;
}
/**
* 验证HEX颜色格式
*/
private void validateHexColor(String color) {
if (color == null) {
return;
}
if (!color.matches("^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$")) {
throw new IllegalArgumentException("无效的HEX颜色格式: " + color + ",正确格式如 #1E90FF");
}
}
}
@@ -0,0 +1,207 @@
package cn.novalon.gym.manage.brand.websocket;
import cn.novalon.gym.manage.brand.core.domain.BrandConfig;
import cn.novalon.gym.manage.sys.security.JwtTokenProvider;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.socket.WebSocketHandler;
import org.springframework.web.reactive.socket.WebSocketSession;
import reactor.core.publisher.Mono;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 品牌配置实时预览 WebSocket 处理器
*
* @author 张翔
* @date 2026-07-23
*/
@Component
public class BrandWebSocketHandler implements WebSocketHandler {
private final Map<String, WebSocketSession> sessions = new ConcurrentHashMap<>();
private final Map<String, LocalDateTime> lastActivityTime = new ConcurrentHashMap<>();
private final ObjectMapper objectMapper = new ObjectMapper();
private final JwtTokenProvider jwtTokenProvider;
@Value("${websocket.idle-timeout:300s}")
private Duration idleTimeout;
@Value("${websocket.heartbeat-interval:30s}")
private Duration heartbeatInterval;
public BrandWebSocketHandler(JwtTokenProvider jwtTokenProvider) {
this.jwtTokenProvider = jwtTokenProvider;
}
@Override
public Mono<Void> handle(WebSocketSession session) {
String tenantId = extractTenantId(session);
sessions.put(tenantId, session);
lastActivityTime.put(tenantId, LocalDateTime.now());
return session.receive()
.doOnNext(message -> {
String payload = message.getPayloadAsText();
handleIncomingMessage(session, tenantId, payload);
lastActivityTime.put(tenantId, LocalDateTime.now());
})
.doOnComplete(() -> {
sessions.remove(tenantId);
lastActivityTime.remove(tenantId);
})
.doOnError(error -> {
sessions.remove(tenantId);
lastActivityTime.remove(tenantId);
})
.then();
}
@Scheduled(fixedRate = 60000)
public void cleanupIdleConnections() {
LocalDateTime now = LocalDateTime.now();
lastActivityTime.entrySet().removeIf(entry -> {
if (Duration.between(entry.getValue(), now).compareTo(idleTimeout) > 0) {
String tenantId = entry.getKey();
WebSocketSession session = sessions.remove(tenantId);
if (session != null && session.isOpen()) {
session.close().subscribe();
}
return true;
}
return false;
});
}
@Scheduled(fixedRate = 30000)
public void sendHeartbeat() {
sessions.forEach((tenantId, session) -> {
if (session.isOpen()) {
try {
String heartbeat = objectMapper.writeValueAsString(Map.of(
"type", "heartbeat",
"timestamp", System.currentTimeMillis()
));
session.send(Mono.just(session.textMessage(heartbeat))).subscribe();
} catch (Exception e) {
System.err.println("Brand WS heartbeat error: " + e.getMessage());
}
}
});
}
/**
* 向指定租户推送品牌配置更新
*/
public void sendBrandUpdate(String tenantId, BrandConfig config) {
WebSocketSession session = sessions.get(tenantId);
if (session != null && session.isOpen()) {
try {
Map<String, Object> message = Map.of(
"type", "brandUpdate",
"data", config,
"timestamp", System.currentTimeMillis()
);
String json = objectMapper.writeValueAsString(message);
session.send(Mono.just(session.textMessage(json))).subscribe();
} catch (Exception e) {
System.err.println("Brand WS send error: " + e.getMessage());
}
}
}
/**
* 广播品牌配置更新给所有连接的租户
*/
public void broadcastBrandUpdate(BrandConfig config) {
// 仅推送给对应租户
if (config.getTenantId() != null) {
sendBrandUpdate(config.getTenantId(), config);
}
}
/**
* 从 WebSocket 握手中提取租户ID
* <ol>
* <li>优先从 Authorization Header 的 JWT Token 中提取</li>
* <li>回退到 URL 查询参数 tenantId</li>
* <li>兜底使用 session ID</li>
* </ol>
*/
private String extractTenantId(WebSocketSession session) {
// 1. 优先从 JWT Token 提取
String tenantId = extractTenantIdFromJwt(session);
if (tenantId != null) {
return tenantId;
}
// 2. 回退到查询参数(兼容旧版)
String query = session.getHandshakeInfo().getUri().getQuery();
if (query != null && query.contains("tenantId=")) {
return query.split("tenantId=")[1].split("&")[0];
}
// 3. 兜底
return session.getId();
}
/**
* 从 WebSocket 握手的 Authorization Header 中提取 JWT Token 的 tenantId
*/
private String extractTenantIdFromJwt(WebSocketSession session) {
try {
var headers = session.getHandshakeInfo().getHeaders();
String authHeader = headers.getFirst(HttpHeaders.AUTHORIZATION);
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
if (jwtTokenProvider.validateToken(token)) {
return jwtTokenProvider.getTenantIdFromToken(token);
}
}
} catch (Exception e) {
System.err.println("Brand WS: Failed to extract tenantId from JWT: " + e.getMessage());
}
return null;
}
private void handleIncomingMessage(WebSocketSession session, String tenantId, String payload) {
try {
Map<String, Object> message = objectMapper.readValue(payload,
new TypeReference<Map<String, Object>>() {});
String type = (String) message.get("type");
switch (type) {
case "ping":
sendPong(session);
break;
case "subscribe":
sessions.put(tenantId, session);
lastActivityTime.put(tenantId, LocalDateTime.now());
break;
default:
break;
}
} catch (Exception e) {
System.err.println("Brand WS message error: " + e.getMessage());
}
}
private void sendPong(WebSocketSession session) {
try {
String pong = objectMapper.writeValueAsString(Map.of(
"type", "pong",
"timestamp", System.currentTimeMillis()
));
session.send(Mono.just(session.textMessage(pong))).subscribe();
} catch (Exception e) {
System.err.println("Brand WS pong error: " + e.getMessage());
}
}
}
@@ -0,0 +1 @@
cn.novalon.gym.manage.brand.config.BrandWebSocketConfig
@@ -0,0 +1,296 @@
package cn.novalon.gym.manage.brand.core.service.impl;
import cn.novalon.gym.manage.brand.core.domain.BrandConfig;
import cn.novalon.gym.manage.brand.core.repository.IBrandConfigRepository;
import cn.novalon.gym.manage.brand.core.service.FileStorageService;
import cn.novalon.gym.manage.brand.websocket.BrandWebSocketHandler;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.codec.multipart.FilePart;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class BrandConfigServiceImplTest {
@Mock
private IBrandConfigRepository brandConfigRepository;
@Mock
private FileStorageService fileStorageService;
@Mock
private BrandWebSocketHandler brandWebSocketHandler;
@Mock
private FilePart filePart;
private BrandConfigServiceImpl brandConfigService;
private static final String TENANT_ID = "tenant-001";
@BeforeEach
void setUp() {
brandConfigService = new BrandConfigServiceImpl(
brandConfigRepository, fileStorageService, brandWebSocketHandler);
}
// ==================== getBrandConfig ====================
@Test
void getBrandConfig_shouldReturnExistingConfig() {
BrandConfig existingConfig = createTestConfig();
when(brandConfigRepository.findByTenantId(TENANT_ID)).thenReturn(Mono.just(existingConfig));
Mono<BrandConfig> result = brandConfigService.getBrandConfig(TENANT_ID);
StepVerifier.create(result)
.assertNext(config -> {
assertThat(config).isNotNull();
assertThat(config.getTenantId()).isEqualTo(TENANT_ID);
assertThat(config.getPrimaryColor()).isEqualTo("#00E676");
})
.verifyComplete();
verify(brandConfigRepository).findByTenantId(TENANT_ID);
verify(brandConfigRepository, never()).save(any());
}
@Test
void getBrandConfig_shouldCreateDefaultWhenNotFound() {
when(brandConfigRepository.findByTenantId(TENANT_ID)).thenReturn(Mono.empty());
when(brandConfigRepository.save(any(BrandConfig.class)))
.thenAnswer(invocation -> Mono.just(invocation.getArgument(0)));
Mono<BrandConfig> result = brandConfigService.getBrandConfig(TENANT_ID);
StepVerifier.create(result)
.assertNext(config -> {
assertThat(config.getTenantId()).isEqualTo(TENANT_ID);
assertThat(config.getPrimaryColor()).isEqualTo("#00E676");
assertThat(config.getPrimaryColorRgb()).isEqualTo("0,230,118");
assertThat(config.getSecondaryColor()).isEqualTo("#1A1A1A");
assertThat(config.getLogoUrl()).isNull();
})
.verifyComplete();
verify(brandConfigRepository).findByTenantId(TENANT_ID);
verify(brandConfigRepository).save(any(BrandConfig.class));
}
// ==================== uploadLogo ====================
@Test
void uploadLogo_shouldUploadAndSaveConfig() {
BrandConfig existingConfig = createTestConfig();
String newLogoUrl = "https://example.com/new-logo.png";
lenient().when(brandConfigRepository.findByTenantId(TENANT_ID))
.thenReturn(Mono.just(existingConfig));
when(fileStorageService.uploadImage(filePart, "logo")).thenReturn(Mono.just(newLogoUrl));
when(fileStorageService.deleteFile("https://example.com/logo.png")).thenReturn(Mono.empty());
when(brandConfigRepository.save(any(BrandConfig.class)))
.thenAnswer(invocation -> Mono.just(invocation.getArgument(0)));
Mono<BrandConfig> result = brandConfigService.uploadLogo(TENANT_ID, filePart);
StepVerifier.create(result)
.assertNext(config -> {
assertThat(config.getLogoUrl()).isEqualTo(newLogoUrl);
})
.verifyComplete();
verify(fileStorageService).uploadImage(filePart, "logo");
verify(brandConfigRepository, atLeastOnce()).save(any(BrandConfig.class));
}
@Test
void uploadLogo_shouldCreateConfigWhenTenantNotFound() {
String newLogoUrl = "https://example.com/new-logo.png";
lenient().when(brandConfigRepository.findByTenantId(TENANT_ID)).thenReturn(Mono.empty());
when(fileStorageService.uploadImage(filePart, "logo")).thenReturn(Mono.just(newLogoUrl));
lenient().when(fileStorageService.deleteFile(any())).thenReturn(Mono.empty());
when(brandConfigRepository.save(any(BrandConfig.class)))
.thenAnswer(invocation -> Mono.just(invocation.getArgument(0)));
Mono<BrandConfig> result = brandConfigService.uploadLogo(TENANT_ID, filePart);
StepVerifier.create(result)
.assertNext(config -> {
assertThat(config.getLogoUrl()).isEqualTo(newLogoUrl);
assertThat(config.getTenantId()).isEqualTo(TENANT_ID);
})
.verifyComplete();
}
@Test
void uploadLogo_shouldPropagateStorageError() {
BrandConfig existingConfig = createTestConfig();
lenient().when(brandConfigRepository.findByTenantId(TENANT_ID))
.thenReturn(Mono.just(existingConfig));
when(fileStorageService.uploadImage(filePart, "logo"))
.thenReturn(Mono.error(new RuntimeException("Storage error")));
Mono<BrandConfig> result = brandConfigService.uploadLogo(TENANT_ID, filePart);
StepVerifier.create(result)
.expectError(RuntimeException.class)
.verify();
}
// ==================== uploadBackgroundImage ====================
@Test
void uploadBackgroundImage_shouldUploadAndSaveConfig() {
BrandConfig existingConfig = createTestConfig();
String newBgUrl = "https://example.com/new-bg.png";
lenient().when(brandConfigRepository.findByTenantId(TENANT_ID))
.thenReturn(Mono.just(existingConfig));
when(fileStorageService.uploadImage(filePart, "background")).thenReturn(Mono.just(newBgUrl));
lenient().when(fileStorageService.deleteFile(anyString())).thenReturn(Mono.empty());
when(brandConfigRepository.save(any(BrandConfig.class)))
.thenAnswer(invocation -> Mono.just(invocation.getArgument(0)));
Mono<BrandConfig> result = brandConfigService.uploadBackgroundImage(TENANT_ID, filePart);
StepVerifier.create(result)
.assertNext(config -> {
assertThat(config.getBackgroundImageUrl()).isEqualTo(newBgUrl);
})
.verifyComplete();
verify(fileStorageService).uploadImage(filePart, "background");
}
// ==================== updateColorConfig ====================
@Test
void updateColorConfig_shouldUpdateAllColorFields() {
BrandConfig existingConfig = createTestConfig();
BrandConfig updateConfig = new BrandConfig();
updateConfig.setPrimaryColor("#FF0000");
updateConfig.setPrimaryColorRgb("255,0,0");
updateConfig.setSecondaryColor("#00FF00");
updateConfig.setSecondaryColorRgb("0,255,0");
updateConfig.setFontFamily("Arial");
lenient().when(brandConfigRepository.findByTenantId(TENANT_ID))
.thenReturn(Mono.just(existingConfig));
when(brandConfigRepository.save(any(BrandConfig.class)))
.thenAnswer(invocation -> Mono.just(invocation.getArgument(0)));
Mono<BrandConfig> result = brandConfigService.updateColorConfig(TENANT_ID, updateConfig);
StepVerifier.create(result)
.assertNext(config -> {
assertThat(config.getPrimaryColor()).isEqualTo("#FF0000");
assertThat(config.getPrimaryColorRgb()).isEqualTo("255,0,0");
assertThat(config.getSecondaryColor()).isEqualTo("#00FF00");
assertThat(config.getSecondaryColorRgb()).isEqualTo("0,255,0");
assertThat(config.getFontFamily()).isEqualTo("Arial");
})
.verifyComplete();
}
@Test
void updateColorConfig_shouldOnlyUpdateProvidedFields() {
BrandConfig existingConfig = createTestConfig();
BrandConfig updateConfig = new BrandConfig();
updateConfig.setPrimaryColor("#FF0000");
lenient().when(brandConfigRepository.findByTenantId(TENANT_ID))
.thenReturn(Mono.just(existingConfig));
when(brandConfigRepository.save(any(BrandConfig.class)))
.thenAnswer(invocation -> Mono.just(invocation.getArgument(0)));
Mono<BrandConfig> result = brandConfigService.updateColorConfig(TENANT_ID, updateConfig);
StepVerifier.create(result)
.assertNext(config -> {
assertThat(config.getPrimaryColor()).isEqualTo("#FF0000");
assertThat(config.getSecondaryColor()).isEqualTo("#1A1A1A"); // unchanged
assertThat(config.getLogoUrl()).isEqualTo("https://example.com/logo.png"); // unchanged
})
.verifyComplete();
}
// ==================== removeLogo ====================
@Test
void removeLogo_shouldClearLogoUrl() {
BrandConfig existingConfig = createTestConfig();
lenient().when(brandConfigRepository.findByTenantId(TENANT_ID))
.thenReturn(Mono.just(existingConfig));
when(fileStorageService.deleteFile("https://example.com/logo.png")).thenReturn(Mono.empty());
when(brandConfigRepository.save(any(BrandConfig.class)))
.thenAnswer(invocation -> Mono.just(invocation.getArgument(0)));
Mono<BrandConfig> result = brandConfigService.removeLogo(TENANT_ID);
StepVerifier.create(result)
.assertNext(config -> {
assertThat(config.getLogoUrl()).isNull();
})
.verifyComplete();
verify(fileStorageService).deleteFile("https://example.com/logo.png");
}
// ==================== removeBackgroundImage ====================
@Test
void removeBackgroundImage_shouldClearBackgroundImageUrl() {
BrandConfig existingConfig = createTestConfig();
lenient().when(brandConfigRepository.findByTenantId(TENANT_ID))
.thenReturn(Mono.just(existingConfig));
when(fileStorageService.deleteFile("https://example.com/bg.png")).thenReturn(Mono.empty());
when(brandConfigRepository.save(any(BrandConfig.class)))
.thenAnswer(invocation -> Mono.just(invocation.getArgument(0)));
Mono<BrandConfig> result = brandConfigService.removeBackgroundImage(TENANT_ID);
StepVerifier.create(result)
.assertNext(config -> {
assertThat(config.getBackgroundImageUrl()).isNull();
})
.verifyComplete();
verify(fileStorageService).deleteFile("https://example.com/bg.png");
}
// ==================== helper ====================
private BrandConfig createTestConfig() {
BrandConfig config = new BrandConfig();
config.setId(1L);
config.setTenantId(TENANT_ID);
config.setLogoUrl("https://example.com/logo.png");
config.setBackgroundImageUrl("https://example.com/bg.png");
config.setPrimaryColor("#00E676");
config.setPrimaryColorRgb("0,230,118");
config.setSecondaryColor("#1A1A1A");
config.setSecondaryColorRgb("26,26,26");
config.setFontFamily("default");
config.setCreatedAt(LocalDateTime.now());
config.setUpdatedAt(LocalDateTime.now());
return config;
}
}
@@ -0,0 +1,170 @@
package cn.novalon.gym.manage.brand.core.service.impl;
import cn.novalon.gym.manage.brand.config.OssProperties;
import cn.novalon.gym.manage.brand.core.service.FileStorageService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.codec.multipart.FilePart;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class DualFileStorageServiceTest {
@Mock
private FileStorageService ossStorage;
@Mock
private FileStorageService localStorage;
@Mock
private FilePart filePart;
private OssProperties ossProperties;
private DualFileStorageService dualService;
@BeforeEach
void setUp() {
ossProperties = new OssProperties();
// 默认不启用 OSS
ossProperties.setEnabled(false);
dualService = new DualFileStorageService(ossStorage, localStorage, ossProperties);
}
// ==================== OSS 未启用时,直接走本地存储 ====================
@Test
void shouldUseLocalStorageWhenOssDisabled() {
String localUrl = "http://localhost:8080/api/files/preview/brand/logo/logo_test.png";
when(localStorage.uploadImage(filePart, "logo")).thenReturn(Mono.just(localUrl));
Mono<String> result = dualService.uploadImage(filePart, "logo");
StepVerifier.create(result)
.assertNext(url -> assertThat(url).isEqualTo(localUrl))
.verifyComplete();
verify(localStorage).uploadImage(filePart, "logo");
verify(ossStorage, never()).uploadImage(any(), anyString());
}
@Test
void shouldDeleteFromLocalOnlyWhenOssDisabled() {
String fileUrl = "http://localhost/api/files/preview/brand/logo/test.png";
when(localStorage.deleteFile(fileUrl)).thenReturn(Mono.empty());
Mono<Void> result = dualService.deleteFile(fileUrl);
StepVerifier.create(result).verifyComplete();
verify(localStorage).deleteFile(fileUrl);
verify(ossStorage, never()).deleteFile(anyString());
}
// ==================== OSS 启用时,优先 OSS ====================
@Test
void shouldUseOssWhenEnabledAndConfigured() {
configureOss();
String ossUrl = "https://my-bucket.oss-cn-hangzhou.aliyuncs.com/brand/logo/logo_test.png";
when(ossStorage.uploadImage(filePart, "logo")).thenReturn(Mono.just(ossUrl));
Mono<String> result = dualService.uploadImage(filePart, "logo");
StepVerifier.create(result)
.assertNext(url -> assertThat(url).isEqualTo(ossUrl))
.verifyComplete();
verify(ossStorage).uploadImage(filePart, "logo");
verify(localStorage, never()).uploadImage(any(), anyString());
}
@Test
void shouldFallbackToLocalWhenOssFails() {
configureOss();
String localUrl = "http://localhost:8080/api/files/preview/brand/logo/logo_fallback.png";
when(ossStorage.uploadImage(filePart, "logo"))
.thenReturn(Mono.error(new RuntimeException("OSS unavailable")));
when(localStorage.uploadImage(filePart, "logo")).thenReturn(Mono.just(localUrl));
Mono<String> result = dualService.uploadImage(filePart, "logo");
StepVerifier.create(result)
.assertNext(url -> assertThat(url).isEqualTo(localUrl))
.verifyComplete();
verify(ossStorage).uploadImage(filePart, "logo");
verify(localStorage).uploadImage(filePart, "logo");
}
@Test
void shouldDeleteFromBothWhenOssEnabled() {
configureOss();
String fileUrl = "https://my-bucket.oss-cn-hangzhou.aliyuncs.com/brand/logo/test.png";
when(ossStorage.deleteFile(fileUrl)).thenReturn(Mono.empty());
when(localStorage.deleteFile(fileUrl)).thenReturn(Mono.empty());
Mono<Void> result = dualService.deleteFile(fileUrl);
StepVerifier.create(result).verifyComplete();
verify(ossStorage).deleteFile(fileUrl);
verify(localStorage).deleteFile(fileUrl);
}
@Test
void shouldDeleteLocalEvenWhenOssDeleteFails() {
configureOss();
String fileUrl = "https://my-bucket.oss-cn-hangzhou.aliyuncs.com/brand/logo/test.png";
when(ossStorage.deleteFile(fileUrl))
.thenReturn(Mono.error(new RuntimeException("OSS delete failed")));
when(localStorage.deleteFile(fileUrl)).thenReturn(Mono.empty());
Mono<Void> result = dualService.deleteFile(fileUrl);
StepVerifier.create(result).verifyComplete();
verify(ossStorage).deleteFile(fileUrl);
verify(localStorage).deleteFile(fileUrl);
}
// ==================== 边界情况 ====================
@Test
void deleteFile_shouldHandleNullUrl() {
Mono<Void> result = dualService.deleteFile(null);
StepVerifier.create(result).verifyComplete();
verify(localStorage, never()).deleteFile(any());
verify(ossStorage, never()).deleteFile(any());
}
@Test
void deleteFile_shouldHandleEmptyUrl() {
Mono<Void> result = dualService.deleteFile("");
StepVerifier.create(result).verifyComplete();
verify(localStorage, never()).deleteFile(any());
verify(ossStorage, never()).deleteFile(any());
}
// ==================== helper ====================
private void configureOss() {
ossProperties.setEnabled(true);
ossProperties.setEndpoint("oss-cn-hangzhou.aliyuncs.com");
ossProperties.setAccessKeyId("test-access-key");
ossProperties.setAccessKeySecret("test-access-secret");
ossProperties.setBucketName("test-bucket");
}
}
@@ -0,0 +1,182 @@
package cn.novalon.gym.manage.brand.core.service.impl;
import cn.novalon.gym.manage.brand.core.service.FileStorageService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.codec.multipart.FilePart;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import java.io.File;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class LocalFileStorageServiceTest {
@TempDir
Path tempDir;
@Mock
private FilePart filePart;
private FileStorageService localStorageService;
@BeforeEach
void setUp() {
String uploadDir = tempDir.toString();
String baseUrl = "http://localhost:8080/api/files";
localStorageService = new LocalFileStorageService(uploadDir, baseUrl);
}
@Test
void uploadImage_shouldAcceptAndSaveValidFiles() {
when(filePart.filename()).thenReturn("test-logo.png");
when(filePart.transferTo(any(File.class))).thenAnswer(invocation -> {
File targetFile = invocation.getArgument(0);
targetFile.createNewFile();
return Mono.empty();
});
Mono<String> result = localStorageService.uploadImage(filePart, "logo");
StepVerifier.create(result)
.assertNext(url -> {
assertThat(url).contains("brand/logo/logo_");
assertThat(url).endsWith(".png");
})
.verifyComplete();
}
@Test
void uploadImage_shouldRejectInvalidFormat() {
when(filePart.filename()).thenReturn("document.pdf");
Mono<String> result = localStorageService.uploadImage(filePart, "logo");
StepVerifier.create(result)
.expectError(IllegalArgumentException.class)
.verify();
}
@Test
void uploadImage_shouldRejectTxtFormat() {
when(filePart.filename()).thenReturn("script.txt");
Mono<String> result = localStorageService.uploadImage(filePart, "logo");
StepVerifier.create(result)
.expectError(IllegalArgumentException.class)
.verify();
}
@Test
void uploadImage_shouldRejectBatchFormat() {
when(filePart.filename()).thenReturn("file.bat");
Mono<String> result = localStorageService.uploadImage(filePart, "logo");
StepVerifier.create(result)
.expectError(IllegalArgumentException.class)
.verify();
}
@Test
void uploadImage_shouldHandleNoExtensionAsPng() {
when(filePart.filename()).thenReturn("noextension");
when(filePart.transferTo(any(File.class))).thenAnswer(invocation -> {
File targetFile = invocation.getArgument(0);
targetFile.createNewFile();
return Mono.empty();
});
Mono<String> result = localStorageService.uploadImage(filePart, "logo");
StepVerifier.create(result)
.assertNext(url -> {
assertThat(url).endsWith(".png");
})
.verifyComplete();
}
@Test
void uploadImage_shouldGenerateCorrectUrlFormat() {
when(filePart.filename()).thenReturn("company-logo.png");
when(filePart.transferTo(any(File.class))).thenAnswer(invocation -> {
File targetFile = invocation.getArgument(0);
targetFile.createNewFile();
return Mono.empty();
});
Mono<String> result = localStorageService.uploadImage(filePart, "logo");
StepVerifier.create(result)
.assertNext(url -> {
assertThat(url).startsWith("http://localhost:8080/api/files/preview/brand/logo/");
})
.verifyComplete();
}
@Test
void uploadImage_shouldUseCorrectDirectory() {
when(filePart.filename()).thenReturn("bg.png");
when(filePart.transferTo(any(File.class))).thenAnswer(invocation -> {
File targetFile = invocation.getArgument(0);
targetFile.createNewFile();
return Mono.empty();
});
Mono<String> result = localStorageService.uploadImage(filePart, "background");
StepVerifier.create(result)
.assertNext(url -> {
assertThat(url).contains("brand/background/background_");
})
.verifyComplete();
}
@Test
void uploadImage_shouldHandleTransferError() {
when(filePart.filename()).thenReturn("logo.png");
when(filePart.transferTo(any(File.class)))
.thenReturn(Mono.error(new RuntimeException("Transfer failed")));
Mono<String> result = localStorageService.uploadImage(filePart, "logo");
StepVerifier.create(result)
.expectError(RuntimeException.class)
.verify();
}
@Test
void deleteFile_shouldNotThrowForNullUrl() {
Mono<Void> result = localStorageService.deleteFile(null);
StepVerifier.create(result)
.verifyComplete();
}
@Test
void deleteFile_shouldNotThrowForEmptyUrl() {
Mono<Void> result = localStorageService.deleteFile("");
StepVerifier.create(result)
.verifyComplete();
}
@Test
void deleteFile_shouldNotThrowForNonExistentFile() {
Mono<Void> result = localStorageService.deleteFile(
"http://localhost:8080/api/files/preview/brand/logo/nonexistent.png");
StepVerifier.create(result)
.verifyComplete();
}
}
@@ -0,0 +1,209 @@
package cn.novalon.gym.manage.brand.handler;
import cn.novalon.gym.manage.brand.core.domain.BrandConfig;
import cn.novalon.gym.manage.brand.core.service.IBrandConfigService;
import cn.novalon.gym.manage.sys.util.AuthUtil;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.mock.web.reactive.function.server.MockServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import java.time.LocalDateTime;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class BrandConfigHandlerTest {
@Mock
private IBrandConfigService brandConfigService;
@Mock
private AuthUtil authUtil;
private BrandConfigHandler brandConfigHandler;
private static final String TENANT_ID = "tenant-001";
@BeforeEach
void setUp() {
brandConfigHandler = new BrandConfigHandler(brandConfigService, authUtil);
}
private MockServerRequest.Builder mockRequest() {
return MockServerRequest.builder()
.header("X-Tenant-Id", "tenant-001");
}
// ==================== getBrandConfig ====================
@Test
void getBrandConfig_shouldReturnConfig() {
BrandConfig config = createTestConfig();
when(authUtil.getTenantId(any())).thenReturn(TENANT_ID);
when(brandConfigService.getBrandConfig(TENANT_ID)).thenReturn(Mono.just(config));
MockServerRequest request = mockRequest().build();
Mono<ServerResponse> result = brandConfigHandler.getBrandConfig(request);
StepVerifier.create(result)
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK))
.verifyComplete();
verify(brandConfigService).getBrandConfig(TENANT_ID);
}
@Test
void getBrandConfig_shouldReturnOkEvenWhenNotFound() {
when(authUtil.getTenantId(any())).thenReturn(TENANT_ID);
when(brandConfigService.getBrandConfig(TENANT_ID)).thenReturn(Mono.empty());
MockServerRequest request = mockRequest().build();
Mono<ServerResponse> result = brandConfigHandler.getBrandConfig(request);
StepVerifier.create(result)
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK))
.verifyComplete();
}
// ==================== updateColorConfig ====================
@Test
void updateColorConfig_shouldUpdateAndReturnOk() {
BrandConfig config = createTestConfig();
config.setPrimaryColor("#FF0000");
Map<String, Object> requestBody = Map.of("primaryColor", "#FF0000");
when(authUtil.getTenantId(any())).thenReturn(TENANT_ID);
when(brandConfigService.updateColorConfig(eq(TENANT_ID), any(BrandConfig.class)))
.thenReturn(Mono.just(config));
MockServerRequest request = mockRequest()
.body(Mono.just(requestBody));
Mono<ServerResponse> result = brandConfigHandler.updateColorConfig(request);
StepVerifier.create(result)
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK))
.verifyComplete();
}
@Test
void updateColorConfig_shouldRejectInvalidHex() {
Map<String, Object> requestBody = Map.of("primaryColor", "INVALID");
when(authUtil.getTenantId(any())).thenReturn(TENANT_ID);
MockServerRequest request = mockRequest()
.body(Mono.just(requestBody));
Mono<ServerResponse> result = brandConfigHandler.updateColorConfig(request);
StepVerifier.create(result)
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST))
.verifyComplete();
verify(brandConfigService, never()).updateColorConfig(anyString(), any());
}
@Test
void updateColorConfig_shouldAcceptShortHexFormat() {
BrandConfig config = createTestConfig();
config.setPrimaryColor("#F00");
Map<String, Object> requestBody = Map.of("primaryColor", "#F00");
when(authUtil.getTenantId(any())).thenReturn(TENANT_ID);
when(brandConfigService.updateColorConfig(eq(TENANT_ID), any(BrandConfig.class)))
.thenReturn(Mono.just(config));
MockServerRequest request = mockRequest()
.body(Mono.just(requestBody));
Mono<ServerResponse> result = brandConfigHandler.updateColorConfig(request);
StepVerifier.create(result)
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK))
.verifyComplete();
}
@Test
void updateColorConfig_shouldRejectInvalidHexInSecondary() {
Map<String, Object> requestBody = Map.of("secondaryColor", "not-a-color");
when(authUtil.getTenantId(any())).thenReturn(TENANT_ID);
MockServerRequest request = mockRequest()
.body(Mono.just(requestBody));
Mono<ServerResponse> result = brandConfigHandler.updateColorConfig(request);
StepVerifier.create(result)
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST))
.verifyComplete();
}
// ==================== removeLogo ====================
@Test
void removeLogo_shouldRemoveAndReturnOk() {
BrandConfig config = createTestConfig();
config.setLogoUrl(null);
when(authUtil.getTenantId(any())).thenReturn(TENANT_ID);
when(brandConfigService.removeLogo(TENANT_ID)).thenReturn(Mono.just(config));
MockServerRequest request = mockRequest().build();
Mono<ServerResponse> result = brandConfigHandler.removeLogo(request);
StepVerifier.create(result)
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK))
.verifyComplete();
verify(brandConfigService).removeLogo(TENANT_ID);
}
// ==================== removeBackgroundImage ====================
@Test
void removeBackgroundImage_shouldRemoveAndReturnOk() {
BrandConfig config = createTestConfig();
config.setBackgroundImageUrl(null);
when(authUtil.getTenantId(any())).thenReturn(TENANT_ID);
when(brandConfigService.removeBackgroundImage(TENANT_ID)).thenReturn(Mono.just(config));
MockServerRequest request = mockRequest().build();
Mono<ServerResponse> result = brandConfigHandler.removeBackgroundImage(request);
StepVerifier.create(result)
.assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK))
.verifyComplete();
verify(brandConfigService).removeBackgroundImage(TENANT_ID);
}
// ==================== helper ====================
private BrandConfig createTestConfig() {
BrandConfig config = new BrandConfig();
config.setId(1L);
config.setTenantId(TENANT_ID);
config.setLogoUrl("https://example.com/logo.png");
config.setBackgroundImageUrl("https://example.com/bg.png");
config.setPrimaryColor("#00E676");
config.setPrimaryColorRgb("0,230,118");
config.setSecondaryColor("#1A1A1A");
config.setSecondaryColorRgb("26,26,26");
config.setFontFamily("default");
config.setCreatedAt(LocalDateTime.now());
config.setUpdatedAt(LocalDateTime.now());
return config;
}
}
@@ -0,0 +1,181 @@
package cn.novalon.gym.manage.brand.websocket;
import cn.novalon.gym.manage.brand.core.domain.BrandConfig;
import cn.novalon.gym.manage.sys.security.JwtTokenProvider;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpHeaders;
import org.springframework.web.reactive.socket.HandshakeInfo;
import org.springframework.web.reactive.socket.WebSocketMessage;
import org.springframework.web.reactive.socket.WebSocketSession;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import java.net.URI;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class BrandWebSocketHandlerTest {
@Mock
private WebSocketSession session;
@Mock
private WebSocketMessage webSocketMessage;
@Mock
private HandshakeInfo handshakeInfo;
@Mock
private JwtTokenProvider jwtTokenProvider;
private BrandWebSocketHandler handler;
@BeforeEach
void setUp() {
handler = new BrandWebSocketHandler(jwtTokenProvider);
}
// ==================== JWT-based tenantId extraction ====================
@Test
void handle_shouldExtractTenantIdFromJwtAuthHeader() {
setupJwtAuthHeader("Bearer valid-jwt-token");
when(jwtTokenProvider.validateToken("valid-jwt-token")).thenReturn(true);
when(jwtTokenProvider.getTenantIdFromToken("valid-jwt-token")).thenReturn("tenant-from-jwt");
when(session.receive()).thenReturn(Flux.never());
// Should not throw — tenantId extracted from JWT
handler.handle(session).subscribe();
}
// ==================== Query param fallback ====================
@Test
void handle_shouldFallbackToQueryParamWhenNoJwtHeader() {
// No Authorization header
when(handshakeInfo.getHeaders()).thenReturn(new HttpHeaders());
URI uri = URI.create("ws://localhost/ws/brand?tenantId=tenant-from-query");
when(session.getHandshakeInfo()).thenReturn(handshakeInfo);
when(handshakeInfo.getUri()).thenReturn(uri);
when(session.receive()).thenReturn(Flux.never());
handler.handle(session).subscribe();
}
@Test
void handle_shouldFallbackToSessionIdWhenNoQueryParamAndNoJwt() {
when(handshakeInfo.getHeaders()).thenReturn(new HttpHeaders());
URI uri = URI.create("ws://localhost/ws/brand");
when(session.getHandshakeInfo()).thenReturn(handshakeInfo);
when(handshakeInfo.getUri()).thenReturn(uri);
when(session.getId()).thenReturn("session-id-123");
when(session.receive()).thenReturn(Flux.never());
handler.handle(session).subscribe();
}
// ==================== Message handling ====================
@Test
void handle_shouldProcessPingMessage() {
setupSessionWithJwt("tenant-001");
when(session.receive()).thenReturn(Flux.just(webSocketMessage));
when(webSocketMessage.getPayloadAsText()).thenReturn("{\"type\":\"ping\"}");
when(session.textMessage(anyString())).thenReturn(webSocketMessage);
when(session.send(any())).thenReturn(Mono.empty());
Mono<Void> result = handler.handle(session);
StepVerifier.create(result).verifyComplete();
}
@Test
void handle_shouldProcessSubscribeMessage() {
setupSessionWithJwt("tenant-001");
when(session.receive()).thenReturn(Flux.just(webSocketMessage));
when(webSocketMessage.getPayloadAsText()).thenReturn("{\"type\":\"subscribe\"}");
Mono<Void> result = handler.handle(session);
StepVerifier.create(result).verifyComplete();
}
@Test
void handle_shouldIgnoreUnknownMessageType() {
setupSessionWithJwt("tenant-001");
when(session.receive()).thenReturn(Flux.just(webSocketMessage));
when(webSocketMessage.getPayloadAsText()).thenReturn("{\"type\":\"unknown\"}");
Mono<Void> result = handler.handle(session);
StepVerifier.create(result).verifyComplete();
}
@Test
void handle_shouldNotCrashOnInvalidJson() {
setupSessionWithJwt("tenant-001");
when(session.receive()).thenReturn(Flux.just(webSocketMessage));
when(webSocketMessage.getPayloadAsText()).thenReturn("not-valid-json");
Mono<Void> result = handler.handle(session);
StepVerifier.create(result).verifyComplete();
}
@Test
void handle_shouldPropagateConnectionError() {
setupSessionWithJwt("tenant-001");
when(session.receive()).thenReturn(Flux.error(new RuntimeException("Connection error")));
Mono<Void> result = handler.handle(session);
StepVerifier.create(result).verifyError();
}
// ==================== sendBrandUpdate ====================
@Test
void sendBrandUpdate_shouldNotFailWhenNoSession() {
BrandConfig config = createTestConfig();
// Should not throw
handler.sendBrandUpdate("nonexistent-tenant", config);
}
// ==================== helper ====================
private void setupSessionWithJwt(String tenantId) {
String token = "jwt-" + tenantId;
HttpHeaders headers = new HttpHeaders();
headers.set(HttpHeaders.AUTHORIZATION, "Bearer " + token);
when(handshakeInfo.getHeaders()).thenReturn(headers);
when(session.getHandshakeInfo()).thenReturn(handshakeInfo);
when(jwtTokenProvider.validateToken(token)).thenReturn(true);
when(jwtTokenProvider.getTenantIdFromToken(token)).thenReturn(tenantId);
}
private void setupJwtAuthHeader(String authHeader) {
String token = authHeader.substring(7); // strip "Bearer "
HttpHeaders headers = new HttpHeaders();
headers.set(HttpHeaders.AUTHORIZATION, authHeader);
when(handshakeInfo.getHeaders()).thenReturn(headers);
when(session.getHandshakeInfo()).thenReturn(handshakeInfo);
}
private BrandConfig createTestConfig() {
BrandConfig config = new BrandConfig();
config.setId(1L);
config.setTenantId("tenant-001");
config.setLogoUrl("https://example.com/logo.png");
config.setPrimaryColor("#00E676");
config.setFontFamily("default");
return config;
}
}
+6
View File
@@ -175,6 +175,12 @@
<version>1.0.0</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>gym-brand</artifactId>
<version>1.0.0</version>
<scope>compile</scope>
</dependency>
</dependencies>
<build>
@@ -20,6 +20,7 @@ import cn.novalon.gym.manage.notify.handler.BannerHandler;
import cn.novalon.gym.manage.notify.handler.SysNoticeHandler;
import cn.novalon.gym.manage.notify.handler.SysUserMessageHandler;
import cn.novalon.gym.manage.payment.handler.PaymentHandler;
import cn.novalon.gym.manage.brand.handler.BrandConfigHandler;
import cn.novalon.gym.manage.coach.handler.CoachCourseHandler;
import cn.novalon.gym.manage.coach.handler.CoachHandler;
import cn.novalon.gym.manage.datacount.handler.CoachPerformanceHandler;
@@ -88,6 +89,7 @@ public class SystemRouter {
DataStatisticsHandler dataStatisticsHandler,
PhoneAuthHandler phoneAuthHandler,
PaymentHandler paymentHandler,
BrandConfigHandler brandConfigHandler,
CoachHandler coachHandler,
CoachCourseHandler coachCourseHandler,
CoachPerformanceHandler coachPerformanceHandler) {
@@ -241,7 +243,7 @@ public class SystemRouter {
.GET("/api/files/{id}/download", fileHandler::downloadFile)
.GET("/api/files/download/{fileName}", fileHandler::downloadFileByName)
.GET("/api/files/{id}/preview", fileHandler::previewFile)
.GET("/api/files/preview/{fileName}", fileHandler::previewFileByName)
.GET("/api/files/preview/{*fileName}", fileHandler::previewFileByName)
.DELETE("/api/files/{id}", fileHandler::deleteFile)
// ========== 权限路由 ==========
@@ -429,6 +431,17 @@ public class SystemRouter {
.POST("/api/payment/{orderId}/refund", paymentHandler::refundPayment)
.POST("/api/payment/{orderId}/close", paymentHandler::closeOrder)
// ========================================
// ========== 品牌定制模块路由 ============
// ========================================
.GET("/api/brand", brandConfigHandler::getBrandConfig)
.POST("/api/brand/logo", brandConfigHandler::uploadLogo)
.DELETE("/api/brand/logo", brandConfigHandler::removeLogo)
.POST("/api/brand/background", brandConfigHandler::uploadBackgroundImage)
.DELETE("/api/brand/background", brandConfigHandler::removeBackgroundImage)
.PUT("/api/brand/color", brandConfigHandler::updateColorConfig)
.PUT("/api/brand/info", brandConfigHandler::updateBrandInfo)
.build();
}
}
+5
View File
@@ -27,6 +27,11 @@
<artifactId>manage-notify</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>gym-brand</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>manage-file</artifactId>
@@ -0,0 +1,59 @@
package cn.novalon.gym.manage.db.converter;
import cn.novalon.gym.manage.brand.core.domain.BrandConfig;
import cn.novalon.gym.manage.db.entity.BrandConfigEntity;
import org.springframework.stereotype.Component;
/**
* 品牌配置实体转换器
*
* @author 张翔
* @date 2026-07-23
*/
@Component
public class BrandConfigConverter {
public BrandConfig toDomain(BrandConfigEntity entity) {
if (entity == null) {
return null;
}
BrandConfig domain = new BrandConfig();
domain.setId(entity.getId());
domain.setTenantId(entity.getTenantId());
domain.setLogoUrl(entity.getLogoUrl());
domain.setBackgroundImageUrl(entity.getBackgroundImageUrl());
domain.setPrimaryColor(entity.getPrimaryColor());
domain.setPrimaryColorRgb(entity.getPrimaryColorRgb());
domain.setSecondaryColor(entity.getSecondaryColor());
domain.setSecondaryColorRgb(entity.getSecondaryColorRgb());
domain.setFontFamily(entity.getFontFamily());
domain.setBrandName(entity.getBrandName());
domain.setSlogan(entity.getSlogan());
domain.setCreatedAt(entity.getCreatedAt());
domain.setUpdatedAt(entity.getUpdatedAt());
domain.setDeletedAt(entity.getDeletedAt());
return domain;
}
public BrandConfigEntity toEntity(BrandConfig domain) {
if (domain == null) {
return null;
}
BrandConfigEntity entity = new BrandConfigEntity();
entity.setId(domain.getId());
entity.setTenantId(domain.getTenantId());
entity.setLogoUrl(domain.getLogoUrl());
entity.setBackgroundImageUrl(domain.getBackgroundImageUrl());
entity.setPrimaryColor(domain.getPrimaryColor());
entity.setPrimaryColorRgb(domain.getPrimaryColorRgb());
entity.setSecondaryColor(domain.getSecondaryColor());
entity.setSecondaryColorRgb(domain.getSecondaryColorRgb());
entity.setFontFamily(domain.getFontFamily());
entity.setBrandName(domain.getBrandName());
entity.setSlogan(domain.getSlogan());
entity.setCreatedAt(domain.getCreatedAt());
entity.setUpdatedAt(domain.getUpdatedAt());
entity.setDeletedAt(domain.getDeletedAt());
return entity;
}
}
@@ -0,0 +1,20 @@
package cn.novalon.gym.manage.db.dao;
import cn.novalon.gym.manage.db.entity.BrandConfigEntity;
import org.springframework.data.r2dbc.repository.Query;
import org.springframework.data.r2dbc.repository.R2dbcRepository;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Mono;
/**
* 品牌配置数据访问接口
*
* @author 张翔
* @date 2026-07-23
*/
@Repository
public interface BrandConfigDao extends R2dbcRepository<BrandConfigEntity, Long> {
@Query("SELECT * FROM brand_config WHERE tenant_id = $1 AND deleted_at IS NULL")
Mono<BrandConfigEntity> findByTenantIdAndDeletedAtIsNull(String tenantId);
}
@@ -0,0 +1,88 @@
package cn.novalon.gym.manage.db.entity;
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.data.relational.core.mapping.Table;
import java.time.LocalDateTime;
/**
* 品牌配置数据库实体类
*
* @author 张翔
* @date 2026-07-23
*/
@Table("brand_config")
public class BrandConfigEntity {
@Id
private Long id;
@Column("tenant_id")
private String tenantId;
@Column("logo_url")
private String logoUrl;
@Column("background_image_url")
private String backgroundImageUrl;
@Column("primary_color")
private String primaryColor;
@Column("primary_color_rgb")
private String primaryColorRgb;
@Column("secondary_color")
private String secondaryColor;
@Column("secondary_color_rgb")
private String secondaryColorRgb;
@Column("font_family")
private String fontFamily;
@Column("brand_name")
private String brandName;
@Column("slogan")
private String slogan;
@Column("created_at")
private LocalDateTime createdAt;
@Column("updated_at")
private LocalDateTime updatedAt;
@Column("deleted_at")
private LocalDateTime deletedAt;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getTenantId() { return tenantId; }
public void setTenantId(String tenantId) { this.tenantId = tenantId; }
public String getLogoUrl() { return logoUrl; }
public void setLogoUrl(String logoUrl) { this.logoUrl = logoUrl; }
public String getBackgroundImageUrl() { return backgroundImageUrl; }
public void setBackgroundImageUrl(String backgroundImageUrl) { this.backgroundImageUrl = backgroundImageUrl; }
public String getPrimaryColor() { return primaryColor; }
public void setPrimaryColor(String primaryColor) { this.primaryColor = primaryColor; }
public String getPrimaryColorRgb() { return primaryColorRgb; }
public void setPrimaryColorRgb(String primaryColorRgb) { this.primaryColorRgb = primaryColorRgb; }
public String getSecondaryColor() { return secondaryColor; }
public void setSecondaryColor(String secondaryColor) { this.secondaryColor = secondaryColor; }
public String getSecondaryColorRgb() { return secondaryColorRgb; }
public void setSecondaryColorRgb(String secondaryColorRgb) { this.secondaryColorRgb = secondaryColorRgb; }
public String getFontFamily() { return fontFamily; }
public void setFontFamily(String fontFamily) { this.fontFamily = fontFamily; }
public String getBrandName() { return brandName; }
public void setBrandName(String brandName) { this.brandName = brandName; }
public String getSlogan() { return slogan; }
public void setSlogan(String slogan) { this.slogan = slogan; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
public LocalDateTime getDeletedAt() { return deletedAt; }
public void setDeletedAt(LocalDateTime deletedAt) { this.deletedAt = deletedAt; }
}
@@ -0,0 +1,38 @@
package cn.novalon.gym.manage.db.repository;
import cn.novalon.gym.manage.brand.core.domain.BrandConfig;
import cn.novalon.gym.manage.brand.core.repository.IBrandConfigRepository;
import cn.novalon.gym.manage.db.converter.BrandConfigConverter;
import cn.novalon.gym.manage.db.dao.BrandConfigDao;
import org.springframework.stereotype.Repository;
import reactor.core.publisher.Mono;
/**
* 品牌配置仓储实现类
*
* @author 张翔
* @date 2026-07-23
*/
@Repository
public class BrandConfigRepository implements IBrandConfigRepository {
private final BrandConfigDao brandConfigDao;
private final BrandConfigConverter brandConfigConverter;
public BrandConfigRepository(BrandConfigDao brandConfigDao, BrandConfigConverter brandConfigConverter) {
this.brandConfigDao = brandConfigDao;
this.brandConfigConverter = brandConfigConverter;
}
@Override
public Mono<BrandConfig> findByTenantId(String tenantId) {
return brandConfigDao.findByTenantIdAndDeletedAtIsNull(tenantId)
.map(brandConfigConverter::toDomain);
}
@Override
public Mono<BrandConfig> save(BrandConfig brandConfig) {
return brandConfigDao.save(brandConfigConverter.toEntity(brandConfig))
.map(brandConfigConverter::toDomain);
}
}
@@ -0,0 +1,33 @@
-- ============================================
-- V25: 创建品牌配置表
-- ============================================
-- 创建 brand_config 表(租户独立品牌配置)
CREATE TABLE IF NOT EXISTS brand_config (
id BIGSERIAL PRIMARY KEY,
tenant_id VARCHAR(50) NOT NULL,
logo_url VARCHAR(512),
background_image_url VARCHAR(512),
primary_color VARCHAR(20) DEFAULT '#00E676',
primary_color_rgb VARCHAR(30),
secondary_color VARCHAR(20) DEFAULT '#1A1A1A',
secondary_color_rgb VARCHAR(30),
font_family VARCHAR(100) DEFAULT 'default',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP
);
-- 创建唯一索引:每个租户只能有一条品牌配置
CREATE UNIQUE INDEX IF NOT EXISTS idx_brand_config_tenant_id ON brand_config(tenant_id) WHERE deleted_at IS NULL;
-- 添加注释
COMMENT ON TABLE brand_config IS '品牌配置表';
COMMENT ON COLUMN brand_config.tenant_id IS '租户ID';
COMMENT ON COLUMN brand_config.logo_url IS 'Logo图片URL';
COMMENT ON COLUMN brand_config.background_image_url IS '背景图URL';
COMMENT ON COLUMN brand_config.primary_color IS '品牌主色调(HEX格式)';
COMMENT ON COLUMN brand_config.primary_color_rgb IS '品牌主色调(RGB格式)';
COMMENT ON COLUMN brand_config.secondary_color IS '品牌辅助色(HEX格式)';
COMMENT ON COLUMN brand_config.secondary_color_rgb IS '品牌辅助色(RGB格式)';
COMMENT ON COLUMN brand_config.font_family IS '字体';
@@ -0,0 +1,27 @@
-- ============================================
-- V26: 新增品牌定制菜单及按钮权限
-- ============================================
-- 品牌定制菜单(顶级菜单,parent_id=0,排序在轮播图之后)
INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at)
VALUES (29, '品牌定制', 0, 9, 'C', 'brand:config:list', 'brand/index', 1, NOW(), NOW())
ON CONFLICT (id) DO NOTHING;
-- 品牌定制按钮权限
INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES
(291, '品牌配置查询', 29, 1, 'F', 'brand:config:query', NULL, 1, NOW(), NOW()),
(292, '品牌配色修改', 29, 2, 'F', 'brand:config:edit', NULL, 1, NOW(), NOW()),
(293, 'Logo上传', 29, 3, 'F', 'brand:logo:upload', NULL, 1, NOW(), NOW()),
(294, '背景图上传', 29, 4, 'F', 'brand:bg:upload', NULL, 1, NOW(), NOW())
ON CONFLICT (id) DO NOTHING;
-- 为管理员角色(role_id=1)分配品牌定制权限
INSERT INTO sys_role_permission (role_id, permission_id)
SELECT 1, id FROM sys_permission
WHERE permission_code IN ('brand:config:list', 'brand:config:query', 'brand:config:edit', 'brand:logo:upload', 'brand:bg:upload')
AND NOT EXISTS (
SELECT 1 FROM sys_role_permission WHERE role_id = 1 AND permission_id = sys_permission.id
);
-- 重置菜单序列
SELECT setval('sys_menu_id_seq', (SELECT COALESCE(MAX(id), 1) FROM sys_menu));
@@ -0,0 +1,12 @@
-- =====================================================
-- V27: 为 brand_config 表添加品牌名和口号字段
-- 作者: 张翔
-- 日期: 2026-07-23
-- =====================================================
ALTER TABLE brand_config
ADD COLUMN IF NOT EXISTS brand_name VARCHAR(100),
ADD COLUMN IF NOT EXISTS slogan VARCHAR(200);
COMMENT ON COLUMN brand_config.brand_name IS '品牌名称';
COMMENT ON COLUMN brand_config.slogan IS '品牌口号';
@@ -4,6 +4,7 @@ import cn.novalon.gym.manage.file.core.domain.SysFile;
import cn.novalon.gym.manage.file.core.service.ISysFileService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.codec.multipart.FilePart;
import org.springframework.stereotype.Component;
@@ -21,9 +22,12 @@ import java.nio.file.Paths;
public class SysFileHandler {
private final ISysFileService fileService;
private final String uploadDir;
public SysFileHandler(ISysFileService fileService) {
public SysFileHandler(ISysFileService fileService,
@Value("${file.upload.dir:/tmp/uploads}") String uploadDir) {
this.fileService = fileService;
this.uploadDir = uploadDir;
}
@Operation(summary = "获取所有文件", description = "获取系统中所有文件列表")
@@ -88,8 +92,13 @@ public class SysFileHandler {
@Operation(summary = "根据文件名下载", description = "根据文件名下载文件")
public Mono<ServerResponse> downloadFileByName(ServerRequest request) {
String fileName = request.pathVariable("fileName");
// 支持多段路径(如 brand/logo/xxx.png),提取最后一段作为文件名
if (fileName != null && fileName.contains("/")) {
fileName = fileName.substring(fileName.lastIndexOf('/') + 1);
}
final String finalFileName = fileName;
return fileService.getAllFiles()
.filter(file -> file.getFileName().equals(fileName))
.filter(file -> file.getFileName().equals(finalFileName))
.next()
.flatMap(file -> {
try {
@@ -127,8 +136,15 @@ public class SysFileHandler {
@Operation(summary = "根据文件名预览", description = "根据文件名预览文件")
public Mono<ServerResponse> previewFileByName(ServerRequest request) {
String fileName = request.pathVariable("fileName");
// 保存原始路径,用于磁盘文件回退(如 brand/logo/xxx.png
final String originalPath = fileName;
// 支持多段路径(如 brand/logo/xxx.png),提取最后一段作为文件名
if (fileName != null && fileName.contains("/")) {
fileName = fileName.substring(fileName.lastIndexOf('/') + 1);
}
final String finalFileName = fileName;
return fileService.getAllFiles()
.filter(file -> file.getFileName().equals(fileName))
.filter(file -> file.getFileName().equals(finalFileName))
.next()
.flatMap(file -> {
try {
@@ -141,7 +157,28 @@ public class SysFileHandler {
return ServerResponse.notFound().build();
}
})
.switchIfEmpty(ServerResponse.notFound().build());
.switchIfEmpty(Mono.defer(() -> {
// 回退:从上传目录直接读取文件(用于品牌图片等未注册 sys_file 的文件)
try {
Path diskPath = Paths.get(uploadDir, originalPath);
if (Files.exists(diskPath) && !Files.isDirectory(diskPath)) {
byte[] fileContent = Files.readAllBytes(diskPath);
String contentType = "image/" + getFileExtension(originalPath);
return ServerResponse.ok()
.header("Content-Type", contentType)
.bodyValue(fileContent);
}
} catch (Exception ignored) {
}
return ServerResponse.notFound().build();
}));
}
private String getFileExtension(String filename) {
if (filename == null || !filename.contains(".")) return "jpeg";
String ext = filename.substring(filename.lastIndexOf('.') + 1).toLowerCase();
if ("jpg".equals(ext)) return "jpeg";
return ext;
}
@Operation(summary = "删除文件", description = "删除指定文件")
@@ -29,7 +29,7 @@ class SysFileHandlerTest {
@BeforeEach
void setUp() {
fileHandler = new SysFileHandler(fileService);
fileHandler = new SysFileHandler(fileService, "/tmp/uploads");
testFile = new SysFile();
testFile.setId(1L);
testFile.setFileName("test.txt");
@@ -44,11 +44,13 @@ public class JwtAuthenticationFilter extends AbstractGatewayFilterFactory<JwtAut
String username = jwtUtil.getUsernameFromToken(token);
Long userId = jwtUtil.getUserIdFromToken(token);
String tenantId = jwtUtil.getTenantIdFromToken(token);
ServerHttpRequest modifiedRequest = request.mutate()
.header("X-User-Id", String.valueOf(userId))
.header("X-Member-Id", String.valueOf(userId))
.header("X-Username", username)
.header("X-Tenant-Id", tenantId)
.build();
return chain.filter(exchange.mutate().request(modifiedRequest).build());
@@ -74,6 +74,12 @@ public class JwtUtil {
return claims.get("userId", Long.class);
}
public String getTenantIdFromToken(String token) {
Claims claims = parseToken(token);
String tenantId = claims.get("tenantId", String.class);
return tenantId != null ? tenantId : "default";
}
public boolean validateToken(String token) {
try {
parseToken(token);
@@ -67,7 +67,8 @@ public class SecurityConfig {
.pathMatchers("/api/payment/notify").permitAll()
.pathMatchers("/api/payment/refund").permitAll()
.pathMatchers("/api/files/**").permitAll()
.pathMatchers("/api/coach/**").permitAll();
.pathMatchers("/api/coach/**").permitAll()
.pathMatchers("/api/brand").permitAll();
if (isDevOrTest) {
spec.pathMatchers("/swagger-ui.html").permitAll()
@@ -31,10 +31,16 @@ public class JwtTokenProvider {
return Keys.hmacShaKeyFor(jwtProperties.getSecret().getBytes(StandardCharsets.UTF_8));
}
/**
* 默认租户ID,后续多租户化时替换为从用户上下文获取
*/
public static final String DEFAULT_TENANT_ID = "default";
public String generateToken(String username, Long userId) {
Map<String, Object> claims = new HashMap<>();
claims.put("userId", userId);
claims.put("username", username);
claims.put("tenantId", DEFAULT_TENANT_ID);
return Jwts.builder()
.setClaims(claims)
@@ -50,6 +56,7 @@ public class JwtTokenProvider {
claims.put("userId", userId);
claims.put("username", username);
claims.put("roles", roles);
claims.put("tenantId", DEFAULT_TENANT_ID);
return Jwts.builder()
.setClaims(claims)
@@ -76,6 +83,11 @@ public class JwtTokenProvider {
return getClaimsFromToken(token).get("userId", Long.class);
}
public String getTenantIdFromToken(String token) {
String tenantId = getClaimsFromToken(token).get("tenantId", String.class);
return tenantId != null ? tenantId : DEFAULT_TENANT_ID;
}
@SuppressWarnings("unchecked")
public java.util.List<String> getRolesFromToken(String token) {
Object roles = getClaimsFromToken(token).get("roles");
@@ -29,4 +29,32 @@ public class AuthUtil {
if (jwtTokenProvider.getUserIdFromToken(token) <= 0L) throw new IllegalArgumentException("ID无效");
return jwtTokenProvider.getUserIdFromToken(token);
}
/**
* 从 JWT Token 中提取当前用户的租户ID
*/
public String getTenantIdOrThrow(ServerRequest request) {
String tenantId = getTenantId(request);
if (tenantId == null || tenantId.isBlank()) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "无法获取租户ID");
}
return tenantId;
}
/**
* 从 JWT Token 或 Gateway Header 中提取租户ID(不抛异常)
*/
public String getTenantId(ServerRequest request) {
// 优先从 Gateway 转发的 Header 获取
String headerTenantId = request.headers().firstHeader("X-Tenant-Id");
if (headerTenantId != null && !headerTenantId.isBlank()) {
return headerTenantId;
}
// 回退到 JWT Token 中提取
String token = extractToken(request);
if (token != null && jwtTokenProvider.validateToken(token)) {
return jwtTokenProvider.getTenantIdFromToken(token);
}
return JwtTokenProvider.DEFAULT_TENANT_ID;
}
}
+7
View File
@@ -49,6 +49,7 @@
<module>gym-auth</module>
<module>gym-payment</module>
<module>gym-coach</module>
<module>gym-brand</module>
</modules>
<dependencyManagement>
@@ -219,6 +220,12 @@
<artifactId>commons-compress</artifactId>
<version>1.21</version>
</dependency>
<!-- Aliyun OSS SDK -->
<dependency>
<groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId>
<version>3.17.4</version>
</dependency>
</dependencies>
</dependencyManagement>