diff --git a/gym-manage-api/gym-brand/pom.xml b/gym-manage-api/gym-brand/pom.xml new file mode 100644 index 0000000..15d46af --- /dev/null +++ b/gym-manage-api/gym-brand/pom.xml @@ -0,0 +1,113 @@ + + + 4.0.0 + + + cn.novalon.gym.manage + gym-manage-api + 1.0.0 + + + gym-brand + jar + + Gym Brand + Brand Customization Module - Logo Upload, Color Settings, Real-time Preview + + + + cn.novalon.gym.manage + manage-common + ${project.version} + + + cn.novalon.gym.manage + manage-sys + ${project.version} + + + org.springframework.boot + spring-boot-starter-webflux + + + org.springdoc + springdoc-openapi-starter-webflux-ui + + + com.fasterxml.jackson.core + jackson-databind + + + org.springframework.boot + spring-boot-starter-test + test + + + io.projectreactor + reactor-test + test + + + + com.aliyun.oss + aliyun-sdk-oss + + + + + + + org.apache.maven.plugins + maven-jar-plugin + 3.4.2 + + + default-jar + package + + jar + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + 21 + 21 + + + org.projectlombok + lombok + ${lombok.version} + + + + + + org.jacoco + jacoco-maven-plugin + 0.8.12 + + + prepare-agent + + prepare-agent + + + + report + verify + + report + + + + + + + diff --git a/gym-manage-api/gym-brand/src/main/java/cn/novalon/gym/manage/brand/config/BrandWebSocketConfig.java b/gym-manage-api/gym-brand/src/main/java/cn/novalon/gym/manage/brand/config/BrandWebSocketConfig.java new file mode 100644 index 0000000..de8ac2d --- /dev/null +++ b/gym-manage-api/gym-brand/src/main/java/cn/novalon/gym/manage/brand/config/BrandWebSocketConfig.java @@ -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 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(); + } +} diff --git a/gym-manage-api/gym-brand/src/main/java/cn/novalon/gym/manage/brand/config/OssProperties.java b/gym-manage-api/gym-brand/src/main/java/cn/novalon/gym/manage/brand/config/OssProperties.java new file mode 100644 index 0000000..ab033ef --- /dev/null +++ b/gym-manage-api/gym-brand/src/main/java/cn/novalon/gym/manage/brand/config/OssProperties.java @@ -0,0 +1,105 @@ +package cn.novalon.gym.manage.brand.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * 阿里云 OSS 配置属性 + *

