diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/storage/aliyunoss/service/impl/LocalReplayObjectStorageService.java b/server/src/main/java/cn/nianxx/thhotel/integrations/storage/aliyunoss/service/impl/LocalReplayObjectStorageService.java new file mode 100644 index 0000000..f15469b --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/storage/aliyunoss/service/impl/LocalReplayObjectStorageService.java @@ -0,0 +1,124 @@ +package cn.nianxx.thhotel.integrations.storage.aliyunoss.service.impl; + +import cn.nianxx.thhotel.integrations.storage.aliyunoss.common.request.ObjectStorageReadRequest; +import cn.nianxx.thhotel.integrations.storage.aliyunoss.common.request.ObjectStoragePutRequest; +import cn.nianxx.thhotel.integrations.storage.aliyunoss.common.result.ObjectStorageReadResult; +import cn.nianxx.thhotel.integrations.storage.aliyunoss.common.result.ObjectStoragePutResult; +import cn.nianxx.thhotel.integrations.storage.aliyunoss.service.ObjectStorageService; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.Objects; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; + +/** + * local/local-replay 专用文件对象存储。 + * + *
对象 key 只能落在配置的单一根目录下;返回 file URL 供同一进程内的 booking worker 读取, + * 不提供目录浏览或任意路径读取能力。
+ */ +@Service +@ConditionalOnProperty( + prefix = "debug.agentbus-eml-replay", + name = "local-storage-enabled", + havingValue = "true") +public class LocalReplayObjectStorageService implements ObjectStorageService { + + private final Path root; + + public LocalReplayObjectStorageService( + cn.nianxx.thhotel.platform.debug.service.impl.AgentBusEmlReplayProperties properties) { + String configured = properties.getLocalStoragePath(); + if (configured == null || configured.isBlank()) { + throw new IllegalStateException("debug.agentbus-eml-replay.local-storage-path 不能为空。"); + } + this.root = Path.of(configured).toAbsolutePath().normalize(); + try { + Files.createDirectories(root); + } catch (IOException exception) { + throw new IllegalStateException("本地回放对象存储目录无法创建。", exception); + } + } + + @Override + public ObjectStoragePutResult putObject(ObjectStoragePutRequest request) { + Objects.requireNonNull(request, "对象存储请求不能为空"); + Path target = safeKey(request.objectKey()); + try { + Files.createDirectories(target.getParent()); + byte[] content = request.content() == null ? new byte[0] : request.content(); + Files.write(target, content, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, + StandardOpenOption.WRITE); + return new ObjectStoragePutResult( + request.objectKey(), target.toUri().toString(), request.contentType(), (long) content.length); + } catch (IOException exception) { + throw new ObjectStorageException("本地回放对象写入失败。", exception); + } + } + + @Override + public ObjectStorageReadResult readObject(ObjectStorageReadRequest request) { + if (request == null || request.externalUrl() == null || request.externalUrl().isBlank()) { + throw new ObjectStorageException("本地回放对象读取地址不能为空。"); + } + Path target; + try { + target = safeUri(request.externalUrl()); + } catch (RuntimeException exception) { + throw new ObjectStorageException("本地回放对象读取地址无效。", exception); + } + try { + long maxBytes = request.maxBytes() == null || request.maxBytes() <= 0 + ? 10L * 1024L * 1024L + : request.maxBytes(); + if (Files.size(target) > maxBytes) { + throw new ObjectStorageException("本地回放对象超过读取大小限制。"); + } + byte[] content = Files.readAllBytes(target); + return new ObjectStorageReadResult(content, null, (long) content.length); + } catch (IOException exception) { + throw new ObjectStorageException("本地回放对象读取失败。", exception); + } + } + + @Override + public void deleteObject(String objectKey) { + Path target = safeKey(objectKey); + if (target.equals(root) || objectKey.endsWith("/") || objectKey.contains("*") || objectKey.contains("?")) { + throw new ObjectStorageException("本地回放对象删除目标格式错误。"); + } + try { + Files.deleteIfExists(target); + } catch (IOException exception) { + throw new ObjectStorageException("本地回放对象删除失败。", exception); + } + } + + private Path safeKey(String objectKey) { + if (objectKey == null || objectKey.isBlank() || objectKey.startsWith("/") + || objectKey.contains("\\") || objectKey.contains("\u0000")) { + throw new ObjectStorageException("本地回放对象 key 格式错误。"); + } + Path target = root.resolve(objectKey).normalize(); + if (!target.startsWith(root) || target.equals(root)) { + throw new ObjectStorageException("本地回放对象 key 越过存储根目录。"); + } + return target; + } + + private Path safeUri(String externalUrl) { + URI uri = URI.create(externalUrl); + if (!"file".equalsIgnoreCase(uri.getScheme()) || uri.getQuery() != null || uri.getFragment() != null) { + throw new ObjectStorageException("本地回放对象只允许 file URL。"); + } + Path target = Path.of(uri).toAbsolutePath().normalize(); + if (!target.startsWith(root) || target.equals(root)) { + throw new ObjectStorageException("本地回放对象读取地址越过存储根目录。"); + } + return target; + } +}