Merge seaweedfs-pilot: 对象存储从 MinIO 迁移到 SeaweedFS 4.47,并修复 presigned URL 直传 host 断链

- 对象存储后端由 MinIO 换成 SeaweedFS 4.47(path-style 寻址,filer 预建 public/private 桶)
- 新增 storage-init 容器幂等建桶;三套 compose(dev/staging/prod)相应改造
- 修复 presigned URL:签发改用「对外可达 endpoint」的独立 AmazonS3 客户端,避免客户端直传被 403 断链
- 新增对象存储真实链路探针(判据,非 pytest 用例)

PR #1,GitHub Actions 7/7 全绿(含真实栈 E2E 65 passed)。
This commit is contained in:
WorkBuddy
2026-09-22 17:23:13 +08:00
15 changed files with 608 additions and 93 deletions
+13 -10
View File
@@ -249,15 +249,15 @@ jobs:
working-directory: infrastructure
run: docker compose config --quiet
# 2026-09-22:compose 里的 minio 已从 Docker Hub 迁到 quay.io 并钉版
# (RELEASE.2025-09-07T16-13-09Z,版本由本步骤上一轮的实测输出确认)。
# 2026-09-22:compose 的对象存储已换成 chrislusf/seaweedfs:4.47 并钉版。
# 单独保留这一步的理由:镜像拉取失败在 compose up 里会埋进几十行输出,
# 这里能在起栈前秒级暴露「上游镜像下架」这类外部依赖失效 ——
# 上一轮正是靠日志里的 pull access denied 才定位到 MinIO 已移除 Docker Hub 仓库。
- name: 校验 minio 钉版镜像可拉取并打印版本
# 上一轮正是靠日志里的 pull access denied 才定位到 MinIO 已移除 Docker Hub 仓库
# (minio/minio 现返回 404;教训是「第三方镜像」本身就是外部依赖风险点)。
- name: 校验 seaweedfs 钉版镜像可拉取并打印版本
run: |
docker pull quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z
docker run --rm quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z --version
docker pull chrislusf/seaweedfs:4.47
docker run --rm chrislusf/seaweedfs:4.47 version
# 保证库为全新:seed 用 `ON CONFLICT (username) DO NOTHING`,若卷是上一轮残留,
# 账号密码会停留在上一轮的 `E2E_OPERATOR_PASSWORD` 上 → 本轮登录 401(表现为各种业务失败)。
@@ -270,11 +270,14 @@ jobs:
working-directory: infrastructure
run: docker compose build backend
- name: 起真实栈(postgres + redis + minio + backend)
# 显式列出 minio:backend 的 depends_on 虽会隐式拉起它,但隐式依赖不利于阅读,
# 且 minio 的 healthcheck(`mc ready local`)是 backend 能否启动的前置条件。
- name: 起真实栈(postgres + redis + seaweedfs + 建桶 + backend)
# 显式列出 seaweedfs 与 storage-init:backend 的 depends_on 虽会隐式拉起它们,
# 但隐式依赖不利于阅读,且 seaweedfs 的 healthcheck 是 backend 启动的前置条件。
# storage-init 是**一次性**建桶容器 —— SeaweedFS 不会自动建桶,
# PutObject 到不存在的 bucket 会直接失败,少了它照片链路就是断的
# (E2E 对存储零覆盖,不会替你发现)。
working-directory: infrastructure
run: docker compose up -d postgres redis minio backend
run: docker compose up -d postgres redis seaweedfs storage-init backend
- name: 等待 backend 就绪(超时即失败,不静默跳过)
# 窗口 90×5s=7.5min:首次构建后还需 Spring 启动 + Flyway 46 个迁移,5min 偏紧。
+46 -8
View File
@@ -1,11 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0">
<mirrors>
<mirror>
<id>central</id>
<mirrorOf>central</mirrorOf>
<name>Maven Central</name>
<url>https://repo1.maven.org/maven2</url>
</mirror>
</mirrors>
<!--
本地构建必须走 aliyun 镜像:本机 Maven Central(repo1.maven.org)被 DNS 投毒,
TLS 握手被中断("Remote host terminated the handshake"),直连 central 必然失败。
aliyun 作为主源、central 作为兜底(仅当 aliyun 缺失某构件时才回退,正常构件不会触达 central)。
CI runner 能直连 central,但 aliyun 同样可达,故该配置对 CI 也无害。
-->
<profiles>
<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>
@@ -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.exception.GlobalExceptionHandler;
import com.danyang.match.platform.shared.policy.ProfilePolicy;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
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.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -30,18 +31,36 @@ import java.util.UUID;
import java.util.concurrent.TimeUnit;
@Service
@RequiredArgsConstructor
public class PhotoService {
private static final Logger log = LoggerFactory.getLogger(PhotoService.class);
private static final int SIGNED_URL_EXPIRE_HOURS = 24;
private final UserPhotoMapper photoMapper;
/** 后端服务内连存储用的内网客户端(putObject 等服务端上传走它)。 */
private final AmazonS3 amazonS3;
/** 给客户端签发 presigned URL 用的客户端,指向对外可达的 S3 网关(见 S3Config.amazonS3Signer)。 */
private final AmazonS3 amazonS3Signer;
private final FeatureFlagConfig featureFlags;
private final ProfilePolicy profilePolicy;
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}")
private String privateBucket;
@@ -59,7 +78,7 @@ public class PhotoService {
GeneratePresignedUrlRequest putRequest = new GeneratePresignedUrlRequest(privateBucket, fileKey)
.withMethod(com.amazonaws.HttpMethod.PUT)
.withExpiration(expiration);
String uploadUrl = amazonS3.generatePresignedUrl(putRequest).toString();
String uploadUrl = amazonS3Signer.generatePresignedUrl(putRequest).toString();
Map<String, Object> token = new HashMap<>();
token.put("fileKey", fileKey);
@@ -84,7 +103,7 @@ public class PhotoService {
GeneratePresignedUrlRequest getRequest = new GeneratePresignedUrlRequest(privateBucket, fileKey)
.withMethod(com.amazonaws.HttpMethod.GET)
.withExpiration(expiration);
String url = amazonS3.generatePresignedUrl(getRequest).toString();
String url = amazonS3Signer.generatePresignedUrl(getRequest).toString();
Map<String, Object> result = new HashMap<>();
result.put("url", url);
@@ -98,7 +117,7 @@ public class PhotoService {
GeneratePresignedUrlRequest request = new GeneratePresignedUrlRequest(privateBucket, fileKey)
.withMethod(com.amazonaws.HttpMethod.GET)
.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}")
private String region;
/**
* 对外可达的 S3 网关地址,用于给客户端签发 presigned URL。
* 缺省时回退到 {@code endpoint}(后端内网地址),保持向后兼容。
*/
@Value("${object-storage.public-endpoint:}")
private String publicEndpoint;
@Bean
public AmazonS3 amazonS3() {
return AmazonS3ClientBuilder.standard()
@@ -37,6 +44,48 @@ public class S3Config {
new AwsClientBuilder.EndpointConfiguration(endpoint, region))
.withCredentials(new AWSStaticCredentialsProvider(
new BasicAWSCredentials(accessKey, secretKey)))
// 必须显式开启 path-style。
//
// AWS SDK v1 默认是 virtual-host 风格,会把 bucket 拼进主机名:
// endpoint=http://seaweedfs:8333 -> http://private.seaweedfs:8333/...
// 而 `seaweedfs` 是 Docker 内部服务名,`private.seaweedfs` 没有任何 DNS 记录,
// 请求必然失败。private 部署的对象存储几乎都用 IP 或容器名,
// virtual-host 天然不可用 —— path-style(http://host:port/bucket/key)才是正解。
//
// 这一点在换存储前就已埋着:MinIO 时代之所以没暴露,是因为对象存储链路
// 从未被任何测试真正走过(E2E 对存储零覆盖),而不是因为配置正确。
//
// 排查提醒:本机有 http_proxy 时,*.localhost 这类 virtual-host 域名
// 不在 NO_PROXY 内会走代理并返回 502,看起来像「存储不支持」,
// 实为代理假象 —— 判据里 502 不能当作「被拒绝」。
.withPathStyleAccessEnabled(true)
.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:
endpoint: ${MINIO_ENDPOINT:http://localhost:9000}
# 对外可达的 S3 网关地址:presigned URL 下发给客户端直传用。
# 缺省回退到内网 endpoint(容器名),那时直传仍不可达 —— 运行环境必须显式配置。
public-endpoint: ${MINIO_PUBLIC_ENDPOINT:${MINIO_ENDPOINT}}
access-key: ${MINIO_ACCESS_KEY}
secret-key: ${MINIO_SECRET_KEY}
bucket-public: ${MINIO_BUCKET_PUBLIC:public}
@@ -46,7 +46,7 @@ class PhotoLimitGuardTest {
when(mapper.insert(any(UserPhoto.class))).thenReturn(1);
ProfilePolicy policy = mock(ProfilePolicy.class);
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() {
@@ -109,7 +109,7 @@ class PhotoLimitGuardTest {
when(userMapper.lockUserRow(anyString())).thenReturn("u1");
ProfilePolicy policy = mock(ProfilePolicy.class);
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 → 可再传
assertDoesNotThrow(() -> svc.ensureWithinPhotoLimit("u1", "lifestyle"));
@@ -1,13 +1,23 @@
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 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.*;
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
void testValidateMagicNumberJpeg() {
@@ -42,3 +52,82 @@ class PhotoServiceTest {
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) {
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) {
+67 -13
View File
@@ -39,23 +39,67 @@ services:
- danyang-net
restart: unless-stopped
minio:
# 同 docker-compose.yml 的 minio 条目:Docker Hub 上 minio/minio 仓库已被整体移除
# (MinIO 自 2025-10 起停止发布容器镜像,仅源码分发),生产环境同样拉不到。
# 改为仍在提供镜像的 quay.io —— 这是真实的部署阻塞,不只是 CI 问题。
# 版本与本地/CI 保持一致,钉死为 CI 实测确认可用的 RELEASE.2025-09-07T16-13-09Z。
image: quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z
container_name: danyang-match-minio
command: server /data --console-address ":9001"
seaweedfs:
# 同 docker-compose.yml:对象存储已从 MinIO 换成 SeaweedFS。
# 生产换的动力比 CI 更强 —— MinIO 仓库已归档、不再产出安全补丁,
# 而这里存的是身份证与实名材料。详见 docker-compose.yml 中的完整说明。
# 版本与本地/CI 一致(钉 4.47),避免各环境跑不同实现。
image: chrislusf/seaweedfs:4.47
container_name: danyang-match-seaweedfs
# 必须覆盖 entrypoint:镜像 ENTRYPOINT 是 /usr/bin/weed,只写 command 会被追加成
# `weed sh -c ...` → `weed: unknown subcommand "sh"`(本地实测踩到)。
entrypoint: ["sh", "-c"]
command:
- |
cat > /tmp/s3cfg.json <<CFG
{"identities":[{"name":"app","credentials":[{"accessKey":"$${MINIO_ACCESS_KEY}","secretKey":"$${MINIO_SECRET_KEY}"}],"actions":["Admin","Read","Write","List","Tagging"]}]}
CFG
# -volume.max=100:bucket 名即 collection,默认上限 8 个 volume 只够一个 bucket
# (第二个桶写入会 InternalError)。详见 docker-compose.yml 中的说明。
exec weed server -s3 -s3.port=8333 -s3.config=/tmp/s3cfg.json -dir=/data -volume.max=100
environment:
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:?请设置MINIO_ACCESS_KEY}
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:?请设置MINIO_SECRET_KEY}
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:?请设置MINIO_ACCESS_KEY}
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:?请设置MINIO_SECRET_KEY}
volumes:
- minio_data:/data
- seaweedfs_data:/data
networks:
- danyang-net
healthcheck:
# 与本地一致:鉴权开启后匿名列桶返回 403,故 200/403 都算健康。
test: ["CMD-SHELL", "code=$$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8333/); [ \"$$code\" = 200 ] || [ \"$$code\" = 403 ]"]
interval: 5s
timeout: 5s
retries: 24
restart: unless-stopped
storage-init:
# SeaweedFS 不会自动建桶,PutObject 到不存在的 bucket 直接失败。
# 生产同样必须预建 public / private,否则照片与实名材料全部上传不了。
# 走 filer 目录接口,无需签名,复用同一镜像的 curl。幂等:201 新建 / 409 已存在。
image: chrislusf/seaweedfs:4.47
container_name: danyang-match-storage-init
depends_on:
seaweedfs:
condition: service_healthy
entrypoint:
- sh
- -c
- |
for b in public private; do
code=$$(curl -s -o /dev/null -w '%{http_code}' "http://seaweedfs:8888/buckets/$$b/" || echo 000)
if [ "$$code" = "200" ]; then echo "bucket $$b: already exists"; continue; fi
i=0
while [ $$i -lt 30 ]; do
code=$$(curl -s -o /dev/null -w '%{http_code}' -X POST "http://seaweedfs:8888/buckets/$$b/" || echo 000)
case "$$code" in 200|201|409) break ;; esac
i=$$((i+1)); sleep 2
done
echo "bucket $$b: create -> $$code"
done
networks:
- danyang-net
restart: "no"
backend:
build:
context: ../backend
@@ -66,6 +110,10 @@ services:
condition: service_healthy
redis:
condition: service_healthy
seaweedfs:
condition: service_healthy
storage-init:
condition: service_completed_successfully
environment:
SPRING_PROFILES_ACTIVE: docker,production
SPRING_CONFIG_IMPORT: configtree:/run/secrets/
@@ -76,7 +124,13 @@ services:
REDIS_HOST: redis
REDIS_PORT: 6379
REDIS_PASSWORD: ${REDIS_PASSWORD:?请设置REDIS_PASSWORD}
MINIO_ENDPOINT: http://minio:9000
# 变量名仍为 MINIO_*(服务端配置键未改),实际指向 SeaweedFS 的 S3 网关。
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_SECRET_KEY: ${MINIO_SECRET_KEY:?请设置MINIO_SECRET_KEY}
JWT_ACCESS_SECRET: ${JWT_ACCESS_SECRET:?请设置JWT_ACCESS_SECRET}
@@ -138,7 +192,7 @@ services:
volumes:
postgres_data:
redis_data:
minio_data:
seaweedfs_data:
networks:
danyang-net:
+56 -13
View File
@@ -31,20 +31,61 @@ services:
timeout: 5s
retries: 5
minio:
# 同 docker-compose.yml 的 minio 条目:Docker Hub 上 minio/minio 仓库已被整体移除
# (MinIO 自 2025-10 起停止发布容器镜像,仅源码分发),staging 环境同样拉不到。
# 改为仍在提供镜像的 quay.io,并钉死为与本地/CI 一致的 RELEASE.2025-09-07T16-13-09Z。
image: quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z
command: server /data --console-address ":9001"
seaweedfs:
# 同 docker-compose.yml:对象存储已从 MinIO 换成 SeaweedFS(钉 4.47)。
# staging 与本地/生产保持同一实现,避免「只有预发用的是另一套存储」这类偏差。
image: chrislusf/seaweedfs:4.47
container_name: danyang-match-staging-seaweedfs
# 必须覆盖 entrypoint:镜像 ENTRYPOINT 是 /usr/bin/weed,只写 command 会被追加成
# `weed sh -c ...` → `weed: unknown subcommand "sh"`(本地实测踩到)。
entrypoint: ["sh", "-c"]
command:
- |
cat > /tmp/s3cfg.json <<CFG
{"identities":[{"name":"app","credentials":[{"accessKey":"$${MINIO_ACCESS_KEY}","secretKey":"$${MINIO_SECRET_KEY}"}],"actions":["Admin","Read","Write","List","Tagging"]}]}
CFG
# -volume.max=100:bucket 名即 collection,默认上限 8 个 volume 只够一个 bucket
# (第二个桶写入会 InternalError)。详见 docker-compose.yml 中的说明。
exec weed server -s3 -s3.port=8333 -s3.config=/tmp/s3cfg.json -dir=/data -volume.max=100
environment:
MINIO_ROOT_USER: staging
MINIO_ROOT_PASSWORD: staging_dev_password
MINIO_ACCESS_KEY: staging
MINIO_SECRET_KEY: staging_dev_password
ports:
- "9010:9000"
- "9011:9001"
# 与生产错开一位(8080/8081、5432/5433 同理):本地栈的 8333 已被占,故用 8334。
- "8334:8333"
- "9334:9333"
- "8889:8888"
volumes:
- minio_staging_data:/data
- seaweedfs_staging_data:/data
healthcheck:
test: ["CMD-SHELL", "code=$$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8333/); [ \"$$code\" = 200 ] || [ \"$$code\" = 403 ]"]
interval: 5s
timeout: 5s
retries: 24
storage-init:
# 同 docker-compose.yml:SeaweedFS 不自动建桶,必须预建 public / private。
image: chrislusf/seaweedfs:4.47
container_name: danyang-match-staging-storage-init
depends_on:
seaweedfs:
condition: service_healthy
entrypoint:
- sh
- -c
- |
for b in public private; do
code=$$(curl -s -o /dev/null -w '%{http_code}' "http://seaweedfs:8888/buckets/$$b/" || echo 000)
if [ "$$code" = "200" ]; then echo "bucket $$b: already exists"; continue; fi
i=0
while [ $$i -lt 30 ]; do
code=$$(curl -s -o /dev/null -w '%{http_code}' -X POST "http://seaweedfs:8888/buckets/$$b/" || echo 000)
case "$$code" in 200|201|409) break ;; esac
i=$$((i+1)); sleep 2
done
echo "bucket $$b: create -> $$code"
done
restart: "no"
backend:
build:
@@ -66,7 +107,9 @@ services:
REDIS_HOST: redis
REDIS_PORT: 6379
REDIS_PASSWORD: staging-redis-pass-change-me
MINIO_ENDPOINT: http://minio:9000
MINIO_ENDPOINT: http://seaweedfs:8333
# 客户端直传 presigned URL 用:staging S3 网关 publish 到宿主 8334。
MINIO_PUBLIC_ENDPOINT: http://localhost:8334
MINIO_ACCESS_KEY: staging
MINIO_SECRET_KEY: staging_dev_password
JWT_ACCESS_SECRET: staging-access-secret-dev-only
@@ -103,4 +146,4 @@ services:
volumes:
postgres_staging_data:
redis_staging_data:
minio_staging_data:
seaweedfs_staging_data:
+99 -33
View File
@@ -32,44 +32,99 @@ services:
timeout: 5s
retries: 5
minio:
# 2026-09-22 修正:镜像源从 Docker Hub 迁到 quay.io。
seaweedfs:
# 2026-09-22:对象存储从 MinIO 换成 SeaweedFS。
#
# 原 `minio/minio:RELEASE.2025-09-07T16-13-09Z` 在 CI 上根本拉不到,Actions 报:
# pull access denied for minio/minio, repository does not exist
# 问题不在 tag,而在**整个仓库已从 Docker Hub 移除**:MinIO 自 2025-10 起停止发布
# 容器镜像(改为仅源码分发),其 GitHub 仓库于 2026-02 归档;Docker Hub 上
# minio/minio 与 minio/mc 现均返回 404。quay.io/minio/minio 仍在提供镜像。
# 动机不是「拉不到镜像」。MinIO 的 GitHub 仓库已于 2026-02 归档,**不再产出安全补丁**,
# 已累积 CVE-2025-62506 / CVE-2026-39414 / CVE-2026-40344 / CVE-2026-42600 且不修。
# 本项目存身份证、实名材料与用户照片 —— 无人修 CVE 才是真正的驱动因素。
# (Docker Hub 上 minio/minio 仓库自 2026-09-12 起返回 404 只是表象;quay.io 仍在提供,
# 2026-09-22 核实时发现 quay 上仍有 2026-04 的 hotfix tag,故「换源」可以再拖,
# 「无人修 CVE」不能拖。)
#
# 本机此前正是用 quay.io 的镜像重打 tag 顶替才跑通的(见 known-risks KR-01),
# 但那只是**本地绕过**、没有同步回仓库,于是「本地能起栈、CI 拉不到」。
# 现在统一改为 quay.io,让本地与 CI 拉同一个镜像,消除这处不一致。
# 选型依据来自本机实测而非文档推断:本项目只用到极窄的 S3 操作面
# —— presigned PUT/GET + putObject + 两个 bucket,无 versioning / lifecycle / policy。
# SeaweedFS 4.47 在 path-style 下这三项全部通过且**字节一致**。
#
# 版本来源(2026-09-22 于 CI 实测):迁移初期先用了 quay.io 的 latest,
# 并在 e2e job 里打印实际版本,得到
# minio version RELEASE.2025-09-07T16-13-09Z (commit-id=07c3a429bfed…)
# 即 latest 当前正指向这个版本 —— 与本仓库原先在 Docker Hub 上钉的 tag 完全一致。
# 现在据此钉死,恢复「可复现」这一原则(若日后 quay 上的 latest 滚动到别的版本,
# 本配置不受影响)。
# 长期风险:quay 上的 MinIO 镜像已冻结(最后发布 2025-09),同样可能下架,
# 届时需自建镜像或迁移到其他 S3 兼容存储 ——
# 「依赖第三方镜像」本身就是一条外部依赖风险。
image: quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z
container_name: danyang-match-minio
command: server /data --console-address ":9001"
# 必须显式启用鉴权:SeaweedFS 的 S3 网关**默认完全不鉴权** —— 实测匿名 GET 私有对象
# 200、匿名 PUT 200、匿名列举桶 200,AK/SK 形同虚设。对本项目的敏感数据不可接受。
# 故用 -s3.config 注入 identity;配好后匿名访问回到 403(已实测)。
#
# 版本钉 4.47(实测 `weed version` = 4.47 c50733600),不用 latest:
# 滚动 tag 会让 CI 拉到与本地验证时不同的镜像,属不可复现的偶发失败来源。
image: chrislusf/seaweedfs:4.47
container_name: danyang-match-seaweedfs
# 凭据不入库:compose 无法把变量插值进挂载文件的内容,故在容器内把环境变量
# 渲染成 s3cfg.json 再交给 weed。(未加引号的 heredoc 是故意的:需要展开变量。)
# 必须覆盖 entrypoint:该镜像的 ENTRYPOINT 是 /usr/bin/weed,
# 只写 command 会被追加成 `weed sh -c ...` → `weed: unknown subcommand "sh"`(实测踩到)。
entrypoint: ["sh", "-c"]
command:
- |
cat > /tmp/s3cfg.json <<CFG
{"identities":[{"name":"app","credentials":[{"accessKey":"$${MINIO_ACCESS_KEY}","secretKey":"$${MINIO_SECRET_KEY}"}],"actions":["Admin","Read","Write","List","Tagging"]}]}
CFG
# -volume.max=100 是必需的,不是调优:
# SeaweedFS 把 **bucket 名当作 collection**,每个 collection 至少要分到一个 volume。
# 而 volume server 默认上限只有 8 个(实测 /dir/status:Volumes 8、Max 8、Free 0),
# 于是第二个 bucket 写入时 master 报
# failed to find writable volumes for collection:public … topo failed to pick 1 from 0 node candidates
# 表现为 put_object 返回 InternalError —— 错误信息完全不提 volume 配额,
# 不知道这条就只会反复重试。volume 是按需增长的(/data 下空卷仅 8 字节),
# 故调大上限不占实际空间。
exec weed server -s3 -s3.port=8333 -s3.config=/tmp/s3cfg.json -dir=/data -volume.max=100
environment:
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:?请在本地 .env 设置 MINIO_ACCESS_KEY}
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:?请在本地 .env 设置 MINIO_SECRET_KEY}
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:?请在本地 .env 设置 MINIO_ACCESS_KEY}
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:?请在本地 .env 设置 MINIO_SECRET_KEY}
ports:
- "9003:9000"
- "9004:9001"
- "8333:8333" # S3 网关:后端签名、客户端直传都走它
- "9333:9333" # master:集群状态 / 排查用
- "8888:8888" # filer:storage-init 建桶用
volumes:
- minio_data:/data
- seaweedfs_data:/data
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 10s
# 不能用 curl -f:鉴权开启后匿名列桶返回 403,而 403 恰恰说明网关正在服务。
# 因此显式接受 200(未鉴权时)与 403(已鉴权时)两种状态码。
test: ["CMD-SHELL", "code=$$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8333/); [ \"$$code\" = 200 ] || [ \"$$code\" = 403 ]"]
interval: 5s
timeout: 5s
retries: 5
retries: 24
storage-init:
# SeaweedFS 与 MinIO 的一个关键差异:**PutObject 到不存在的 bucket 会直接失败**
# (实测:匿名下 AccessDenied、正确凭据下 404),不会自动建桶。
# 而本项目全仓库没有任何 createBucket 代码或初始化脚本 —— 桶必须显式预建,
# 否则换完存储照片上传依然是断的(这一点在 MinIO 时代就已是既成缺陷,
# 见 tests/e2e/storage_link_probe.py 的说明)。
#
# 建桶走 filer 的目录接口(POST /buckets/<name>/),无需 SigV4 签名,
# 因此可复用 seaweedfs 镜像自带的 curl,**不引入新镜像依赖**。
# 实测该方式建的目录会被 S3 网关识别为 bucket:list_buckets 可见、
# head_bucket 200、put_object 成功,且鉴权照常生效(匿名 GET 403)。
#
# 幂等语义(实测):首次 POST=201,已存在 POST=409,GET 到目录=200。
# 三者都视为「桶已就位」,故重跑 up 不会失败。
image: chrislusf/seaweedfs:4.47
container_name: danyang-match-storage-init
depends_on:
seaweedfs:
condition: service_healthy
entrypoint:
- sh
- -c
- |
for b in public private; do
code=$$(curl -s -o /dev/null -w '%{http_code}' "http://seaweedfs:8888/buckets/$$b/" || echo 000)
if [ "$$code" = "200" ]; then echo "bucket $$b: already exists"; continue; fi
i=0
while [ $$i -lt 30 ]; do
code=$$(curl -s -o /dev/null -w '%{http_code}' -X POST "http://seaweedfs:8888/buckets/$$b/" || echo 000)
case "$$code" in 200|201|409) break ;; esac
i=$$((i+1)); sleep 2
done
echo "bucket $$b: create -> $$code"
done
restart: "no"
backend:
build:
@@ -81,8 +136,11 @@ services:
condition: service_healthy
redis:
condition: service_healthy
minio:
seaweedfs:
condition: service_healthy
storage-init:
# 一次性容器:桶没建好时后端任何 putObject / presigned 直传都会失败。
condition: service_completed_successfully
environment:
SPRING_PROFILES_ACTIVE: docker
DB_HOST: postgres
@@ -93,7 +151,15 @@ services:
REDIS_HOST: redis
REDIS_PORT: 6379
REDIS_PASSWORD: ${REDIS_PASSWORD:?请在本地 .env 设置 REDIS_PASSWORD}
MINIO_ENDPOINT: http://minio:9000
# 变量名仍沿用 MINIO_* 前缀(服务端 Bean 与 application-*.yml 都在读它),
# 但它现在指向 SeaweedFS 的 S3 网关 —— 属历史命名,待下一轮统一改名。
# 「presigned URL 的 host 必须是对外可达地址」缺陷已修复:endpoint 拆成
# 内网签发地址(MINIO_ENDPOINT=seaweedfs:8333,后端连存储)+
# 外网访问地址(MINIO_PUBLIC_ENDPOINT=localhost: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_SECRET_KEY: ${MINIO_SECRET_KEY:?请在本地 .env 设置 MINIO_SECRET_KEY}
JWT_ACCESS_SECRET: ${JWT_ACCESS_SECRET:?请在本地 .env 设置 JWT_ACCESS_SECRET}
@@ -147,4 +213,4 @@ services:
volumes:
postgres_data:
redis_data:
minio_data:
seaweedfs_data:
+1 -1
View File
@@ -176,7 +176,7 @@ http {
proxy_set_header X-Real-IP $remote_addr;
}
# MinIO public bucket (read-only)
# SeaweedFS public bucket (read-only)
location /uploads/ {
proxy_pass http://backend/;
proxy_set_header Host $host;
+2 -2
View File
@@ -44,8 +44,8 @@ check_service "PostgreSQL" \
check_service "Redis" \
"redis-cli -h redis${REDIS_PASSWORD:+ -a ${REDIS_PASSWORD}} ping 2>/dev/null | grep -q PONG && echo OK || echo FAIL"
check_service "MinIO" \
"curl -sf http://minio:9000/minio/health/live 2>/dev/null | grep -q '\"status\":\"online\"' && echo OK || echo FAIL"
check_service "SeaweedFS" \
"curl -s -o /dev/null -w '%{http_code}' http://seaweedfs:8333/ 2>/dev/null | grep -qE '^(200|403)$' && echo OK || echo FAIL"
check_service "Backend API" \
"curl -sf http://localhost:8080/api/v1/health/live 2>/dev/null | grep -q success && echo OK || echo FAIL"
+148
View File
@@ -0,0 +1,148 @@
"""对象存储真实链路探针(手工/CI 外验证工具,非 pytest 用例)。
为什么需要它
------------
本项目 E2E 全套对对象存储**零覆盖**(2026-09-22 核实:65 例中无任何一例
触及 photo / upload / presigned)。而 `generatePresignedUrl` 只是本地签名、
不访问存储,因此"签名接口返回 200"完全不能证明对象真的能存进去。
这直接导致一类缺陷长期隐身:bucket 从未被任何代码或脚本创建过
(2026-09-22 实证:MinIO /data 下只有 .minio.sys,mc cp 报
`Bucket 'private' does not exist`),于是 `putObject` 必然失败,但所有
测试仍然全绿。
本探针覆盖 PhotoService 的真实调用路径,并要求**字节级一致**:
A. 服务端直传 <- amazonS3.putObject
B. presigned PUT <- getUploadToken 下发的 uploadUrl(客户端直传)
C. presigned GET <- getSignedUrl 下发的展示 URL
D. addressing style <- path / virtual-host(后端 AWS SDK v1 未显式设置)
用法(需 boto3 + requests,不在 CI 依赖里,故不在 CI 运行)
-------------------------------------------------------
# 正向:验证链路可用
python tests/e2e/storage_link_probe.py --endpoint http://localhost:9003 \
--ak <access-key> --sk <secret-key>
# 负向:不预建桶,应当失败 —— 用于证明本探针确实有判别力,
# 而不是恒绿。改动存储配置后务必先跑这一遍。
python tests/e2e/storage_link_probe.py --endpoint http://localhost:9003 \
--ak <ak> --sk <sk> --no-create-bucket
"""
import argparse
import sys
import boto3
import requests
from botocore.config import Config
from botocore.exceptions import ClientError
PAYLOAD = b"danyang-storage-probe-" + b"x" * 512
KEY_DIRECT = "probe/direct.bin"
KEY_SIGNED = "probe/signed.bin"
def build(endpoint, ak, sk, style):
return boto3.client(
"s3",
endpoint_url=endpoint,
aws_access_key_id=ak,
aws_secret_access_key=sk,
region_name="us-east-1",
config=Config(signature_version="s3v4", s3={"addressing_style": style}),
)
def bucket_exists(s3, bucket):
try:
s3.head_bucket(Bucket=bucket)
return True, "exists"
except ClientError as e:
return False, e.response.get("Error", {}).get("Code", "?")
except Exception as e: # noqa: BLE001 - 探针需容忍任意后端异常
return False, type(e).__name__
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--endpoint", required=True)
ap.add_argument("--ak", required=True)
ap.add_argument("--sk", required=True)
ap.add_argument("--bucket", default="private")
ap.add_argument("--no-create-bucket", action="store_true",
help="不预建桶,用于负向验证(判据应当失败)")
a = ap.parse_args()
failures = []
print(f"===== 对象存储链路探针 @ {a.endpoint} =====")
for style in ("path", "virtual"):
print(f"\n--- addressing_style={style} ---")
s3 = build(a.endpoint, a.ak, a.sk, style)
ok, detail = bucket_exists(s3, a.bucket)
if not ok:
if a.no_create_bucket:
print(f" bucket: MISSING ({detail}) -> 负向模式,跳过写入")
failures.append(f"[{style}] bucket missing")
continue
try:
s3.create_bucket(Bucket=a.bucket)
print(f" bucket: 已创建 (was {detail})")
except Exception as e: # noqa: BLE001
print(f" bucket: 创建失败 {type(e).__name__}: {str(e)[:120]}")
failures.append(f"[{style}] create_bucket")
continue
else:
print(f" bucket: {detail}")
# A 服务端直传
try:
s3.put_object(Bucket=a.bucket, Key=KEY_DIRECT, Body=PAYLOAD)
print(" A put_object : OK")
except Exception as e: # noqa: BLE001
print(f" A put_object : FAIL {type(e).__name__}: {str(e)[:110]}")
failures.append(f"[{style}] put_object")
continue
# B presigned PUT(客户端直传)
try:
url = s3.generate_presigned_url(
"put_object", Params={"Bucket": a.bucket, "Key": KEY_SIGNED}, ExpiresIn=600
)
r = requests.put(url, data=PAYLOAD, timeout=30)
if r.status_code not in (200, 201):
print(f" B presigned PUT : FAIL http={r.status_code} {r.text[:110]}")
failures.append(f"[{style}] presigned PUT")
continue
print(f" B presigned PUT : OK ({r.status_code})")
except Exception as e: # noqa: BLE001
print(f" B presigned PUT : FAIL {type(e).__name__}: {str(e)[:110]}")
failures.append(f"[{style}] presigned PUT")
continue
# C presigned GET(字节级比对)
try:
url = s3.generate_presigned_url(
"get_object", Params={"Bucket": a.bucket, "Key": KEY_SIGNED}, ExpiresIn=600
)
r = requests.get(url, timeout=30)
if r.status_code == 200 and r.content == PAYLOAD:
print(f" C presigned GET : OK ({len(r.content)} bytes, 字节一致)")
else:
print(f" C presigned GET : FAIL http={r.status_code} bytes={len(r.content)}")
failures.append(f"[{style}] presigned GET")
except Exception as e: # noqa: BLE001
print(f" C presigned GET : FAIL {type(e).__name__}: {str(e)[:110]}")
failures.append(f"[{style}] presigned GET")
print("\n===== 汇总 =====")
print(f"failures: {len(failures)}")
for f in failures:
print(" -", f)
if failures and a.no_create_bucket:
print("(负向模式:有 failures 才是正确结果,说明判据有判别力)")
return 0 if not failures else 1
if __name__ == "__main__":
sys.exit(main())
+7 -4
View File
@@ -70,11 +70,14 @@ else
log_result "Redis 连接" "FAIL" "Redis 不可用"
fi
# MinIO
if curl -sf "http://localhost:9000/minio/health/live" 2>/dev/null | grep -q online; then
log_result "MinIO 连接" "PASS"
# SeaweedFS(S3 网关)。开启鉴权后匿名根请求返回 403,403 同样说明网关正在服务,
# 故 200/403 都算存活。宿主端口:本地 8333 / staging 8334,可用 UAT_STORAGE_S3_PORT 覆盖。
S3_PORT="${UAT_STORAGE_S3_PORT:-8333}"
code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:${S3_PORT}/" 2>/dev/null || echo 000)
if [ "${code}" = "200" ] || [ "${code}" = "403" ]; then
log_result "SeaweedFS 连接" "PASS"
else
log_result "MinIO 连接" "FAIL" "对象存储不可用"
log_result "SeaweedFS 连接" "FAIL" "对象存储不可用"
fi
echo ""