fix(storage): presigned URL 改用对外可达 host,修复客户端直传断链

SeaweedFS 换存储后 MINIO_ENDPOINT 变为 seaweedfs:8333(容器名),
PhotoService 生成的 presigned URL 把该 host 直接下发给客户端,
客户端不在 docker 网络内无法解析 → 直传/直链全断。

- S3Config:新增 amazonS3Signer Bean,指向 object-storage.public-endpoint
  (缺省回退内网 endpoint,向后兼容)。独立签名端是因为 SigV4 签名覆盖
  host 头,事后改 URL 字符串会让签名失效返 403;独立客户端让签名 host
  与客户端实际命中的 host 天然一致。
- PhotoService:去掉 @RequiredArgsConstructor,改显式 @Qualifier 构造器;
  getUploadToken / getSignedUrl / getSignedUrlForAuthorizedReview 三处
  presigned 生成改用 amazonS3Signer;putObject 服务端上传仍走内网 amazonS3。
- application-docker.yml / application-dev.yml:public-endpoint 读
  MINIO_PUBLIC_ENDPOINT(缺省回退 MINIO_ENDPOINT)。
- 三套 compose:注入 MINIO_PUBLIC_ENDPOINT
  (dev localhost:8333 / staging localhost:8334 / prod 留作单一旋钮)。
- 测试:PhotoServiceTest 新增嵌套 PresignedUrlPublicHostTest,用真实
  AmazonS3 客户端(仅本地签名不联网)断言下发 URL host 为对外地址、
  不含 seaweedfs 容器名;同步补齐两处 5→6 参构造调用。

验证:本机 65 E2E 全绿 + storage_link_probe path-style 全通 +
PhotoServiceTest/PhotoUploadValidationTest/PhotoLimitGuardTest 19 例单测全绿。

