familyId = $familyId; $this->cfg = $cfg; } public function id(): string { return 'oss'; } public function validateConfig(): string { if (trim((string)$this->cfg['bucket']) === '') { return '没有填 Bucket 名称'; } if (trim((string)$this->cfg['endpoint']) === '') { return '没有填 Endpoint'; } $auth = ($this->cfg['auth_mode'] ?? 'ak') === 'ecs_role' || (trim((string)($this->cfg['access_key_id'] ?? '')) !== '' && trim((string)($this->cfg['access_key_secret'] ?? '')) !== ''); if (!$auth) { return '凭据不完整:要么填 AccessKey,要么选「用服务器角色」'; } return ''; } // ---------------- 凭据 ---------------- private function credentials(): array { if ($this->cred !== null) { return $this->cred; } if (($this->cfg['auth_mode'] ?? 'ak') === 'ecs_role') { $c = EcsMeta::ramCredentials($this->familyId, (string)($this->cfg['ram_role_name'] ?? '')); if (empty($c['ok'])) { return $this->cred = ['ok' => false, 'error' => $c['error'] ?? '取临时凭据失败']; } return $this->cred = $c; } $id = trim((string)($this->cfg['access_key_id'] ?? '')); $secret = trim((string)($this->cfg['access_key_secret'] ?? '')); if ($id === '' || $secret === '') { return $this->cred = ['ok' => false, 'error' => '没有填 AccessKey ID / AccessKey Secret']; } return $this->cred = ['ok' => true, 'id' => $id, 'secret' => $secret, 'token' => '', 'error' => '']; } // ---------------- endpoint / 域名 ---------------- private function useHttps(): bool { return (string)($this->cfg['https'] ?? '1') !== '0'; } private function scheme(): string { return $this->useHttps() ? 'https' : 'http'; } private function endpoint(): string { return trim((string)$this->cfg['endpoint']); } private function host(): string { return trim((string)$this->cfg['bucket']) . '.' . $this->endpoint(); } /** 内网 endpoint 只从标准域名推导,自定义/VPC 一律不猜 */ private function internalEndpoint(): string { if ((string)($this->cfg['internal_endpoint'] ?? '1') === '0') { return ''; } $ep = $this->endpoint(); if ($ep === '' || strpos($ep, '-internal.') !== false) { return ''; } if (preg_match('#^(oss-[a-z0-9-]+)\.aliyuncs\.com$#i', $ep, $m)) { return $m[1] . '-internal.aliyuncs.com'; } return ''; } /** 内网是否可用:先比地域(零成本),必要时才真探一次 */ private function internalUsable(): bool { $internal = $this->internalEndpoint(); if ($internal === '') { return false; } $cached = json_decode((string)Setting::get($this->familyId, 'cache_oss_internal', ''), true); if (is_array($cached) && ($cached['endpoint'] ?? '') === $internal && !empty($cached['expires']) && $cached['expires'] > time()) { return !empty($cached['ok']); } return $this->probeInternal($internal); } private function probeInternal(string $internal): bool { $ok = false; $reason = ''; $bucketRegion = $this->bucketRegion(); $ecsRegion = EcsMeta::region($this->familyId); if ($bucketRegion !== '' && $ecsRegion !== '' && $bucketRegion !== $ecsRegion) { $reason = "本机在 {$ecsRegion}、桶在 {$bucketRegion},不是同一地域"; } else { $cred = $this->credentials(); if (!empty($cred['ok'])) { $url = $this->scheme() . '://' . trim((string)$this->cfg['bucket']) . '.' . $internal . '/'; // HEAD 探测:即使没 ListBucket 权限回 403,也证明网络可达 $res = Http::send('HEAD', $url, $this->readHeaders('/', $cred, [], 'HEAD'), null, 3, 2); $ok = $res['status'] > 0; $reason = $ok ? '' : ('连接失败:' . (string)($res['error'] ?? '')); } else { $reason = '凭据不可用'; } } Setting::set($this->familyId, 'cache_oss_internal', json_encode([ 'endpoint' => $internal, 'ok' => $ok ? 1 : 0, 'reason' => $reason, 'expires' => time() + ($ok ? 3600 : 1800), ], JSON_UNESCAPED_UNICODE)); return $ok; } /** 桶所在地域(用 GetBucketLocation,失败则从 endpoint 推) */ private function bucketRegion(): string { if (preg_match('#^oss-([a-z0-9-]+)\.aliyuncs\.com$#i', $this->endpoint(), $m)) { return $m[1]; } return ''; } // ---------------- 签名 ---------------- /** * @param string $subresource 子资源串(如 `?x-oss-process=image/resize,w_400`), * **必须计入 CanonicalizedResource**,否则 403。 */ private function authorization(string $method, string $key, string $contentType, string $date, array $cred, string $subresource = ''): string { $canonicalHeaders = ''; if ((string)($cred['token'] ?? '') !== '') { $canonicalHeaders = "x-oss-security-token:{$cred['token']}\n"; } $canonicalResource = '/' . trim((string)$this->cfg['bucket']) . '/' . ltrim($key, '/') . $subresource; $stringToSign = $method . "\n" . "\n" // Content-MD5(未使用) . $contentType . "\n" . $date . "\n" . $canonicalHeaders . $canonicalResource; $signature = base64_encode(hash_hmac('sha1', $stringToSign, $cred['secret'], true)); return 'OSS ' . $cred['id'] . ':' . $signature; } private function writeHeaders(string $method, string $key, string $contentType, array $cred): array { $date = gmdate('D, d M Y H:i:s \G\M\T'); $h = [ 'Date: ' . $date, 'Content-Type: ' . $contentType, 'Authorization: ' . $this->authorization($method, $key, $contentType, $date, $cred), ]; if ((string)($cred['token'] ?? '') !== '') { $h[] = 'x-oss-security-token: ' . $cred['token']; } return $h; } /** 读请求刻意不发 Content-Type,签名串里传空串 */ private function readHeaders(string $key, array $cred, array $extra = [], string $method = 'GET', string $subresource = ''): array { $date = gmdate('D, d M Y H:i:s \G\M\T'); $h = [ 'Date: ' . $date, 'Authorization: ' . $this->authorization($method, $key, '', $date, $cred, $subresource), ]; if ((string)($cred['token'] ?? '') !== '') { $h[] = 'x-oss-security-token: ' . $cred['token']; } foreach ($extra as $x) { $h[] = $x; } return $h; } // ---------------- 对象操作 ---------------- private function objectKey(string $key): string { $prefix = trim((string)($this->cfg['prefix'] ?? ''), '/'); $key = ltrim($key, '/'); return $prefix === '' ? $key : $prefix . '/' . $key; } private function objectUrl(string $key): string { return $this->scheme() . '://' . $this->host() . '/' . Http::encodeKey($key); } public function putFile(string $srcPath, string $key, ?string $mime = null): array { if (!is_file($srcPath)) { return ['ok' => false, 'error' => '本地文件不存在']; } $binary = @file_get_contents($srcPath); if ($binary === false) { return ['ok' => false, 'error' => '读不出本地文件']; } return $this->putContents($binary, $key, $mime); } public function putContents(string $binary, string $key, ?string $mime = null): array { if ($err = $this->validateConfig()) { return ['ok' => false, 'error' => $err]; } $cred = $this->credentials(); if (empty($cred['ok'])) { return ['ok' => false, 'error' => $cred['error']]; } $key = $this->objectKey($key); $mime = $mime ?: 'application/octet-stream'; $res = Http::send('PUT', $this->objectUrl($key), $this->writeHeaders('PUT', $key, $mime, $cred), $binary, 60); if (!$res['ok']) { return ['ok' => false, 'error' => '上传到 OSS 失败:' . Http::shortError($res)]; } return ['ok' => true, 'error' => '']; } /** * 服务端拷贝(CopyObject)—— v8 迁移用:同一个桶里把 movielog/ 前缀的对象 * 复制成 family/ 前缀,不走流量、不用下载重传。 * * @param string $srcObjectKey 源对象**完整键**(含前缀,如 movielog/uploads/posters/x.jpg) * @param string $destRelKey 目标**相对键**(不含本站前缀,如 posters/x.jpg) * * ⚠️ 签名注意:所有随请求发送的 x-oss-* 头都必须按字典序拼进签名串 * (copy-source 在 security-token 之前),漏一个就是 SignatureDoesNotMatch。 * CopyObject 不需要 Content-Type(拷贝沿用源对象的元数据),签名串里留空。 */ public function copyObject(string $srcObjectKey, string $destRelKey): array { if ($err = $this->validateConfig()) { return ['ok' => false, 'error' => $err]; } $cred = $this->credentials(); if (empty($cred['ok'])) { return ['ok' => false, 'error' => $cred['error']]; } $bucket = trim((string)$this->cfg['bucket']); $srcKey = ltrim($srcObjectKey, '/'); $destKey = $this->objectKey($destRelKey); $date = gmdate('D, d M Y H:i:s \G\M\T'); $copySource = '/' . $bucket . '/' . Http::encodeKey($srcKey); $canonical = "x-oss-copy-source:{$copySource}\n"; if ((string)($cred['token'] ?? '') !== '') { $canonical .= "x-oss-security-token:{$cred['token']}\n"; } $stringToSign = "PUT\n\n\n{$date}\n{$canonical}/{$bucket}/{$destKey}"; $signature = base64_encode(hash_hmac('sha1', $stringToSign, $cred['secret'], true)); $h = [ 'Date: ' . $date, 'x-oss-copy-source: ' . $copySource, 'Authorization: OSS ' . $cred['id'] . ':' . $signature, ]; if ((string)($cred['token'] ?? '') !== '') { $h[] = 'x-oss-security-token: ' . $cred['token']; } $res = Http::send('PUT', $this->objectUrl($destKey), $h, null, 60); if (!$res['ok']) { return ['ok' => false, 'error' => 'OSS CopyObject 失败:' . Http::shortError($res)]; } return ['ok' => true, 'error' => '']; } public function delete(string $key): array { $cred = $this->credentials(); if (empty($cred['ok'])) { return ['ok' => false, 'error' => $cred['error']]; } $key = $this->objectKey($key); $res = Http::send('DELETE', $this->objectUrl($key), $this->writeHeaders('DELETE', $key, '', $cred), null, 30); if (!$res['ok'] && (int)$res['status'] !== 404) { return ['ok' => false, 'error' => '删除 OSS 对象失败:' . Http::shortError($res)]; } return ['ok' => true, 'error' => '']; } /** 服务端读取(内网优先 + 自动回退外网) */ /** * 取对象。 * * @param string $process OSS 图片处理参数(如 `image/resize,l_400/quality,q_80`)。 * **空 = 取原图**。非空时: * ① URL 追加 `?x-oss-process=...` * ② 签名把它计入 CanonicalizedResource(不然 403) * * 🔴 #56(用户 2026-11-05):「OSS在获取图片的时候,好像可以传一个参数, * 它会给你返回一张压缩过的图片文件。」—— 就是 `x-oss-process`。 * **实时处理,原图不动**,所以不用改上传流程、不用二次存储。 */ public function get(string $key, array $extra = [], string $process = ''): array { if ($err = $this->validateConfig()) { return ['ok' => false, 'status' => 0, 'headers' => [], 'body' => '', 'error' => $err]; } $cred = $this->credentials(); if (empty($cred['ok'])) { return ['ok' => false, 'status' => 0, 'headers' => [], 'body' => '', 'error' => $cred['error']]; } $key = $this->objectKey($key); /* 子资源串:签名与 URL 必须**用同一个值**,否则签名对不上 */ $sub = ''; if ($process !== '') { $sub = '?x-oss-process=' . $process; } $headers = $this->readHeaders($key, $cred, $extra, 'GET', $sub); $hosts = []; $internal = $this->internalEndpoint(); if ($internal !== '' && $this->internalUsable()) { $hosts[] = $internal; } $hosts[] = $this->endpoint(); $res = null; foreach ($hosts as $i => $host) { $isInternal = ($internal !== '' && $host === $internal); /* ⚠️ process 里的逗号、下划线不需要 URL 编码(OSS 认原样), 但 `?` 与 `=` 必须保留为分隔符 —— 所以直接拼,不 encode。 */ $url = $this->scheme() . '://' . trim((string)$this->cfg['bucket']) . '.' . $host . '/' . Http::encodeKey($key) . $sub; $res = Http::send('GET', $url, $headers, null, 60, $isInternal ? 2 : null); if ((int)$res['status'] === 0 && $isInternal && isset($hosts[$i + 1])) { continue; // 内网连不上,换外网 } break; } return $res ?: ['ok' => false, 'status' => 0, 'headers' => [], 'body' => '', 'error' => '连不上 OSS']; } public function exists(string $key): bool { $cred = $this->credentials(); if (empty($cred['ok'])) { return false; } $key = $this->objectKey($key); $res = Http::send('HEAD', $this->objectUrl($key), $this->readHeaders($key, $cred, [], 'HEAD'), null, 15); return (int)$res['status'] === 200; } /** 对外地址:优先自定义域名/CDN */ public function url(string $key): string { $key = $this->objectKey($key); $custom = trim((string)($this->cfg['custom_domain'] ?? '')); if ($custom !== '') { $host = Http::hostOf($custom); return (strpos($custom, 'http') === 0 ? rtrim($custom, '/') : $this->scheme() . '://' . $host) . '/' . Http::encodeKey($key); } return $this->objectUrl($key); } // ---------------- 连通性诊断 ---------------- /** * 一步一步把「连不上 OSS」的原因定位到具体的哪一环。 * * 为什么不能只用一个"测试存储是否可用"的按钮: * 失败时它只会说"写入失败" —— 而失败可能是 6 种完全不同的原因 * (没填 bucket / 凭据取不到 / 桶地域与本机不同 / 内网域名解析不了 / * AK 没这个桶的权限 / 桶 ACL 不是私有…),用户根本无从下手。 * 这里把每一步的**结论与耗时**都摊开,一眼能看出卡在哪。 * * @return array */ public function diagnose(): array { $steps = []; // ① 配置完整性(纯本地判断,最快,先挡掉一半问题) $err = $this->validateConfig(); $steps[] = [ 'name' => '配置完整性', 'ok' => $err === '', 'detail' => $err !== '' ? $err : sprintf('bucket=%s endpoint=%s 前缀=%s 凭据方式=%s', $this->cfg['bucket'] ?: '(空)', $this->endpoint() ?: '(空)', trim((string)($this->cfg['prefix'] ?? '')) !== '' ? trim((string)$this->cfg['prefix']) : '(无)', ($this->cfg['auth_mode'] ?? 'ak') === 'ecs_role' ? '服务器绑定角色' : 'AccessKey'), 'ms' => 0, ]; if ($err !== '') { return $steps; // 配置都没填全,后面每一步都会以同样的原因失败,不必刷屏 } // ② 凭据 $t0 = microtime(true); $cred = $this->credentials(); $ms = (int)round((microtime(true) - $t0) * 1000); $steps[] = [ 'name' => '取凭据', 'ok' => !empty($cred['ok']), 'detail' => !empty($cred['ok']) ? (($this->cfg['auth_mode'] ?? 'ak') === 'ecs_role' ? '已从实例 RAM 角色取到临时凭据' : '使用填写的 AccessKey') : (string)($cred['error'] ?? '取不到凭据'), 'ms' => $ms, ]; if (empty($cred['ok'])) { return $steps; } // ③ 地域比对 —— 跨地域时内网域名能解析但永远连不上,必须先说出来 $bucketRegion = $this->bucketRegion(); $ecsRegion = EcsMeta::region($this->familyId); $sameRegion = ($bucketRegion === '' || $ecsRegion === '' || $bucketRegion === $ecsRegion); $steps[] = [ 'name' => '地域', 'ok' => $sameRegion, 'detail' => $bucketRegion === '' || $ecsRegion === '' ? sprintf('本机地域=%s 桶地域=%s(信息不全,跳过内网加速)', $ecsRegion ?: '未知', $bucketRegion ?: '未知') : ($sameRegion ? "本机与桶都在 {$bucketRegion},内网可达" : "本机在 {$ecsRegion}、桶在 {$bucketRegion} —— **不是同一地域**,内网走不通,只能走公网"), 'ms' => 0, ]; // ④ 公网 endpoint 可达性(HEAD 桶,即使 403 也证明网络通了) $url = $this->scheme() . '://' . $this->host() . '/'; $res = Http::send('HEAD', $url, $this->readHeaders('/', $cred, [], 'HEAD'), null, 10, 5); $steps[] = [ 'name' => '公网 endpoint 可达', 'ok' => (int)$res['status'] > 0, 'detail' => (int)$res['status'] > 0 ? sprintf('%s → HTTP %d%s', $url, (int)$res['status'], (int)$res['status'] === 403 ? '(可达;403 只是没有 ListBucket 权限,正常)' : '') : (string)($res['error'] ?? '连不上'), 'ms' => (int)($res['ms'] ?? 0), ]; if ((int)$res['status'] === 0) { return $steps; // 公网都不通,后面没意义 } // ⑤ 内网 endpoint(只在同地域且配了内网时才测;跨地域测它是白等超时) $internal = $this->internalEndpoint(); if ($internal !== '') { if (!$sameRegion) { $steps[] = ['name' => '内网 endpoint', 'ok' => true, 'detail' => '跳过:跨地域时内网域名能解析但连不上,测了只会白等一次超时', 'ms' => 0]; } else { $iurl = $this->scheme() . '://' . trim((string)$this->cfg['bucket']) . '.' . $internal . '/'; $ires = Http::send('HEAD', $iurl, $this->readHeaders('/', $cred, [], 'HEAD'), null, 5, 2); $steps[] = [ 'name' => '内网 endpoint(服务端读取更快且免流量费)', 'ok' => (int)$ires['status'] > 0, 'detail' => (int)$ires['status'] > 0 ? sprintf('%s → HTTP %d', $iurl, (int)$ires['status']) : sprintf('%s → %s(会自动回退公网,功能不受影响)', $iurl, (string)($ires['error'] ?? '连不上')), 'ms' => (int)($ires['ms'] ?? 0), ]; } } // ⑥ 真写一个对象再读回来再删掉 —— 唯一能证明"有权限"的一步 $probeKey = 'avatars/.diag-' . bin2hex(random_bytes(6)) . '.txt'; $payload = 'familybook-diag-' . time(); $t0 = microtime(true); $put = $this->putContents($payload, $probeKey, 'text/plain'); $putMs = (int)round((microtime(true) - $t0) * 1000); if (empty($put['ok'])) { $steps[] = ['name' => '写入(PUT)', 'ok' => false, 'detail' => (string)($put['error'] ?? '写入失败') . ' —— 常见原因:这个 AK 对这个桶没有 PutObject 权限,或桶 ACL 不是私有/公共读写。', 'ms' => $putMs]; return $steps; } $steps[] = ['name' => '写入(PUT)', 'ok' => true, 'detail' => '对象已写入 ' . $probeKey, 'ms' => $putMs]; $get = $this->get($probeKey); $same = !empty($get['ok']) && trim((string)$get['body']) === $payload; $steps[] = [ 'name' => '读回(GET)', 'ok' => $same, 'detail' => $same ? '读回内容与写入一致' : sprintf('写入成功但读回来对不上(HTTP %d)—— 多半是内网/外网 endpoint 指错,或桶有版本/加密设置', (int)($get['status'] ?? 0)), 'ms' => (int)($get['ms'] ?? 0), ]; $del = $this->delete($probeKey); $steps[] = [ 'name' => '删除(DELETE)', 'ok' => !empty($del['ok']), 'detail' => !empty($del['ok']) ? '探针对象已清理' : (string)($del['error'] ?? '删不掉(会留下垃圾文件)'), 'ms' => 0, ]; return $steps; } }