feat: 七牛云存储支持(本地缓存/备份)

- StorageService 抽象层(store/read/exists)
- QiniuStorageService: 七牛云主存储(@ConditionalOnProperty)
- LocalStorageService: 本地文件存储(默认/@ConditionalOnMissingBean)
- CachedStorageService: 七牛主+本地缓存(写:七牛→本地, 读:本地→七牛)
- 配置: PAR_STORAGE_TYPE=qiniu + QINIU_AK/SK/BUCKET/REGION
- 不配七牛时自动降级到纯本地存储
This commit is contained in:
mediabot-pt
2026-06-29 22:58:32 +08:00
parent 98e040c06b
commit f351d71d18
8 changed files with 271 additions and 61 deletions

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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));
}
}

View File

@@ -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();
};
}
}

View File

@@ -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);
}