feat: 七牛云存储支持(本地缓存/备份)
- StorageService 抽象层(store/read/exists) - QiniuStorageService: 七牛云主存储(@ConditionalOnProperty) - LocalStorageService: 本地文件存储(默认/@ConditionalOnMissingBean) - CachedStorageService: 七牛主+本地缓存(写:七牛→本地, 读:本地→七牛) - 配置: PAR_STORAGE_TYPE=qiniu + QINIU_AK/SK/BUCKET/REGION - 不配七牛时自动降级到纯本地存储
This commit is contained in:
@@ -4,19 +4,13 @@ import com.par.core.dto.ApiResponse;
|
|||||||
import com.par.core.dto.SiteConfigDTO;
|
import com.par.core.dto.SiteConfigDTO;
|
||||||
import com.par.core.entity.SiteConfig;
|
import com.par.core.entity.SiteConfig;
|
||||||
import com.par.core.service.ConfigService;
|
import com.par.core.service.ConfigService;
|
||||||
|
import com.par.core.storage.StorageService;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
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.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@@ -27,13 +21,15 @@ import java.util.Map;
|
|||||||
@Slf4j
|
@Slf4j
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/configs")
|
@RequestMapping("/api/v1/configs")
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class ConfigController {
|
public class ConfigController {
|
||||||
|
|
||||||
private final ConfigService configService;
|
private final ConfigService configService;
|
||||||
|
private final StorageService storageService;
|
||||||
|
|
||||||
@Value("${par.static.path:./static}")
|
public ConfigController(ConfigService configService, StorageService storageService) {
|
||||||
private String staticPath;
|
this.configService = configService;
|
||||||
|
this.storageService = storageService;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 列出所有待审核配置
|
* 列出所有待审核配置
|
||||||
@@ -76,19 +72,18 @@ public class ConfigController {
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 读取文件内容
|
// 读取文件内容(通过存储抽象:本地缓存或七牛云)
|
||||||
Path filePath = Paths.get(staticPath, config.getStoragePath());
|
|
||||||
String content;
|
String content;
|
||||||
try {
|
try {
|
||||||
content = Files.readString(filePath, StandardCharsets.UTF_8);
|
content = storageService.read(config.getStoragePath());
|
||||||
} catch (IOException e) {
|
} catch (Exception e) {
|
||||||
log.error("Failed to read config file: {}", filePath, e);
|
log.error("Failed to read config: siteId={}, version={}", siteId, version, e);
|
||||||
return ResponseEntity.status(500).body("Failed to read config");
|
return ResponseEntity.status(500).body("Failed to read config");
|
||||||
}
|
}
|
||||||
|
|
||||||
return ResponseEntity.ok()
|
return ResponseEntity.ok()
|
||||||
.eTag(config.getConfigHash())
|
.eTag(config.getConfigHash())
|
||||||
.header(HttpHeaders.CONTENT_TYPE, "application/json")
|
.header(org.springframework.http.HttpHeaders.CONTENT_TYPE, "application/json")
|
||||||
.body(content);
|
.body(content);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,13 @@ mybatis-plus:
|
|||||||
par:
|
par:
|
||||||
static:
|
static:
|
||||||
path: ${PAR_STATIC_PATH:./data/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:
|
mediabot:
|
||||||
psk: ${PAR_MEDIABOT_PSK:}
|
psk: ${PAR_MEDIABOT_PSK:}
|
||||||
admin:
|
admin:
|
||||||
|
|||||||
@@ -89,5 +89,12 @@
|
|||||||
<artifactId>lombok</artifactId>
|
<artifactId>lombok</artifactId>
|
||||||
<optional>true</optional>
|
<optional>true</optional>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- 七牛云存储 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.qiniu</groupId>
|
||||||
|
<artifactId>qiniu-java-sdk</artifactId>
|
||||||
|
<version>7.15.0</version>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
</project>
|
</project>
|
||||||
|
|||||||
@@ -6,41 +6,28 @@ import com.par.core.entity.SiteConfig;
|
|||||||
import com.par.core.enums.ConfigStatus;
|
import com.par.core.enums.ConfigStatus;
|
||||||
import com.par.core.mapper.SiteConfigMapper;
|
import com.par.core.mapper.SiteConfigMapper;
|
||||||
import com.par.core.service.ConfigService;
|
import com.par.core.service.ConfigService;
|
||||||
import jakarta.annotation.PostConstruct;
|
import com.par.core.storage.StorageService;
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
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 org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
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.List;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
|
||||||
* 配置服务实现
|
|
||||||
*/
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class ConfigServiceImpl implements ConfigService {
|
public class ConfigServiceImpl implements ConfigService {
|
||||||
|
|
||||||
private final SiteConfigMapper siteConfigMapper;
|
private final SiteConfigMapper siteConfigMapper;
|
||||||
|
private final StorageService storageService;
|
||||||
|
|
||||||
@Value("${par.static.path:./static}")
|
public ConfigServiceImpl(SiteConfigMapper siteConfigMapper,
|
||||||
private String staticPath;
|
@Autowired(required = false) StorageService storageService) {
|
||||||
|
this.siteConfigMapper = siteConfigMapper;
|
||||||
@PostConstruct
|
// 未注入 StorageService 时(极端情况),回退到本地文件存储
|
||||||
void init() {
|
this.storageService = storageService != null ? storageService
|
||||||
try {
|
: new com.par.core.storage.LocalStorageService("./data/static");
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -72,19 +59,17 @@ public class ConfigServiceImpl implements ConfigService {
|
|||||||
@Override
|
@Override
|
||||||
public SiteConfig submitConfig(String siteId, String version, String configJson,
|
public SiteConfig submitConfig(String siteId, String version, String configJson,
|
||||||
String schemaVersion, Long submitterId) {
|
String schemaVersion, Long submitterId) {
|
||||||
// 检查版本是否已存在
|
|
||||||
SiteConfig existing = getConfig(siteId, version);
|
SiteConfig existing = getConfig(siteId, version);
|
||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
throw new IllegalArgumentException("该版本已存在: " + version);
|
throw new IllegalArgumentException("该版本已存在: " + version);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算内容哈希
|
|
||||||
String configHash = HmacUtil.sha256(configJson);
|
String configHash = HmacUtil.sha256(configJson);
|
||||||
|
String storagePath = String.format("configs/%s/%s.json", siteId, version);
|
||||||
|
|
||||||
// 存储静态文件
|
// 存储到抽象存储层(七牛或本地)
|
||||||
String storagePath;
|
|
||||||
try {
|
try {
|
||||||
storagePath = storeConfigFile(siteId, version, configJson);
|
storageService.store(storagePath, configJson);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("存储配置文件失败 siteId={} version={}: {}", siteId, version, e.getMessage());
|
log.error("存储配置文件失败 siteId={} version={}: {}", siteId, version, e.getMessage());
|
||||||
throw new RuntimeException("存储配置文件失败: " + e.getMessage(), e);
|
throw new RuntimeException("存储配置文件失败: " + e.getMessage(), e);
|
||||||
@@ -102,7 +87,6 @@ public class ConfigServiceImpl implements ConfigService {
|
|||||||
config.setIsLatest(false);
|
config.setIsLatest(false);
|
||||||
|
|
||||||
siteConfigMapper.insert(config);
|
siteConfigMapper.insert(config);
|
||||||
|
|
||||||
log.info("Config submitted: siteId={}, version={}, id={}", siteId, version, config.getId());
|
log.info("Config submitted: siteId={}, version={}, id={}", siteId, version, config.getId());
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
@@ -122,21 +106,4 @@ public class ConfigServiceImpl implements ConfigService {
|
|||||||
dto.setCreatedAt(config.getCreatedAt());
|
dto.setCreatedAt(config.getCreatedAt());
|
||||||
return dto;
|
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