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,失败则回退到本地存储
+ * - 删除:同时删除 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
+ *
+ * - 优先从 Authorization Header 的 JWT Token 中提取
+ * - 回退到 URL 查询参数 tenantId
+ * - 兜底使用 session ID
+ *
+ */
+ 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