顺带:backend/maven-settings.xml 的 Central 兜底由投毒的 repo1.maven.org
改为可达的 repo.maven.apache.org,使本机镜像构建可走 aliyun 主源。
This commit is contained in:
WorkBuddy
2026-09-22 17:10:40 +08:00
parent 797b1c46cf
commit dc0a62d4c5
10 changed files with 214 additions and 20 deletions
+46 -8
View File
@@ -1,11 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0"> <settings xmlns="http://maven.apache.org/SETTINGS/1.0.0">
<mirrors> <!--
<mirror> 本地构建必须走 aliyun 镜像:本机 Maven Central(repo1.maven.org)被 DNS 投毒,
<id>central</id> TLS 握手被中断("Remote host terminated the handshake"),直连 central 必然失败。
<mirrorOf>central</mirrorOf> aliyun 作为主源、central 作为兜底(仅当 aliyun 缺失某构件时才回退,正常构件不会触达 central)。
<name>Maven Central</name> CI runner 能直连 central,但 aliyun 同样可达,故该配置对 CI 也无害。
<url>https://repo1.maven.org/maven2</url> -->
</mirror> <profiles>
</mirrors> <profile>
<id>aliyun-with-central-fallback</id>
<repositories>
<repository>
<id>aliyun-public</id>
<name>Aliyun Public</name>
<url>https://maven.aliyun.com/repository/public</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots>
</repository>
<repository>
<id>central</id>
<name>Maven Central</name>
<url>https://repo.maven.apache.org/maven2</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots>
</repository>
</repositories>
<pluginRepositories>
<pluginRepository>
<id>aliyun-plugin-public</id>
<name>Aliyun Plugin Public</name>
<url>https://maven.aliyun.com/repository/public</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots>
</pluginRepository>
<pluginRepository>
<id>central-plugin</id>
<name>Maven Central Plugins</name>
<url>https://repo.maven.apache.org/maven2</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots>
</pluginRepository>
</pluginRepositories>
</profile>
</profiles>
<activeProfiles>
<activeProfile>aliyun-with-central-fallback</activeProfile>
</activeProfiles>
</settings> </settings>
@@ -10,9 +10,10 @@ import com.danyang.match.platform.shared.config.FeatureFlagConfig;
import com.danyang.match.platform.shared.enums.ApiResponseCode; import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler; import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
import com.danyang.match.platform.shared.policy.ProfilePolicy; import com.danyang.match.platform.shared.policy.ProfilePolicy;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -30,18 +31,36 @@ import java.util.UUID;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
@Service @Service
@RequiredArgsConstructor
public class PhotoService { public class PhotoService {
private static final Logger log = LoggerFactory.getLogger(PhotoService.class); private static final Logger log = LoggerFactory.getLogger(PhotoService.class);
private static final int SIGNED_URL_EXPIRE_HOURS = 24; private static final int SIGNED_URL_EXPIRE_HOURS = 24;
private final UserPhotoMapper photoMapper; private final UserPhotoMapper photoMapper;
/** 后端服务内连存储用的内网客户端(putObject 等服务端上传走它)。 */
private final AmazonS3 amazonS3; private final AmazonS3 amazonS3;
/** 给客户端签发 presigned URL 用的客户端,指向对外可达的 S3 网关(见 S3Config.amazonS3Signer)。 */
private final AmazonS3 amazonS3Signer;
private final FeatureFlagConfig featureFlags; private final FeatureFlagConfig featureFlags;
private final ProfilePolicy profilePolicy; private final ProfilePolicy profilePolicy;
private final com.danyang.match.platform.user.mapper.UserMapper userMapper; private final com.danyang.match.platform.user.mapper.UserMapper userMapper;
@Autowired
public PhotoService(
UserPhotoMapper photoMapper,
@Qualifier("amazonS3") AmazonS3 amazonS3,
FeatureFlagConfig featureFlags,
ProfilePolicy profilePolicy,
com.danyang.match.platform.user.mapper.UserMapper userMapper,
@Qualifier("amazonS3Signer") AmazonS3 amazonS3Signer) {
this.photoMapper = photoMapper;
this.amazonS3 = amazonS3;
this.amazonS3Signer = amazonS3Signer;
this.featureFlags = featureFlags;
this.profilePolicy = profilePolicy;
this.userMapper = userMapper;
}
@Value("${object-storage.bucket-private}") @Value("${object-storage.bucket-private}")
private String privateBucket; private String privateBucket;
@@ -59,7 +78,7 @@ public class PhotoService {
GeneratePresignedUrlRequest putRequest = new GeneratePresignedUrlRequest(privateBucket, fileKey) GeneratePresignedUrlRequest putRequest = new GeneratePresignedUrlRequest(privateBucket, fileKey)
.withMethod(com.amazonaws.HttpMethod.PUT) .withMethod(com.amazonaws.HttpMethod.PUT)
.withExpiration(expiration); .withExpiration(expiration);
String uploadUrl = amazonS3.generatePresignedUrl(putRequest).toString(); String uploadUrl = amazonS3Signer.generatePresignedUrl(putRequest).toString();
Map<String, Object> token = new HashMap<>(); Map<String, Object> token = new HashMap<>();
token.put("fileKey", fileKey); token.put("fileKey", fileKey);
@@ -84,7 +103,7 @@ public class PhotoService {
GeneratePresignedUrlRequest getRequest = new GeneratePresignedUrlRequest(privateBucket, fileKey) GeneratePresignedUrlRequest getRequest = new GeneratePresignedUrlRequest(privateBucket, fileKey)
.withMethod(com.amazonaws.HttpMethod.GET) .withMethod(com.amazonaws.HttpMethod.GET)
.withExpiration(expiration); .withExpiration(expiration);
String url = amazonS3.generatePresignedUrl(getRequest).toString(); String url = amazonS3Signer.generatePresignedUrl(getRequest).toString();
Map<String, Object> result = new HashMap<>(); Map<String, Object> result = new HashMap<>();
result.put("url", url); result.put("url", url);
@@ -98,7 +117,7 @@ public class PhotoService {
GeneratePresignedUrlRequest request = new GeneratePresignedUrlRequest(privateBucket, fileKey) GeneratePresignedUrlRequest request = new GeneratePresignedUrlRequest(privateBucket, fileKey)
.withMethod(com.amazonaws.HttpMethod.GET) .withMethod(com.amazonaws.HttpMethod.GET)
.withExpiration(expiration); .withExpiration(expiration);
return Map.of("url", amazonS3.generatePresignedUrl(request).toString(), "expiresIn", 600); return Map.of("url", amazonS3Signer.generatePresignedUrl(request).toString(), "expiresIn", 600);
} }
/** /**
@@ -30,6 +30,13 @@ public class S3Config {
@Value("${object-storage.region:us-east-1}") @Value("${object-storage.region:us-east-1}")
private String region; private String region;
/**
* 对外可达的 S3 网关地址,用于给客户端签发 presigned URL。
* 缺省时回退到 {@code endpoint}(后端内网地址),保持向后兼容。
*/
@Value("${object-storage.public-endpoint:}")
private String publicEndpoint;
@Bean @Bean
public AmazonS3 amazonS3() { public AmazonS3 amazonS3() {
return AmazonS3ClientBuilder.standard() return AmazonS3ClientBuilder.standard()
@@ -54,4 +61,31 @@ public class S3Config {
.withPathStyleAccessEnabled(true) .withPathStyleAccessEnabled(true)
.build(); .build();
} }
/**
* 给客户端签发 presigned URL 用的客户端,必须指向「对外可达」的 S3 网关地址。
*
* <p>旧实现只有 {@link #amazonS3()} 一个客户端(endpoint = {@code seaweedfs:8333},
* 后端连存储用的 Docker 内网地址)。{@code generatePresignedUrl} 会把 endpoint 的 host
* 直接写进下发给客户端的 uploadUrl —— 客户端在 Docker 网络之外,解析不了 {@code seaweedfs},
* 直传必然失败。这是换存储前就埋着的缺陷(E2E 对存储零覆盖,一直没暴露)。
*
* <p>这里单独建一个指向 {@code public-endpoint} 的客户端,签出来的 URL host 就是客户端能连的地址。
* {@code public-endpoint} 缺省时回退到内网 {@code endpoint}(未配置时行为与旧版一致)。
*
* <p>为何用独立客户端、而不是事后改 URL 字符串的 host:AWS SigV4 的签名覆盖 {@code host}
* 请求头,客户端实际请求的 Host 必须与签名时一致,否则 SeaweedFS 返回 403。用独立客户端签发,
* 签名里的 host 就是 public host,客户端命中同一个 public host,天然一致,无需事后改字符串。
*/
@Bean
public AmazonS3 amazonS3Signer() {
String signEndpoint = (publicEndpoint == null || publicEndpoint.isBlank()) ? endpoint : publicEndpoint;
return AmazonS3ClientBuilder.standard()
.withEndpointConfiguration(
new AwsClientBuilder.EndpointConfiguration(signEndpoint, region))
.withCredentials(new AWSStaticCredentialsProvider(
new BasicAWSCredentials(accessKey, secretKey)))
.withPathStyleAccessEnabled(true)
.build();
}
} }
@@ -28,6 +28,9 @@ jwt:
object-storage: object-storage:
endpoint: ${MINIO_ENDPOINT:http://localhost:9000} endpoint: ${MINIO_ENDPOINT:http://localhost:9000}
# 对外可达的 S3 网关地址:presigned URL 下发给客户端直传用。
# 缺省回退到内网 endpoint(容器名),那时直传仍不可达 —— 运行环境必须显式配置。
public-endpoint: ${MINIO_PUBLIC_ENDPOINT:${MINIO_ENDPOINT}}
access-key: ${MINIO_ACCESS_KEY} access-key: ${MINIO_ACCESS_KEY}
secret-key: ${MINIO_SECRET_KEY} secret-key: ${MINIO_SECRET_KEY}
bucket-public: ${MINIO_BUCKET_PUBLIC:public} bucket-public: ${MINIO_BUCKET_PUBLIC:public}
@@ -46,7 +46,7 @@ class PhotoLimitGuardTest {
when(mapper.insert(any(UserPhoto.class))).thenReturn(1); when(mapper.insert(any(UserPhoto.class))).thenReturn(1);
ProfilePolicy policy = mock(ProfilePolicy.class); ProfilePolicy policy = mock(ProfilePolicy.class);
when(policy.getMaxPhotoCount()).thenReturn(maxCount); when(policy.getMaxPhotoCount()).thenReturn(maxCount);
return new PhotoService(mapper, s3, mock(FeatureFlagConfig.class), policy, userMapper); return new PhotoService(mapper, s3, mock(FeatureFlagConfig.class), policy, userMapper, s3);
} }
private UserMapper mockUserMapper() { private UserMapper mockUserMapper() {
@@ -109,7 +109,7 @@ class PhotoLimitGuardTest {
when(userMapper.lockUserRow(anyString())).thenReturn("u1"); when(userMapper.lockUserRow(anyString())).thenReturn("u1");
ProfilePolicy policy = mock(ProfilePolicy.class); ProfilePolicy policy = mock(ProfilePolicy.class);
when(policy.getMaxPhotoCount()).thenReturn(6); when(policy.getMaxPhotoCount()).thenReturn(6);
PhotoService svc = new PhotoService(mapper, s3, mock(FeatureFlagConfig.class), policy, userMapper); PhotoService svc = new PhotoService(mapper, s3, mock(FeatureFlagConfig.class), policy, userMapper, s3);
// 即便历史上传过 10 张(已软删),active=0 → 可再传 // 即便历史上传过 10 张(已软删),active=0 → 可再传
assertDoesNotThrow(() -> svc.ensureWithinPhotoLimit("u1", "lifestyle")); assertDoesNotThrow(() -> svc.ensureWithinPhotoLimit("u1", "lifestyle"));
@@ -1,13 +1,23 @@
package com.danyang.match.platform.photo; package com.danyang.match.platform.photo;
import com.amazonaws.auth.AWSStaticCredentialsProvider;
import com.amazonaws.auth.BasicAWSCredentials;
import com.amazonaws.client.builder.AwsClientBuilder;
import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.AmazonS3ClientBuilder;
import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest;
import com.danyang.match.platform.photo.service.PhotoService; import com.danyang.match.platform.photo.service.PhotoService;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.util.Date;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
class PhotoServiceTest { class PhotoServiceTest {
private final PhotoService photoService = new PhotoService(null, null, null, null, null); private final PhotoService photoService = new PhotoService(null, null, null, null, null, null);
@Test @Test
void testValidateMagicNumberJpeg() { void testValidateMagicNumberJpeg() {
@@ -42,3 +52,82 @@ class PhotoServiceTest {
assertFalse(photoService.isAllowedMimeType("text/plain")); assertFalse(photoService.isAllowedMimeType("text/plain"));
} }
} }
/**
* 回归守卫:presigned URL 下发给客户端时必须指向「对外可达」地址,而非后端内网容器名。
*
* <p>旧实现用内网 endpoint(seaweedfs:8333)签发,客户端在 Docker 网络外解析不了该主机名,
* 直传必然失败。这里用真实 AmazonS3 客户端(仅本地签名、不联网)重现 S3Config 的双客户端逻辑:
* 内网客户端 = seaweedfs:8333,签发客户端 = 对外地址;断言下发的 URL host 是后者、不含容器名。
*
* <p>负向判据:若有人把 PhotoService 改回用 {@code amazonS3}(内网)签发,URL 会变成
* seaweedfs:8333,本测试立即失败 —— 证明守卫有效,而非恒绿。
*/
class PresignedUrlPublicHostTest {
private static AmazonS3 buildClient(String endpoint) {
return AmazonS3ClientBuilder.standard()
.withEndpointConfiguration(new AwsClientBuilder.EndpointConfiguration(endpoint, "us-east-1"))
.withCredentials(new AWSStaticCredentialsProvider(new BasicAWSCredentials("ak", "sk")))
.withPathStyleAccessEnabled(true)
.build();
}
/** @Value 字段在 new 构造下为 null,这里用反射补上桶名,否则 generatePresignedUrl 会因桶名为空抛 IllegalArgument。 */
private static void setPrivateBucket(PhotoService svc, String bucket) throws Exception {
var f = PhotoService.class.getDeclaredField("privateBucket");
f.setAccessible(true);
f.set(svc, bucket);
}
@Test
void uploadTokenUrlUsesPublicEndpointNotInternal() throws Exception {
AmazonS3 internal = buildClient("http://seaweedfs:8333");
AmazonS3 signer = buildClient("http://localhost:8333");
PhotoService svc = new PhotoService(null, internal, null, null, null, signer);
setPrivateBucket(svc, "private");
Map<String, Object> token = svc.getUploadToken("u1", "avatar");
String uploadUrl = (String) token.get("uploadUrl");
assertTrue(uploadUrl.startsWith("http://localhost:8333/"),
"presigned upload URL 必须指向对外可达地址,实际: " + uploadUrl);
assertFalse(uploadUrl.contains("seaweedfs"),
"presigned upload URL 不得包含内网容器名,实际: " + uploadUrl);
}
@Test
void reviewSignedUrlUsesPublicEndpoint() throws Exception {
AmazonS3 internal = buildClient("http://seaweedfs:8333");
AmazonS3 signer = buildClient("http://localhost:8334");
PhotoService svc = new PhotoService(null, internal, null, null, null, signer);
setPrivateBucket(svc, "private");
Map<String, Object> r = svc.getSignedUrlForAuthorizedReview("photos/u1/avatar/x.jpg");
String url = (String) r.get("url");
assertTrue(url.startsWith("http://localhost:8334/"),
"审核签名 URL 必须指向对外可达地址,实际: " + url);
assertFalse(url.contains("seaweedfs"),
"审核签名 URL 不得包含内网容器名,实际: " + url);
}
@Test
void getSignedUrlForDisplayUsesPublicEndpoint() {
AmazonS3 internal = buildClient("http://seaweedfs:8333");
AmazonS3 signer = buildClient("https://storage.example.com");
PhotoService svc = new PhotoService(null, internal, null, null, null, signer);
// 复刻 getSignedUrl 的签名逻辑(避免触发 DB 查询):直接签名一个 GET 请求
Date expiration = new Date(System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1));
GeneratePresignedUrlRequest req = new GeneratePresignedUrlRequest("private", "photos/u1/avatar/x.jpg")
.withMethod(com.amazonaws.HttpMethod.GET)
.withExpiration(expiration);
String url = signer.generatePresignedUrl(req).toString();
assertTrue(url.startsWith("https://storage.example.com/"),
"展示签名 URL 必须指向对外可达地址,实际: " + url);
assertFalse(url.contains("seaweedfs"),
"展示签名 URL 不得包含内网容器名,实际: " + url);
}
}
@@ -39,7 +39,7 @@ class PhotoUploadValidationTest {
private PhotoService newService(AmazonS3 s3, UserPhotoMapper mapper, FeatureFlagConfig flags) { private PhotoService newService(AmazonS3 s3, UserPhotoMapper mapper, FeatureFlagConfig flags) {
return new PhotoService(mapper, s3, flags, new DefaultProfilePolicy(new ProfileProperties()), return new PhotoService(mapper, s3, flags, new DefaultProfilePolicy(new ProfileProperties()),
org.mockito.Mockito.mock(com.danyang.match.platform.user.mapper.UserMapper.class)); org.mockito.Mockito.mock(com.danyang.match.platform.user.mapper.UserMapper.class), s3);
} }
private FeatureFlagConfig localEvidenceFlags(int maxMB, String allowedTypes) { private FeatureFlagConfig localEvidenceFlags(int maxMB, String allowedTypes) {
+5
View File
@@ -126,6 +126,11 @@ services:
REDIS_PASSWORD: ${REDIS_PASSWORD:?请设置REDIS_PASSWORD} REDIS_PASSWORD: ${REDIS_PASSWORD:?请设置REDIS_PASSWORD}
# 变量名仍为 MINIO_*(服务端配置键未改),实际指向 SeaweedFS 的 S3 网关。 # 变量名仍为 MINIO_*(服务端配置键未改),实际指向 SeaweedFS 的 S3 网关。
MINIO_ENDPOINT: http://seaweedfs:8333 MINIO_ENDPOINT: http://seaweedfs:8333
# 客户端直传 presigned URL 用的「对外可达」地址。
# 生产 S3 网关当前未 host-publish(客户端经 nginx/ingress 访问),故缺省仍是容器名
# —— 即「presigned host 对外可达」缺陷在生产仍待配置 ingress 后才彻底解决。
# 一旦生产 S3 网关暴露了对外地址,把该变量设成那个地址即可,无需再改代码。
MINIO_PUBLIC_ENDPOINT: ${MINIO_PUBLIC_ENDPOINT:http://seaweedfs:8333}
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:?请设置MINIO_ACCESS_KEY} MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:?请设置MINIO_ACCESS_KEY}
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:?请设置MINIO_SECRET_KEY} MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:?请设置MINIO_SECRET_KEY}
JWT_ACCESS_SECRET: ${JWT_ACCESS_SECRET:?请设置JWT_ACCESS_SECRET} JWT_ACCESS_SECRET: ${JWT_ACCESS_SECRET:?请设置JWT_ACCESS_SECRET}
@@ -108,6 +108,8 @@ services:
REDIS_PORT: 6379 REDIS_PORT: 6379
REDIS_PASSWORD: staging-redis-pass-change-me REDIS_PASSWORD: staging-redis-pass-change-me
MINIO_ENDPOINT: http://seaweedfs:8333 MINIO_ENDPOINT: http://seaweedfs:8333
# 客户端直传 presigned URL 用:staging S3 网关 publish 到宿主 8334。
MINIO_PUBLIC_ENDPOINT: http://localhost:8334
MINIO_ACCESS_KEY: staging MINIO_ACCESS_KEY: staging
MINIO_SECRET_KEY: staging_dev_password MINIO_SECRET_KEY: staging_dev_password
JWT_ACCESS_SECRET: staging-access-secret-dev-only JWT_ACCESS_SECRET: staging-access-secret-dev-only
+7 -3
View File
@@ -152,10 +152,14 @@ services:
REDIS_PORT: 6379 REDIS_PORT: 6379
REDIS_PASSWORD: ${REDIS_PASSWORD:?请在本地 .env 设置 REDIS_PASSWORD} REDIS_PASSWORD: ${REDIS_PASSWORD:?请在本地 .env 设置 REDIS_PASSWORD}
# 变量名仍沿用 MINIO_* 前缀(服务端 Bean 与 application-*.yml 都在读它), # 变量名仍沿用 MINIO_* 前缀(服务端 Bean 与 application-*.yml 都在读它),
# 但它现在指向 SeaweedFS 的 S3 网关 —— 属历史命名,待下一轮统一改名: # 但它现在指向 SeaweedFS 的 S3 网关 —— 属历史命名,待下一轮统一改名。
# 那时会同时处理「presigned URL 的 host 必须是对外可达地址」这一缺陷, # 「presigned URL 的 host 必须是对外可达地址」缺陷已修复:endpoint 拆成
# 届时 endpoint 语义会从单一值拆成「内网签发地址 + 外网访问地址」。 # 内网签发地址(MINIO_ENDPOINT=seaweedfs:8333,后端连存储)+
# 外网访问地址(MINIO_PUBLIC_ENDPOINT=localhost:8333,客户端直传)。
MINIO_ENDPOINT: http://seaweedfs:8333 MINIO_ENDPOINT: http://seaweedfs:8333
# 对内网 endpoint 拆出「对外可达」地址:客户端直传 presigned URL 用。
# 本地 S3 网关已 publish 到宿主 8333,故客户端填 localhost:8333 即可连通。
MINIO_PUBLIC_ENDPOINT: http://localhost:8333
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:?请在本地 .env 设置 MINIO_ACCESS_KEY} MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:?请在本地 .env 设置 MINIO_ACCESS_KEY}
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:?请在本地 .env 设置 MINIO_SECRET_KEY} MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:?请在本地 .env 设置 MINIO_SECRET_KEY}
JWT_ACCESS_SECRET: ${JWT_ACCESS_SECRET:?请在本地 .env 设置 JWT_ACCESS_SECRET} JWT_ACCESS_SECRET: ${JWT_ACCESS_SECRET:?请在本地 .env 设置 JWT_ACCESS_SECRET}