+ * 配置前缀: 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(); + } +} diff --git a/gym-manage-api/gym-brand/src/main/java/cn/novalon/gym/manage/brand/core/domain/BrandConfig.java b/gym-manage-api/gym-brand/src/main/java/cn/novalon/gym/manage/brand/core/domain/BrandConfig.java new file mode 100644 index 0000000..5e4d63a --- /dev/null +++ b/gym-manage-api/gym-brand/src/main/java/cn/novalon/gym/manage/brand/core/domain/BrandConfig.java @@ -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; } +} diff --git a/gym-manage-api/gym-brand/src/main/java/cn/novalon/gym/manage/brand/core/repository/IBrandConfigRepository.java b/gym-manage-api/gym-brand/src/main/java/cn/novalon/gym/manage/brand/core/repository/IBrandConfigRepository.java new file mode 100644 index 0000000..2075030 --- /dev/null +++ b/gym-manage-api/gym-brand/src/main/java/cn/novalon/gym/manage/brand/core/repository/IBrandConfigRepository.java @@ -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 findByTenantId(String tenantId); + + Mono save(BrandConfig brandConfig); +} diff --git a/gym-manage-api/gym-brand/src/main/java/cn/novalon/gym/manage/brand/core/service/FileStorageService.java b/gym-manage-api/gym-brand/src/main/java/cn/novalon/gym/manage/brand/core/service/FileStorageService.java new file mode 100644 index 0000000..858e575 --- /dev/null +++ b/gym-manage-api/gym-brand/src/main/java/cn/novalon/gym/manage/brand/core/service/FileStorageService.java @@ -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 uploadImage(FilePart filePart, String directory); + + /** + * 根据URL删除文件 + */ + Mono deleteFile(String fileUrl); +} diff --git a/gym-manage-api/gym-brand/src/main/java/cn/novalon/gym/manage/brand/core/service/IBrandConfigService.java b/gym-manage-api/gym-brand/src/main/java/cn/novalon/gym/manage/brand/core/service/IBrandConfigService.java new file mode 100644 index 0000000..b7e9028 --- /dev/null +++ b/gym-manage-api/gym-brand/src/main/java/cn/novalon/gym/manage/brand/core/service/IBrandConfigService.java @@ -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 getBrandConfig(String tenantId); + + /** + * 上传Logo + */ + Mono uploadLogo(String tenantId, FilePart filePart); + + /** + * 上传背景图 + */ + Mono uploadBackgroundImage(String tenantId, FilePart filePart); + + /** + * 更新品牌配色 + */ + Mono updateColorConfig(String tenantId, BrandConfig config); + + /** + * 删除Logo(恢复默认) + */ + Mono removeLogo(String tenantId); + + /** + * 删除背景图(恢复默认) + */ + Mono removeBackgroundImage(String tenantId); + + /** + * 更新品牌信息(名称、口号) + */ + Mono updateBrandInfo(String tenantId, BrandConfig config); +} diff --git a/gym-manage-api/gym-brand/src/main/java/cn/novalon/gym/manage/brand/core/service/impl/BrandConfigServiceImpl.java b/gym-manage-api/gym-brand/src/main/java/cn/novalon/gym/manage/brand/core/service/impl/BrandConfigServiceImpl.java new file mode 100644 index 0000000..385f759 --- /dev/null +++ b/gym-manage-api/gym-brand/src/main/java/cn/novalon/gym/manage/brand/core/service/impl/BrandConfigServiceImpl.java @@ -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 getBrandConfig(String tenantId) { + return brandConfigRepository.findByTenantId(tenantId) + .switchIfEmpty(Mono.defer(() -> createDefaultConfig(tenantId))); + } + + @Override + public Mono 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 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 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 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 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 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 getOrCreateConfig(String tenantId) { + return brandConfigRepository.findByTenantId(tenantId) + .switchIfEmpty(Mono.defer(() -> createDefaultConfig(tenantId))); + } + + /** + * 为租户创建默认品牌配置 + */ + private Mono 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()); + } + } +} diff --git a/gym-manage-api/gym-brand/src/main/java/cn/novalon/gym/manage/brand/core/service/impl/DualFileStorageService.java b/gym-manage-api/gym-brand/src/main/java/cn/novalon/gym/manage/brand/core/service/impl/DualFileStorageService.java new file mode 100644 index 0000000..f6259c3 --- /dev/null +++ b/gym-manage-api/gym-brand/src/main/java/cn/novalon/gym/manage/brand/core/service/impl/DualFileStorageService.java @@ -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 优先 + 本地兜底) + *

+ * 作为 FileStorageService 的 @Primary 实现,编排 OSS 和本地存储: + *

+ * 当 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 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 deleteFile(String fileUrl) { + if (fileUrl == null || fileUrl.isBlank()) { + return Mono.empty(); + } + + // 本地文件总是尝试删除 + Mono localDelete = localStorage.deleteFile(fileUrl); + + if (ossProperties.isConfigured()) { + // OSS 删除:忽略失败(尽力而为) + return ossStorage.deleteFile(fileUrl) + .onErrorResume(e -> Mono.empty()) + .then(localDelete); + } + + return localDelete; + } +} diff --git a/gym-manage-api/gym-brand/src/main/java/cn/novalon/gym/manage/brand/core/service/impl/LocalFileStorageService.java b/gym-manage-api/gym-brand/src/main/java/cn/novalon/gym/manage/brand/core/service/impl/LocalFileStorageService.java new file mode 100644 index 0000000..93b92e3 --- /dev/null +++ b/gym-manage-api/gym-brand/src/main/java/cn/novalon/gym/manage/brand/core/service/impl/LocalFileStorageService.java @@ -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; + +/** + * 本地文件存储服务(兜底实现) + *

+ * 当 OSS 不可用时,文件存储到服务器本地磁盘。 + * 文件访问通过 /api/files/preview/ 路径提供。 + * + * @author 张翔 + * @date 2026-07-23 + */ +@Service("localFileStorage") +public class LocalFileStorageService implements FileStorageService { + + private static final Set 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 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 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; + } +} diff --git a/gym-manage-api/gym-brand/src/main/java/cn/novalon/gym/manage/brand/core/service/impl/OssFileStorageService.java b/gym-manage-api/gym-brand/src/main/java/cn/novalon/gym/manage/brand/core/service/impl/OssFileStorageService.java new file mode 100644 index 0000000..6c156df --- /dev/null +++ b/gym-manage-api/gym-brand/src/main/java/cn/novalon/gym/manage/brand/core/service/impl/OssFileStorageService.java @@ -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 文件存储服务 + *

