diff --git a/par-api/src/main/java/com/par/api/controller/ConfigController.java b/par-api/src/main/java/com/par/api/controller/ConfigController.java index 94c2d2b..b7a34aa 100644 --- a/par-api/src/main/java/com/par/api/controller/ConfigController.java +++ b/par-api/src/main/java/com/par/api/controller/ConfigController.java @@ -4,19 +4,13 @@ import com.par.core.dto.ApiResponse; import com.par.core.dto.SiteConfigDTO; import com.par.core.entity.SiteConfig; import com.par.core.service.ConfigService; +import com.par.core.storage.StorageService; import jakarta.servlet.http.HttpServletRequest; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; import java.util.List; import java.util.Map; @@ -27,13 +21,15 @@ import java.util.Map; @Slf4j @RestController @RequestMapping("/api/v1/configs") -@RequiredArgsConstructor public class ConfigController { private final ConfigService configService; + private final StorageService storageService; - @Value("${par.static.path:./static}") - private String staticPath; + public ConfigController(ConfigService configService, StorageService storageService) { + this.configService = configService; + this.storageService = storageService; + } /** * 列出所有待审核配置 @@ -76,19 +72,18 @@ public class ConfigController { .build(); } - // 读取文件内容 - Path filePath = Paths.get(staticPath, config.getStoragePath()); + // 读取文件内容(通过存储抽象:本地缓存或七牛云) String content; try { - content = Files.readString(filePath, StandardCharsets.UTF_8); - } catch (IOException e) { - log.error("Failed to read config file: {}", filePath, e); + content = storageService.read(config.getStoragePath()); + } catch (Exception e) { + log.error("Failed to read config: siteId={}, version={}", siteId, version, e); return ResponseEntity.status(500).body("Failed to read config"); } return ResponseEntity.ok() .eTag(config.getConfigHash()) - .header(HttpHeaders.CONTENT_TYPE, "application/json") + .header(org.springframework.http.HttpHeaders.CONTENT_TYPE, "application/json") .body(content); } diff --git a/par-api/src/main/resources/application.yml b/par-api/src/main/resources/application.yml index 825d175..0f3b356 100644 --- a/par-api/src/main/resources/application.yml +++ b/par-api/src/main/resources/application.yml @@ -46,6 +46,13 @@ mybatis-plus: par: static: path: ${PAR_STATIC_PATH:./data/static} + storage: + type: ${PAR_STORAGE_TYPE:local} # qiniu / local + qiniu: + access-key: ${QINIU_AK:} + secret-key: ${QINIU_SK:} + bucket: ${QINIU_BUCKET:par-configs} + region: ${QINIU_REGION:z2} mediabot: psk: ${PAR_MEDIABOT_PSK:} admin: diff --git a/par-core/pom.xml b/par-core/pom.xml index e904cb7..4dba60e 100644 --- a/par-core/pom.xml +++ b/par-core/pom.xml @@ -89,5 +89,12 @@ lombok true + + + + com.qiniu + qiniu-java-sdk + 7.15.0 + diff --git a/par-core/src/main/java/com/par/core/service/impl/ConfigServiceImpl.java b/par-core/src/main/java/com/par/core/service/impl/ConfigServiceImpl.java index 19a5104..a55f1cc 100644 --- a/par-core/src/main/java/com/par/core/service/impl/ConfigServiceImpl.java +++ b/par-core/src/main/java/com/par/core/service/impl/ConfigServiceImpl.java @@ -6,41 +6,28 @@ import com.par.core.entity.SiteConfig; import com.par.core.enums.ConfigStatus; import com.par.core.mapper.SiteConfigMapper; import com.par.core.service.ConfigService; -import jakarta.annotation.PostConstruct; -import lombok.RequiredArgsConstructor; +import com.par.core.storage.StorageService; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; -import java.io.IOException; import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; import java.util.List; import java.util.stream.Collectors; -/** - * 配置服务实现 - */ @Slf4j @Service -@RequiredArgsConstructor public class ConfigServiceImpl implements ConfigService { private final SiteConfigMapper siteConfigMapper; + private final StorageService storageService; - @Value("${par.static.path:./static}") - private String staticPath; - - @PostConstruct - void init() { - try { - Files.createDirectories(Paths.get(staticPath, "configs")); - log.info("Config static dir ready: {}", Paths.get(staticPath, "configs").toAbsolutePath()); - } catch (IOException e) { - log.error("Failed to create config static dir: {}", e.getMessage()); - } + public ConfigServiceImpl(SiteConfigMapper siteConfigMapper, + @Autowired(required = false) StorageService storageService) { + this.siteConfigMapper = siteConfigMapper; + // 未注入 StorageService 时(极端情况),回退到本地文件存储 + this.storageService = storageService != null ? storageService + : new com.par.core.storage.LocalStorageService("./data/static"); } @Override @@ -72,19 +59,17 @@ public class ConfigServiceImpl implements ConfigService { @Override public SiteConfig submitConfig(String siteId, String version, String configJson, String schemaVersion, Long submitterId) { - // 检查版本是否已存在 SiteConfig existing = getConfig(siteId, version); if (existing != null) { throw new IllegalArgumentException("该版本已存在: " + version); } - // 计算内容哈希 String configHash = HmacUtil.sha256(configJson); + String storagePath = String.format("configs/%s/%s.json", siteId, version); - // 存储静态文件 - String storagePath; + // 存储到抽象存储层(七牛或本地) try { - storagePath = storeConfigFile(siteId, version, configJson); + storageService.store(storagePath, configJson); } catch (Exception e) { log.error("存储配置文件失败 siteId={} version={}: {}", siteId, version, e.getMessage()); throw new RuntimeException("存储配置文件失败: " + e.getMessage(), e); @@ -102,7 +87,6 @@ public class ConfigServiceImpl implements ConfigService { config.setIsLatest(false); siteConfigMapper.insert(config); - log.info("Config submitted: siteId={}, version={}, id={}", siteId, version, config.getId()); return config; } @@ -122,21 +106,4 @@ public class ConfigServiceImpl implements ConfigService { dto.setCreatedAt(config.getCreatedAt()); return dto; } - - /** - * 存储配置静态文件 - */ - private String storeConfigFile(String siteId, String version, String content) { - String relativePath = String.format("configs/%s/%s.json", siteId, version); - Path filePath = Paths.get(staticPath, relativePath); - - try { - Files.createDirectories(filePath.getParent()); - Files.writeString(filePath, content, StandardCharsets.UTF_8); - } catch (IOException e) { - throw new RuntimeException("Failed to store config file: " + relativePath, e); - } - - return relativePath; - } } diff --git a/par-core/src/main/java/com/par/core/storage/CachedStorageService.java b/par-core/src/main/java/com/par/core/storage/CachedStorageService.java new file mode 100644 index 0000000..110f474 --- /dev/null +++ b/par-core/src/main/java/com/par/core/storage/CachedStorageService.java @@ -0,0 +1,61 @@ +package com.par.core.storage; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.stereotype.Service; + +/** + * 缓存存储:七牛(主) + 本地(读缓存/备份)。 + *

+ * 写:先写七牛,成功后再缓存到本地 + * 读:本地缓存命中 → 返回;未命中 → 七牛拉取 → 缓存本地 → 返回 + */ +@Slf4j +@Service +@ConditionalOnBean(QiniuStorageService.class) +public class CachedStorageService implements StorageService { + + private final QiniuStorageService primary; + private final StorageService cache; + + public CachedStorageService(QiniuStorageService primary, LocalStorageService cache) { + this.primary = primary; + this.cache = cache; + log.info("缓存存储就绪: 七牛(主) + 本地(缓存)"); + } + + @Override + public void store(String path, String content) { + // 先写七牛 + primary.store(path, content); + // 再写本地缓存(失败不影响) + try { + cache.store(path, content); + } catch (Exception e) { + log.warn("写本地缓存失败 {}: {}", path, e.getMessage()); + } + } + + @Override + public String read(String path) { + // 本地缓存命中 + String cached = cache.read(path); + if (cached != null) return cached; + + // 从七牛拉取 + String remote = primary.read(path); + if (remote != null) { + // 回写本地缓存 + try { + cache.store(path, remote); + } catch (Exception ignored) {} + return remote; + } + return null; + } + + @Override + public boolean exists(String path) { + return cache.exists(path) || primary.exists(path); + } +} diff --git a/par-core/src/main/java/com/par/core/storage/LocalStorageService.java b/par-core/src/main/java/com/par/core/storage/LocalStorageService.java new file mode 100644 index 0000000..4f8a74e --- /dev/null +++ b/par-core/src/main/java/com/par/core/storage/LocalStorageService.java @@ -0,0 +1,68 @@ +package com.par.core.storage; + +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +/** + * 本地文件存储。 + * 当 par.storage.type != qiniu 时使用,或作为七牛不可用时的降级。 + */ +@Slf4j +@Service +@ConditionalOnMissingBean(QiniuStorageService.class) +public class LocalStorageService implements StorageService { + + private final Path baseDir; + + public LocalStorageService(@Value("${par.static.path:./data/static}") String basePath) { + this.baseDir = Paths.get(basePath); + } + + @PostConstruct + void init() { + try { + Files.createDirectories(baseDir); + log.info("本地存储就绪: {}", baseDir.toAbsolutePath()); + } catch (IOException e) { + log.error("创建存储目录失败: {}", e.getMessage()); + } + } + + @Override + public void store(String path, String content) { + try { + Path file = baseDir.resolve(path); + Files.createDirectories(file.getParent()); + Files.writeString(file, content, StandardCharsets.UTF_8); + } catch (IOException e) { + throw new RuntimeException("Failed to store file: " + path, e); + } + } + + @Override + public String read(String path) { + try { + Path file = baseDir.resolve(path); + if (Files.exists(file)) { + return Files.readString(file, StandardCharsets.UTF_8); + } + } catch (IOException e) { + log.warn("读取文件失败 {}: {}", path, e.getMessage()); + } + return null; + } + + @Override + public boolean exists(String path) { + return Files.exists(baseDir.resolve(path)); + } +} diff --git a/par-core/src/main/java/com/par/core/storage/QiniuStorageService.java b/par-core/src/main/java/com/par/core/storage/QiniuStorageService.java new file mode 100644 index 0000000..e28b4e7 --- /dev/null +++ b/par-core/src/main/java/com/par/core/storage/QiniuStorageService.java @@ -0,0 +1,87 @@ +package com.par.core.storage; + +import com.qiniu.common.QiniuException; +import com.qiniu.storage.BucketManager; +import com.qiniu.storage.Configuration; +import com.qiniu.storage.Region; +import com.qiniu.storage.UploadManager; +import com.qiniu.util.Auth; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; + +/** + * 七牛云存储(主存储)。 + */ +@Slf4j +@Service +@ConditionalOnProperty(name = "par.storage.type", havingValue = "qiniu") +public class QiniuStorageService implements StorageService { + + private final UploadManager uploadManager; + private final BucketManager bucketManager; + private final Auth auth; + private final String bucket; + + public QiniuStorageService( + @Value("${par.storage.qiniu.access-key:}") String ak, + @Value("${par.storage.qiniu.secret-key:}") String sk, + @Value("${par.storage.qiniu.bucket:par-configs}") String bucket, + @Value("${par.storage.qiniu.region:z2}") String region) { + + this.bucket = bucket; + this.auth = Auth.create(ak, sk); + + Configuration cfg = new Configuration(getRegion(region)); + this.uploadManager = new UploadManager(cfg); + this.bucketManager = new BucketManager(auth, cfg); + + log.info("Qiniu 存储就绪: bucket={}, region={}", bucket, region); + } + + @Override + public void store(String path, String content) { + try { + var resp = uploadManager.put(content.getBytes(), path, auth.uploadToken(bucket)); + log.debug("Qiniu 上传: {} (hash={})", path, resp.hash); + } catch (QiniuException e) { + log.error("Qiniu 上传失败 {}: {}", path, e.response != null ? e.response.error : e.getMessage()); + throw new RuntimeException("Qiniu upload failed: " + path, e); + } + } + + @Override + public String read(String path) { + try { + var resp = bucketManager.get(bucket, path, null, null); + if (resp != null && resp.body() != null) { + return new String(resp.body()); + } + } catch (QiniuException e) { + log.warn("Qiniu 读取失败 {}: {}", path, e.response != null ? e.response.error : e.getMessage()); + } + return null; + } + + @Override + public boolean exists(String path) { + try { + var stat = bucketManager.stat(bucket, path); + return stat != null; + } catch (QiniuException e) { + return false; + } + } + + private Region getRegion(String region) { + return switch (region) { + case "z0" -> Region.region0(); // 华东 + case "z1" -> Region.region1(); // 华北 + case "z2" -> Region.region2(); // 华南 + case "na0" -> Region.regionNa0(); // 北美 + case "as0" -> Region.regionAs0(); // 东南亚 + default -> Region.autoRegion(); + }; + } +} diff --git a/par-core/src/main/java/com/par/core/storage/StorageService.java b/par-core/src/main/java/com/par/core/storage/StorageService.java new file mode 100644 index 0000000..875f06c --- /dev/null +++ b/par-core/src/main/java/com/par/core/storage/StorageService.java @@ -0,0 +1,18 @@ +package com.par.core.storage; + +/** + * 配置内容存储抽象。 + * 写:七牛云(主) → 本地(缓存/备份) + * 读:本地缓存命中 → 返回;未命中 → 七牛云拉取 → 缓存本地 → 返回 + */ +public interface StorageService { + + /** 存储内容 */ + void store(String path, String content); + + /** 读取内容 */ + String read(String path); + + /** 检查是否存在 */ + boolean exists(String path); +}