diff --git a/.env.production.example-cn b/.env.production.example-cn index 8c04d0c..660e12d 100644 --- a/.env.production.example-cn +++ b/.env.production.example-cn @@ -16,14 +16,17 @@ REDIS_URL_CN=redis://1Panel-redis-容器名或IP:6379 # sync)读取;api-cn/worker 不读此键。 DATABASE_URL_OV=mysql://photo_ov:CHANGE_ME@OV服务器IP:3306/photo_ov?connection_limit=10 -# ── 对象存储(真实阿里云 OSS 私有桶,P5/G1 冒烟脚本验证后填)── +# ── 对象存储(真实阿里云 OSS 私有桶,G1 已于 2026-09-21 实测通过)── # endpoint 用「外网 Endpoint」(api-cn 服务器上传;worker 处理读原文件)。 # bucket 私有读写:一切访问走 SigV4 现签 URL(SIGNED_URL_TTL_SECONDS)。 -S3_ENDPOINT=https://oss-cn-hangzhou.aliyuncs.com -S3_REGION=cn-hangzhou +# ⚠️ 寻址形态(G1 实测定案):基础 endpoint(不含 bucket)+ virtual-host—— +# 即 S3_FORCE_PATH_STYLE=false。OSS 兼容口在 path-style 下 403 签名不匹配。 +S3_ENDPOINT=https://oss-cn-beijing.aliyuncs.com +S3_REGION=oss-cn-beijing S3_ACCESS_KEY=CHANGE_ME_LTAI S3_SECRET_KEY=CHANGE_ME -S3_BUCKET=photo-live-prod +S3_BUCKET=live-album +S3_FORCE_PATH_STYLE=false # 现签 GET(viewer 图片)/ PUT(上传)凭证有效期秒(uploads/support.ts)。 SIGNED_URL_TTL_SECONDS=900 # 单文件上传上限字节(uploads/support.ts;50MB 与契约 UPLOAD_MAX_BYTES 建议值一致)。 diff --git a/packages/storage/src/index.ts b/packages/storage/src/index.ts index f285676..59a9b95 100644 --- a/packages/storage/src/index.ts +++ b/packages/storage/src/index.ts @@ -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; - 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)`); } diff --git a/packages/storage/src/minio.adapter.ts b/packages/storage/src/minio.adapter.ts index 1d1be85..7942700 100644 --- a/packages/storage/src/minio.adapter.ts +++ b/packages/storage/src/minio.adapter.ts @@ -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).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, }); } diff --git a/scripts/g1-oss-smoke.mjs b/scripts/g1-oss-smoke.mjs new file mode 100644 index 0000000..e495d57 --- /dev/null +++ b/scripts/g1-oss-smoke.mjs @@ -0,0 +1,91 @@ +// 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); +}