+ * 当 brand.oss.enabled=true 且配置完整时启用, + * 将品牌图片上传至阿里云 OSS。 + * + * @author 张翔 + * @date 2026-07-23 + */ +@Service("ossFileStorage") +public class OssFileStorageService implements FileStorageService { + + private static final Set 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 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 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(".")); + } +} diff --git a/gym-manage-api/gym-brand/src/main/java/cn/novalon/gym/manage/brand/handler/BrandConfigHandler.java b/gym-manage-api/gym-brand/src/main/java/cn/novalon/gym/manage/brand/handler/BrandConfigHandler.java new file mode 100644 index 0000000..5318af8 --- /dev/null +++ b/gym-manage-api/gym-brand/src/main/java/cn/novalon/gym/manage/brand/handler/BrandConfigHandler.java @@ -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 + *

+ * 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 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 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 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 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 removeLogo(ServerRequest request) { + String tenantId = authUtil.getTenantId(request); + return brandConfigService.removeLogo(tenantId) + .flatMap(config -> ServerResponse.ok().bodyValue(config)); + } + + @Operation(summary = "删除背景图", description = "删除品牌背景图,恢复默认") + public Mono removeBackgroundImage(ServerRequest request) { + String tenantId = authUtil.getTenantId(request); + return brandConfigService.removeBackgroundImage(tenantId) + .flatMap(config -> ServerResponse.ok().bodyValue(config)); + } + + @Operation(summary = "更新品牌信息", description = "设置品牌名称和口号") + public Mono 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 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"); + } + } +} diff --git a/gym-manage-api/gym-brand/src/main/java/cn/novalon/gym/manage/brand/websocket/BrandWebSocketHandler.java b/gym-manage-api/gym-brand/src/main/java/cn/novalon/gym/manage/brand/websocket/BrandWebSocketHandler.java new file mode 100644 index 0000000..f2745d0 --- /dev/null +++ b/gym-manage-api/gym-brand/src/main/java/cn/novalon/gym/manage/brand/websocket/BrandWebSocketHandler.java @@ -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 sessions = new ConcurrentHashMap<>(); + private final Map 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 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 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 + *

    + *
  1. 优先从 Authorization Header 的 JWT Token 中提取
  2. + *
  3. 回退到 URL 查询参数 tenantId
  4. + *
  5. 兜底使用 session ID
  6. + *
