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 @@
+ * 写:先写七牛,成功后再缓存到本地 + * 读:本地缓存命中 → 返回;未命中 → 七牛拉取 → 缓存本地 → 返回 + */ +@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); +}