feat: 七牛云存储支持(本地缓存/备份)
- StorageService 抽象层(store/read/exists) - QiniuStorageService: 七牛云主存储(@ConditionalOnProperty) - LocalStorageService: 本地文件存储(默认/@ConditionalOnMissingBean) - CachedStorageService: 七牛主+本地缓存(写:七牛→本地, 读:本地→七牛) - 配置: PAR_STORAGE_TYPE=qiniu + QINIU_AK/SK/BUCKET/REGION - 不配七牛时自动降级到纯本地存储
This commit is contained in:
@@ -89,5 +89,12 @@
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<!-- 七牛云存储 -->
|
||||
<dependency>
|
||||
<groupId>com.qiniu</groupId>
|
||||
<artifactId>qiniu-java-sdk</artifactId>
|
||||
<version>7.15.0</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
* 缓存存储:七牛(主) + 本地(读缓存/备份)。
|
||||
* <p>
|
||||
* 写:先写七牛,成功后再缓存到本地
|
||||
* 读:本地缓存命中 → 返回;未命中 → 七牛拉取 → 缓存本地 → 返回
|
||||
*/
|
||||
@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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user