Files
浅念 eb8983a06c 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>
2026-09-21 10:16:33 +08:00

92 lines
5.5 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// G1 真实 OSS 冒烟(独立脚本,凭证经运行时 env 注入,不写入任何被跟踪文件)
// 覆盖:连通/签名方言 → presign PUT 直传 → head 核验 → presign GET 取回
// → copyIfAbsent 禁止覆盖语义 → 幂等重放 → 清理 → 生命周期规则查询
// 用法:OSS_KEY=... OSS_SECRET=... OSS_ENDPOINT=... OSS_BUCKET=... node scripts/g1-oss-smoke.mjs
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const { S3Client, PutObjectCommand, HeadObjectCommand, GetObjectCommand, CopyObjectCommand, DeleteObjectCommand, GetBucketLifecycleConfigurationCommand, HeadBucketCommand } = require('../packages/storage/node_modules/@aws-sdk/client-s3');
const { getSignedUrl } = require('../packages/storage/node_modules/@aws-sdk/s3-request-presigner');
const KEY = process.env.OSS_KEY, SECRET = process.env.OSS_SECRET, ENDPOINT = process.env.OSS_ENDPOINT, BUCKET = process.env.OSS_BUCKET;
const REGION = process.env.OSS_REGION || 'oss-cn-beijing';
const PREFIX = 'photo-live/g1-smoke/';
const results = [];
const note = (name, ok, detail = '') => { results.push({ name, ok, detail }); console.log(`${ok ? '✓' : '✗'} ${name}${detail ? ' — ' + detail : ''}`); };
// 寻址风格:endpoint 已含 bucket(如 MinIO 按桶端点)→ path-style;
// 基础 endpoint(阿里云 OSS/AWS 形态)→ virtual-host。可用 OSS_FORCE_PATH 覆盖。
const endpointHasBucket = ENDPOINT.includes(`://${BUCKET}.`);
const forcePathStyle = process.env.OSS_FORCE_PATH
? process.env.OSS_FORCE_PATH === 'true'
: endpointHasBucket;
const client = new S3Client({
region: REGION,
endpoint: ENDPOINT,
credentials: { accessKeyId: KEY, secretAccessKey: SECRET },
forcePathStyle,
});
const stamp = Date.now().toString(36);
const stagingKey = `${PREFIX}staging-${stamp}.bin`;
const originalKey = `${PREFIX}originals-${stamp}.jpg`;
const payload = Buffer.from(`g1-smoke-${stamp}-` + 'x'.repeat(1024));
try {
// 0. 连通 + 寻址风格
await client.send(new HeadBucketCommand({ Bucket: BUCKET }));
note('连通/HeadBucket', true, `${forcePathStyle ? 'path-style' : 'virtual-host'} 寻址, region=${REGION}`);
// 1. presign PUT 直传
const putUrl = await getSignedUrl(client, new PutObjectCommand({ Bucket: BUCKET, Key: stagingKey, ContentType: 'image/jpeg' }), { expiresIn: 900 });
const putRes = await fetch(putUrl, { method: 'PUT', headers: { 'Content-Type': 'image/jpeg' }, body: payload });
note('presign PUT 直传', putRes.ok, putRes.ok ? `${payload.length}B` : `HTTP ${putRes.status}: ${(await putRes.text()).slice(0, 120)}`);
// 2. head 核验
const head1 = await client.send(new HeadObjectCommand({ Bucket: BUCKET, Key: stagingKey }));
note('head 核验(字节+类型)', head1.ContentLength === payload.length && String(head1.ContentType).includes('image/jpeg'), `${head1.ContentLength}B ${head1.ContentType}`);
// 3. presign GET 取回
const getUrl = await getSignedUrl(client, new GetObjectCommand({ Bucket: BUCKET, Key: stagingKey }), { expiresIn: 900 });
const getRes = await fetch(getUrl);
const back = Buffer.from(await getRes.arrayBuffer());
note('presign GET 取回一致', getRes.ok && back.equals(payload), getRes.ok ? `${back.length}B` : `HTTP ${getRes.status}`);
// 4. copyIfAbsent 禁止覆盖语义(head-check + copy + 复核)
let destHead = null;
try { destHead = await client.send(new HeadObjectCommand({ Bucket: BUCKET, Key: originalKey })); } catch { /* 不存在=正常 */ }
if (destHead) throw new Error('目标键已存在(不应发生,时间戳唯一)');
await client.send(new CopyObjectCommand({ Bucket: BUCKET, Key: originalKey, CopySource: `/${BUCKET}/${stagingKey}` }));
const head2 = await client.send(new HeadObjectCommand({ Bucket: BUCKET, Key: originalKey }));
note('受控复制 staging→originals', head2.ContentLength === payload.length, `${head2.ContentLength}B etag=${head2.ETag}`);
// 5. 幂等重放:第二次 copy 路径(head 命中后跳过)
const head3 = await client.send(new HeadObjectCommand({ Bucket: BUCKET, Key: originalKey }));
note('幂等重放(head 命中不重拷)', head3.ContentLength === payload.length);
// 6. 清理
await client.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: stagingKey }));
await client.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: originalKey }));
note('清理测试对象(DeleteObject 权限)', true);
// 7. 生命周期规则查询(S3 兼容接口可能不支持,仅探测)
try {
const lc = await client.send(new GetBucketLifecycleConfigurationCommand({ Bucket: BUCKET }));
const rules = lc.Rules || [];
const risky = rules.filter(r => !r.Filter || JSON.stringify(r.Filter).includes('photo-live') === false);
note('生命周期规则', true, rules.length ? `${rules.length} 条规则(需人工核对是否影响 photo-live/ 前缀;受影响规则 ${risky.length} 条待核)` : '无规则');
} catch (e) {
note('生命周期规则', true, 'S3 兼容口不支持查询——请控制台人工确认(需求 §5 红线)');
}
const pass = results.every(r => r.ok);
console.log(`\nG1 冒烟 ${pass ? '全部通过 ✅' : '存在失败 ❌'}`);
process.exit(pass ? 0 : 1);
} catch (e) {
note('执行中断', false,
`code=${e.Code || e.name} http=${e.$metadata?.httpStatusCode} ` +
`reqId=${e.$metadata?.requestId || e.RequestId || '-'} ` +
`msg=${(e.Message || e.message || '').slice(0, 160)}`);
console.log('\nG1 冒烟失败 ❌');
process.exit(1);
}