+ */ + 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 message = objectMapper.readValue(payload, + new TypeReference>() {}); + 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()); + } + } +} diff --git a/gym-manage-api/gym-brand/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/gym-manage-api/gym-brand/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..5ff6860 --- /dev/null +++ b/gym-manage-api/gym-brand/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +cn.novalon.gym.manage.brand.config.BrandWebSocketConfig diff --git a/gym-manage-api/gym-brand/src/test/java/cn/novalon/gym/manage/brand/core/service/impl/BrandConfigServiceImplTest.java b/gym-manage-api/gym-brand/src/test/java/cn/novalon/gym/manage/brand/core/service/impl/BrandConfigServiceImplTest.java new file mode 100644 index 0000000..b1395a8 --- /dev/null +++ b/gym-manage-api/gym-brand/src/test/java/cn/novalon/gym/manage/brand/core/service/impl/BrandConfigServiceImplTest.java @@ -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 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 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 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 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 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 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 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 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 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 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; + } +} diff --git a/gym-manage-api/gym-brand/src/test/java/cn/novalon/gym/manage/brand/core/service/impl/DualFileStorageServiceTest.java b/gym-manage-api/gym-brand/src/test/java/cn/novalon/gym/manage/brand/core/service/impl/DualFileStorageServiceTest.java new file mode 100644 index 0000000..82c1f37 --- /dev/null +++ b/gym-manage-api/gym-brand/src/test/java/cn/novalon/gym/manage/brand/core/service/impl/DualFileStorageServiceTest.java @@ -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 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 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 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 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 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 result = dualService.deleteFile(fileUrl); + + StepVerifier.create(result).verifyComplete(); + + verify(ossStorage).deleteFile(fileUrl); + verify(localStorage).deleteFile(fileUrl); + } + + // ==================== 边界情况 ==================== + + @Test + void deleteFile_shouldHandleNullUrl() { + Mono result = dualService.deleteFile(null); + + StepVerifier.create(result).verifyComplete(); + + verify(localStorage, never()).deleteFile(any()); + verify(ossStorage, never()).deleteFile(any()); + } + + @Test + void deleteFile_shouldHandleEmptyUrl() { + Mono 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"); + } +} diff --git a/gym-manage-api/gym-brand/src/test/java/cn/novalon/gym/manage/brand/core/service/impl/LocalFileStorageServiceTest.java b/gym-manage-api/gym-brand/src/test/java/cn/novalon/gym/manage/brand/core/service/impl/LocalFileStorageServiceTest.java new file mode 100644 index 0000000..f58304d --- /dev/null +++ b/gym-manage-api/gym-brand/src/test/java/cn/novalon/gym/manage/brand/core/service/impl/LocalFileStorageServiceTest.java @@ -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 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 result = localStorageService.uploadImage(filePart, "logo"); + + StepVerifier.create(result) + .expectError(IllegalArgumentException.class) + .verify(); + } + + @Test + void uploadImage_shouldRejectTxtFormat() { + when(filePart.filename()).thenReturn("script.txt"); + + Mono result = localStorageService.uploadImage(filePart, "logo"); + + StepVerifier.create(result) + .expectError(IllegalArgumentException.class) + .verify(); + } + + @Test + void uploadImage_shouldRejectBatchFormat() { + when(filePart.filename()).thenReturn("file.bat"); + + Mono 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 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 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 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 result = localStorageService.uploadImage(filePart, "logo"); + + StepVerifier.create(result) + .expectError(RuntimeException.class) + .verify(); + } + + @Test + void deleteFile_shouldNotThrowForNullUrl() { + Mono result = localStorageService.deleteFile(null); + + StepVerifier.create(result) + .verifyComplete(); + } + + @Test + void deleteFile_shouldNotThrowForEmptyUrl() { + Mono result = localStorageService.deleteFile(""); + + StepVerifier.create(result) + .verifyComplete(); + } + + @Test + void deleteFile_shouldNotThrowForNonExistentFile() { + Mono result = localStorageService.deleteFile( + "http://localhost:8080/api/files/preview/brand/logo/nonexistent.png"); + + StepVerifier.create(result) + .verifyComplete(); + } +} diff --git a/gym-manage-api/gym-brand/src/test/java/cn/novalon/gym/manage/brand/handler/BrandConfigHandlerTest.java b/gym-manage-api/gym-brand/src/test/java/cn/novalon/gym/manage/brand/handler/BrandConfigHandlerTest.java new file mode 100644 index 0000000..c1ab2e8 --- /dev/null +++ b/gym-manage-api/gym-brand/src/test/java/cn/novalon/gym/manage/brand/handler/BrandConfigHandlerTest.java @@ -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 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 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 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 result = brandConfigHandler.updateColorConfig(request); + + StepVerifier.create(result) + .assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK)) + .verifyComplete(); + } + + @Test + void updateColorConfig_shouldRejectInvalidHex() { + Map requestBody = Map.of("primaryColor", "INVALID"); + + when(authUtil.getTenantId(any())).thenReturn(TENANT_ID); + + MockServerRequest request = mockRequest() + .body(Mono.just(requestBody)); + Mono 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 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 result = brandConfigHandler.updateColorConfig(request); + + StepVerifier.create(result) + .assertNext(response -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK)) + .verifyComplete(); + } + + @Test + void updateColorConfig_shouldRejectInvalidHexInSecondary() { + Map requestBody = Map.of("secondaryColor", "not-a-color"); + + when(authUtil.getTenantId(any())).thenReturn(TENANT_ID); + + MockServerRequest request = mockRequest() + .body(Mono.just(requestBody)); + Mono 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 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 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; + } +} diff --git a/gym-manage-api/gym-brand/src/test/java/cn/novalon/gym/manage/brand/websocket/BrandWebSocketHandlerTest.java b/gym-manage-api/gym-brand/src/test/java/cn/novalon/gym/manage/brand/websocket/BrandWebSocketHandlerTest.java new file mode 100644 index 0000000..fe1ab49 --- /dev/null +++ b/gym-manage-api/gym-brand/src/test/java/cn/novalon/gym/manage/brand/websocket/BrandWebSocketHandlerTest.java @@ -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 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 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 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 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 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; + } +} diff --git a/gym-manage-api/manage-app/pom.xml b/gym-manage-api/manage-app/pom.xml index 5afc9c2..fb789f0 100644 --- a/gym-manage-api/manage-app/pom.xml +++ b/gym-manage-api/manage-app/pom.xml @@ -175,6 +175,12 @@ 1.0.0 compile + + cn.novalon.gym.manage + gym-brand + 1.0.0 + compile + diff --git a/gym-manage-api/manage-app/src/main/java/cn/novalon/gym/manage/app/config/SystemRouter.java b/gym-manage-api/manage-app/src/main/java/cn/novalon/gym/manage/app/config/SystemRouter.java index 188f256..9835f81 100644 --- a/gym-manage-api/manage-app/src/main/java/cn/novalon/gym/manage/app/config/SystemRouter.java +++ b/gym-manage-api/manage-app/src/main/java/cn/novalon/gym/manage/app/config/SystemRouter.java @@ -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(); } } diff --git a/gym-manage-api/manage-db/pom.xml b/gym-manage-api/manage-db/pom.xml index 2c54f5b..122f9f9 100644 --- a/gym-manage-api/manage-db/pom.xml +++ b/gym-manage-api/manage-db/pom.xml @@ -27,6 +27,11 @@ manage-notify ${project.version} + + cn.novalon.gym.manage + gym-brand + ${project.version} + cn.novalon.gym.manage manage-file diff --git a/gym-manage-api/manage-db/src/main/java/cn/novalon/gym/manage/db/converter/BrandConfigConverter.java b/gym-manage-api/manage-db/src/main/java/cn/novalon/gym/manage/db/converter/BrandConfigConverter.java new file mode 100644 index 0000000..44ffabf --- /dev/null +++ b/gym-manage-api/manage-db/src/main/java/cn/novalon/gym/manage/db/converter/BrandConfigConverter.java @@ -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; + } +} diff --git a/gym-manage-api/manage-db/src/main/java/cn/novalon/gym/manage/db/dao/BrandConfigDao.java b/gym-manage-api/manage-db/src/main/java/cn/novalon/gym/manage/db/dao/BrandConfigDao.java new file mode 100644 index 0000000..23e9026 --- /dev/null +++ b/gym-manage-api/manage-db/src/main/java/cn/novalon/gym/manage/db/dao/BrandConfigDao.java @@ -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 { + + @Query("SELECT * FROM brand_config WHERE tenant_id = $1 AND deleted_at IS NULL") + Mono findByTenantIdAndDeletedAtIsNull(String tenantId); +} diff --git a/gym-manage-api/manage-db/src/main/java/cn/novalon/gym/manage/db/entity/BrandConfigEntity.java b/gym-manage-api/manage-db/src/main/java/cn/novalon/gym/manage/db/entity/BrandConfigEntity.java new file mode 100644 index 0000000..c0ea5da --- /dev/null +++ b/gym-manage-api/manage-db/src/main/java/cn/novalon/gym/manage/db/entity/BrandConfigEntity.java @@ -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; } +} diff --git a/gym-manage-api/manage-db/src/main/java/cn/novalon/gym/manage/db/repository/BrandConfigRepository.java b/gym-manage-api/manage-db/src/main/java/cn/novalon/gym/manage/db/repository/BrandConfigRepository.java new file mode 100644 index 0000000..cf62204 --- /dev/null +++ b/gym-manage-api/manage-db/src/main/java/cn/novalon/gym/manage/db/repository/BrandConfigRepository.java @@ -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 findByTenantId(String tenantId) { + return brandConfigDao.findByTenantIdAndDeletedAtIsNull(tenantId) + .map(brandConfigConverter::toDomain); + } + + @Override + public Mono save(BrandConfig brandConfig) { + return brandConfigDao.save(brandConfigConverter.toEntity(brandConfig)) + .map(brandConfigConverter::toDomain); + } +} diff --git a/gym-manage-api/manage-db/src/main/resources/db/migration/V25__Create_brand_config.sql b/gym-manage-api/manage-db/src/main/resources/db/migration/V25__Create_brand_config.sql new file mode 100644 index 0000000..7f59ca5 --- /dev/null +++ b/gym-manage-api/manage-db/src/main/resources/db/migration/V25__Create_brand_config.sql @@ -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 '字体'; diff --git a/gym-manage-api/manage-db/src/main/resources/db/migration/V26__Insert_brand_menu.sql b/gym-manage-api/manage-db/src/main/resources/db/migration/V26__Insert_brand_menu.sql new file mode 100644 index 0000000..5ac0630 --- /dev/null +++ b/gym-manage-api/manage-db/src/main/resources/db/migration/V26__Insert_brand_menu.sql @@ -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)); diff --git a/gym-manage-api/manage-db/src/main/resources/db/migration/V27__Add_brand_name_slogan.sql b/gym-manage-api/manage-db/src/main/resources/db/migration/V27__Add_brand_name_slogan.sql new file mode 100644 index 0000000..6fa14a4 --- /dev/null +++ b/gym-manage-api/manage-db/src/main/resources/db/migration/V27__Add_brand_name_slogan.sql @@ -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 '品牌口号'; diff --git a/gym-manage-api/manage-file/src/main/java/cn/novalon/gym/manage/file/handler/SysFileHandler.java b/gym-manage-api/manage-file/src/main/java/cn/novalon/gym/manage/file/handler/SysFileHandler.java index a452fed..fe7ce1e 100644 --- a/gym-manage-api/manage-file/src/main/java/cn/novalon/gym/manage/file/handler/SysFileHandler.java +++ b/gym-manage-api/manage-file/src/main/java/cn/novalon/gym/manage/file/handler/SysFileHandler.java @@ -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 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 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 = "删除指定文件") diff --git a/gym-manage-api/manage-file/src/test/java/cn/novalon/gym/manage/file/handler/SysFileHandlerTest.java b/gym-manage-api/manage-file/src/test/java/cn/novalon/gym/manage/file/handler/SysFileHandlerTest.java index 28e76d3..f12e2be 100644 --- a/gym-manage-api/manage-file/src/test/java/cn/novalon/gym/manage/file/handler/SysFileHandlerTest.java +++ b/gym-manage-api/manage-file/src/test/java/cn/novalon/gym/manage/file/handler/SysFileHandlerTest.java @@ -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"); diff --git a/gym-manage-api/manage-gateway/src/main/java/cn/novalon/gym/manage/gateway/filter/JwtAuthenticationFilter.java b/gym-manage-api/manage-gateway/src/main/java/cn/novalon/gym/manage/gateway/filter/JwtAuthenticationFilter.java index 501026b..fd08003 100644 --- a/gym-manage-api/manage-gateway/src/main/java/cn/novalon/gym/manage/gateway/filter/JwtAuthenticationFilter.java +++ b/gym-manage-api/manage-gateway/src/main/java/cn/novalon/gym/manage/gateway/filter/JwtAuthenticationFilter.java @@ -44,11 +44,13 @@ public class JwtAuthenticationFilter extends AbstractGatewayFilterFactory 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 getRolesFromToken(String token) { Object roles = getClaimsFromToken(token).get("roles"); diff --git a/gym-manage-api/manage-sys/src/main/java/cn/novalon/gym/manage/sys/util/AuthUtil.java b/gym-manage-api/manage-sys/src/main/java/cn/novalon/gym/manage/sys/util/AuthUtil.java index 14c5531..67cc6ee 100644 --- a/gym-manage-api/manage-sys/src/main/java/cn/novalon/gym/manage/sys/util/AuthUtil.java +++ b/gym-manage-api/manage-sys/src/main/java/cn/novalon/gym/manage/sys/util/AuthUtil.java @@ -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; + } } \ No newline at end of file diff --git a/gym-manage-api/pom.xml b/gym-manage-api/pom.xml index 9df6ca5..83de77b 100644 --- a/gym-manage-api/pom.xml +++ b/gym-manage-api/pom.xml @@ -49,6 +49,7 @@ gym-auth gym-payment gym-coach + gym-brand @@ -219,6 +220,12 @@ commons-compress 1.21 + + + com.aliyun.oss + aliyun-sdk-oss + 3.17.4 + diff --git a/gym-manage-coach-uniapp/App.vue b/gym-manage-coach-uniapp/App.vue index 64dae1b..bdf7b91 100644 --- a/gym-manage-coach-uniapp/App.vue +++ b/gym-manage-coach-uniapp/App.vue @@ -1,10 +1,14 @@ diff --git a/gym-manage-uniapp/pages/search/search.vue b/gym-manage-uniapp/pages/search/search.vue index 6876b29..2b93190 100644 --- a/gym-manage-uniapp/pages/search/search.vue +++ b/gym-manage-uniapp/pages/search/search.vue @@ -1,10 +1,10 @@