feat(storage,g1): real-OSS support and G1 smoke — gate CLOSED

G1 (skipped at P1 for lack of credentials) executed 2026-09-21 against the
user-provided bucket live-album @ oss-cn-beijing:

Raw-SDK smoke (scripts/g1-oss-smoke.mjs, creds via runtime env only):
- HeadBucket / presign PUT direct upload / head byte+type verify /
  presign GET round-trip / copy staging→originals / idempotent replay /
  DeleteObject cleanup — all pass.
- S3-compat lifecycle query unsupported → console manual check remains the
  only open G1 sub-item (need: confirm no lifecycle rule hits photo-live/).

Adapter-level (production path, createStorageFromEnv → real OSS):
- presignPut+PUT 200, head verify, presignGet round-trip equal,
  copyIfAbsent idempotent (created:true→false, same etag),
  forbid-overwrite guard throws UPLOAD_VERIFY_FAILED — all pass.

Findings locked in:
- Addressing: OSS S3-compat requires virtual-host style with BASE endpoint
  (path-style → 403 signature mismatch). StoragePort adapter gains optional
  MinioStorageConfig.forcePathStyle + S3_FORCE_PATH_STYLE env (default true
  keeps local MinIO behavior); required-field validation fixed to string
  fields only (boolean false ≠ missing).
- .env.production.example-cn S3 section updated to the confirmed shape
  (beijing endpoint/region, virtual-host, bucket live-album).

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-21 10:16:33 +08:00
parent c26d6b9a73
commit eb8983a06c
4 changed files with 116 additions and 10 deletions
+9 -4
View File
@@ -27,10 +27,15 @@ export function createStorageFromEnv(): MinioStorageAdapter {
accessKey: process.env.S3_ACCESS_KEY,
secretKey: process.env.S3_SECRET_KEY,
bucket: process.env.S3_BUCKET,
} satisfies Record<keyof MinioStorageConfig, string | undefined>;
const missing = Object.entries(config)
.filter(([, value]) => !value)
.map(([key]) => `S3_${key.toUpperCase()}`);
// S3_FORCE_PATH_STYLE 可选:缺省 true(MinIO path-style);阿里云 OSS 等
// 云形态设 false(virtual-host + 基础 endpoint)——G1 实测定案。
forcePathStyle: process.env.S3_FORCE_PATH_STYLE
? process.env.S3_FORCE_PATH_STYLE === 'true'
: true,
};
// 只校验必填字符串字段(forcePathStyle 是布尔,false 不代表缺失)。
const required = ['endpoint', 'region', 'accessKey', 'secretKey', 'bucket'] as const;
const missing = required.filter((key) => !config[key]).map((key) => `S3_${key.toUpperCase()}`);
if (missing.length > 0) {
throw new Error(`createStorageFromEnv: 缺少环境变量 ${missing.join(', ')}(S3_* 配置见仓库根 .env)`);
}
+9 -2
View File
@@ -18,6 +18,8 @@ export interface MinioStorageConfig {
accessKey: string;
secretKey: string;
bucket: string;
/** 寻址风格:true=path-style(MinIO 默认);false=virtual-host(OSS/AWS 云形态)。 */
forcePathStyle?: boolean;
}
/** S3 错误统一判定:HeadObject 对缺失键返回 404/NotFound(无 body 错误码),GetObject 为 NoSuchKey。 */
@@ -32,16 +34,21 @@ export class MinioStorageAdapter implements StoragePort {
private readonly bucket: string;
constructor(config: MinioStorageConfig) {
const missing = (Object.keys(config) as Array<keyof MinioStorageConfig>).filter((k) => !config[k]);
// 只校验必填字符串(forcePathStyle 是可选布尔,false≠缺失)。
const required = ['endpoint', 'region', 'accessKey', 'secretKey', 'bucket'] as const;
const missing = required.filter((k) => !config[k]);
if (missing.length > 0) {
throw new Error(`MinioStorageAdapter: 存储配置缺失 ${missing.join(', ')}(S3_* 环境变量见仓库根 .env)`);
}
this.bucket = config.bucket;
// 寻址风格:本地 MinIO 用 path-style(默认 true);阿里云 OSS 等云形态用
// virtual-host(S3_FORCE_PATH_STYLE=false + 基础 endpoint 不含 bucket)——
// G1 实测:OSS 兼容口 path-style 会 403 签名不匹配。
this.client = new S3Client({
endpoint: config.endpoint,
region: config.region,
credentials: { accessKeyId: config.accessKey, secretAccessKey: config.secretKey },
forcePathStyle: true,
forcePathStyle: config.forcePathStyle ?? true,
});
}