cfg = $cfg; } public function id() { return 'oss'; } public function label() { return '阿里云 OSS'; } // ---------- 凭据 ---------- private function credentials() { if ($this->cred !== null) return $this->cred; if (($this->cfg['auth_mode'] ?? 'ak') === 'ecs_role') { $c = aliyunEcsRamCredentials($this->cfg['ram_role_name'] ?? ''); if (empty($c['ok'])) return $this->cred = ['ok' => false, 'error' => $c['error']]; return $this->cred = $c; } if (trim((string)($this->cfg['access_key_id'] ?? '')) === '' || trim((string)($this->cfg['access_key_secret'] ?? '')) === '') { return $this->cred = ['ok' => false, 'error' => '未填写 AccessKey ID / AccessKey Secret']; } return $this->cred = [ 'ok' => true, 'id' => trim($this->cfg['access_key_id']), 'secret' => trim($this->cfg['access_key_secret']), 'token' => '', 'error' => '', ]; } // ---------- 寻址 ---------- private function useHttps() { return ($this->cfg['https'] ?? '1') === '1'; } private function scheme() { return $this->useHttps() ? 'https' : 'http'; } private function endpoint() { return preg_replace('#^https?://#i', '', trim((string)($this->cfg['endpoint'] ?? ''))); } /** * 服务端读取时优先使用的内网 endpoint(ECS ↔ OSS 同地域内网流量免费、更快)。 * 只把标准的 `.aliyuncs.com` 推导为 `-internal.aliyuncs.com`; * 自定义域名 / VPC 域名 / 已是内网域名的一律不猜,返回空串表示"不适用"。 */ private function internalEndpoint() { if (($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 objectKey($key) { $key = ltrim((string)$key, '/'); $prefix = trim((string)($this->cfg['prefix'] ?? ''), '/'); return $prefix === '' ? $key : $prefix . '/' . $key; } private function host() { return trim((string)$this->cfg['bucket']) . '.' . $this->endpoint(); } private function objectUrl($key) { return $this->scheme() . '://' . $this->host() . '/' . storageEncodeKey($key); } // 对外访问地址(优先自定义域名) public function url($key) { $key = $this->objectKey($key); $custom = trim((string)($this->cfg['custom_domain'] ?? '')); if ($custom !== '') { return storageHostOf($custom, $this->useHttps()) . '/' . storageEncodeKey($key); } return $this->scheme() . '://' . $this->host() . '/' . storageEncodeKey($key); } // ---------- 签名 ---------- private function authorization($method, $key, $contentType, $date, $cred) { $canonicalizedHeaders = ''; if (($cred['token'] ?? '') !== '') { $canonicalizedHeaders = "x-oss-security-token:{$cred['token']}\n"; } $canonicalizedResource = '/' . trim((string)$this->cfg['bucket']) . '/' . ltrim((string)$key, '/'); $stringToSign = $method . "\n" // VERB . "\n" // Content-MD5(未使用) . $contentType . "\n" // Content-Type . $date . "\n" // Date . $canonicalizedHeaders . $canonicalizedResource; $signature = base64_encode(hash_hmac('sha1', $stringToSign, $cred['secret'], true)); return 'OSS ' . $cred['id'] . ':' . $signature; } private function buildHeaders($method, $key, $contentType, $cred) { $date = gmdate('D, d M Y H:i:s \G\M\T'); $headers = [ 'Date: ' . $date, 'Content-Type: ' . $contentType, 'Authorization: ' . $this->authorization($method, $key, $contentType, $date, $cred), ]; if (($cred['token'] ?? '') !== '') { $headers[] = 'x-oss-security-token: ' . $cred['token']; } return $headers; } /** * GET/HEAD 的请求头。故意**不发送** Content-Type 头(签名串里的 Content-Type 用空串), * 这是 OSS 签名 V1 对读取类请求的标准做法。 * $extra 里放条件/断点头(If-None-Match、If-Modified-Since、Range)—— * 它们不属于 CanonicalizedOSSHeaders(只有 x-oss-* 才计入),因此不影响签名。 */ private function readHeaders($key, $cred, array $extra = [], $method = 'GET') { $date = gmdate('D, d M Y H:i:s \G\M\T'); $headers = [ 'Date: ' . $date, 'Authorization: ' . $this->authorization($method, $key, '', $date, $cred), ]; if (($cred['token'] ?? '') !== '') { $headers[] = 'x-oss-security-token: ' . $cred['token']; } foreach ($extra as $h) { $headers[] = $h; } return $headers; } // ---------- 内网 endpoint 可用性 ---------- // // 内网 endpoint(oss-<地域>-internal.aliyuncs.com)只在**与桶同地域**的 ECS 上 // 可路由。跨地域时 DNS 会照常解析出一个 A 记录(解析耗时 ~1ms,看不出异常), // 但 TCP 连接永远建不起来,只能等满连接超时。若不做判定就无脑"内网优先", // 每读一张图都要先白等一次超时再回退公网——图片多的页面直接慢到不可用。 // 所以这里先比地域(零成本、结论确定),拿不准才真的探一次,并把结论缓存下来。 /** 桶所在地域(从标准 endpoint 解析);自定义/VPC 域名返回空串。 */ private function bucketRegion() { if (preg_match('#^oss-([a-z0-9-]+?)(?:-internal)?\.aliyuncs\.com$#i', $this->endpoint(), $m)) { return strtolower($m[1]); } return ''; } /** * 探测内网 endpoint 是否可用,并把结论写入缓存。 * @param bool $force 忽略缓存结论重新探测(设置页"测试连通性"用) * @return array ['ok'=>bool,'reason'=>string] */ private function probeInternal($force = false) { $internal = $this->internalEndpoint(); $out = ['ok' => false, 'reason' => '']; if ($internal === '') { return ['ok' => false, 'reason' => '未启用,或 endpoint 不是标准 OSS 域名']; } $bucketRegion = $this->bucketRegion(); $ecsRegion = function_exists('aliyunEcsRegion') ? aliyunEcsRegion($force) : ''; if ($bucketRegion !== '' && $ecsRegion !== '' && $bucketRegion !== $ecsRegion) { // 跨地域:不必探测,内网一定不通 $out['reason'] = "本机在 {$ecsRegion}、桶在 {$bucketRegion},不是同一地域"; } else { $cred = $this->credentials(); if (empty($cred['ok'])) { $out['reason'] = '凭据不可用,无法探测'; } else { // 对桶根发一个签名 HEAD:只要拿到任何 HTTP 状态码就说明内网可路由 // (没有 ListBucket 权限会回 403,但这已经证明网络通)。 $url = $this->scheme() . '://' . trim((string)$this->cfg['bucket']) . '.' . $internal . '/'; $res = storageHttp('HEAD', $url, $this->readHeaders('/', $cred, [], 'HEAD'), null, 3, false, 2); $out['ok'] = $res['status'] > 0; $out['reason'] = $out['ok'] ? "内网可达(HTTP {$res['status']})" : '内网 endpoint 连接失败:' . ($res['error'] ?? ''); } } setSettingJson('cache_oss_internal', [ 'endpoint' => $internal, 'ok' => $out['ok'] ? 1 : 0, 'reason' => $out['reason'], 'expires' => time() + ($out['ok'] ? 3600 : 1800), ]); return $out; } /** 内网 endpoint 现在能不能用(结论走缓存,不会每个请求都探测)。 */ private function internalUsable() { $internal = $this->internalEndpoint(); if ($internal === '') return false; $cache = getSettingJson('cache_oss_internal', []); if (!empty($cache['expires']) && $cache['expires'] > time() && ($cache['endpoint'] ?? '') === $internal) { return !empty($cache['ok']); } return !empty($this->probeInternal()['ok']); } /** 内网这次真的连不上 → 立刻记下,半小时内不再重试(避免每张图都白等)。 */ private function markInternalDown($reason) { if ($this->internalEndpoint() === '') return; setSettingJson('cache_oss_internal', [ 'endpoint' => $this->internalEndpoint(), 'ok' => 0, 'reason' => $reason, 'expires' => time() + 1800, ]); } /** * 供设置页展示:内网 endpoint 的推导结果与可用性判定。 * @param bool $probe 强制重新探测 * @return array ['endpoint'=>string,'ok'=>bool,'reason'=>string] */ public function internalStatus($probe = false) { $internal = $this->internalEndpoint(); if ($internal === '') return ['endpoint' => '', 'ok' => false, 'reason' => '']; $cache = getSettingJson('cache_oss_internal', []); $cached = !empty($cache['expires']) && $cache['expires'] > time() && ($cache['endpoint'] ?? '') === $internal; if ($probe || !$cached) { $r = $this->probeInternal($probe); return ['endpoint' => $internal, 'ok' => !empty($r['ok']), 'reason' => (string)$r['reason']]; } return [ 'endpoint' => $internal, 'ok' => !empty($cache['ok']), 'reason' => (string)($cache['reason'] ?? ''), ]; } private function validateConfig() { foreach (['bucket' => 'Bucket 名称', 'endpoint' => 'Endpoint'] as $f => $label) { if (trim((string)($this->cfg[$f] ?? '')) === '') { return "未填写{$label}"; } } return ''; } // ---------- 操作 ---------- public function putFile($srcPath, $key, $mime = null) { return $this->putContents(file_get_contents($srcPath), $key, $mime ?: 'application/octet-stream'); } public function putContents($binary, $key, $mime = null) { 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 = storageHttp('PUT', $this->objectUrl($key), $this->buildHeaders('PUT', $key, $mime, $cred), $binary, 60); if (!$res['ok']) { return ['ok' => false, 'error' => '上传到 OSS 失败:' . $this->describe($res)]; } return ['ok' => true, 'error' => '']; } public function delete($key) { $cred = $this->credentials(); if (empty($cred['ok'])) return ['ok' => false, 'error' => $cred['error']]; $key = $this->objectKey($key); $res = storageHttp('DELETE', $this->objectUrl($key), $this->buildHeaders('DELETE', $key, '', $cred), null, 30); // 对象本就不存在时视为成功 if (!$res['ok'] && $res['status'] !== 404) { return ['ok' => false, 'error' => '删除 OSS 对象失败:' . $this->describe($res)]; } return ['ok' => true, 'error' => '']; } public function exists($key) { return true; // 远端不做逐次 HEAD 探测,交由页面兜底 } /** * 服务端读取对象(供 media.php 代理回吐)。 * 私有桶同样可读——用的是实例角色 / AccessKey 签名,与桶的公共读权限无关。 * $extra 用于透传条件头(If-None-Match / If-Modified-Since)与 Range,实现 304 短路与断点。 * 返回结构与 storageHttp() 同构:['ok','status','headers','body','error'] */ public function get($key, array $extra = []) { 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); $headers = $this->readHeaders($key, $cred, $extra); // 候选主机:内网优先(同地域内网流量免费、延迟更低),传输层失败再回退公网。 // 内网是否**真的可用**先判定(结论有缓存)——跨地域时内网不可路由, // 无脑尝试会让每张图先白等一次连接超时。 // Authorization 的 CanonicalizedResource 只含 /bucket/key,与主机无关,故可复用同一组头。 $hosts = []; $internal = $this->internalEndpoint(); if ($internal !== '' && $this->internalUsable()) $hosts[] = $internal; $hosts[] = $this->endpoint(); $res = null; foreach ($hosts as $i => $host) { $isInternal = ($internal !== '' && $host === $internal); $url = $this->scheme() . '://' . trim((string)$this->cfg['bucket']) . '.' . $host . '/' . storageEncodeKey($key); // 内网只给 2 秒连接超时:万一判定失效,也只损失 2 秒而不是默认的 10 秒 $res = storageHttp('GET', $url, $headers, null, 60, false, $isInternal ? 2 : null); if ($res['status'] === 0) { if ($isInternal) { $this->markInternalDown('连接失败:' . ($res['error'] ?? '')); } if (isset($hosts[$i + 1])) continue; // 连不上 → 换下一个主机 } break; } return $res ?: ['ok' => false, 'status' => 0, 'headers' => [], 'body' => '', 'error' => '无法连接 OSS']; } public function test() { if ($err = $this->validateConfig()) return ['ok' => false, 'error' => $err]; $cred = $this->credentials(); if (empty($cred['ok'])) return ['ok' => false, 'error' => $cred['error']]; // 探测对象也走前缀,顺便验证前缀目录的写权限 $raw = '.movielog-probe/' . date('YmdHis') . '-' . bin2hex(random_bytes(4)) . '.txt'; $key = $this->objectKey($raw); $binary = 'movielog storage probe ' . date('c'); $res = storageHttp('PUT', $this->objectUrl($key), $this->buildHeaders('PUT', $key, 'text/plain', $cred), $binary, 20); if (!$res['ok']) { return ['ok' => false, 'error' => '写入测试失败:' . $this->describe($res)]; } // 回读验证:「服务端代理」模式(media.php)完全依赖这条读取链路, // 所以测试连通性必须把 GET 也走一遍——写入成功不等于读得回来。 $read = $this->get($raw); $readErr = ''; if (!$read['ok']) { $readErr = $this->describe($read); } elseif (trim((string)$read['body']) !== trim($binary)) { $readErr = '回读到的内容与写入不一致'; } $del = $this->delete($raw); // delete() 内部会自行加前缀,故传原始键 $mode = ($this->cfg['auth_mode'] ?? 'ak') === 'ecs_role' ? 'ECS RAM 角色(免密钥)' : 'AccessKey'; $prefix = trim((string)($this->cfg['prefix'] ?? ''), '/'); // 内网 endpoint 的结论要明说:跨地域时它是**静默拖慢**的元凶, // 不报出来用户根本无从察觉。 $internalNote = ''; if ($this->internalEndpoint() !== '') { $probe = $this->probeInternal(true); $internalNote = $probe['ok'] ? "服务端读取走内网 {$probe['endpoint']}({$probe['reason']})。" : '内网 endpoint 已自动跳过(原因:' . $probe['reason'] . '),' . '读取改走公网 ' . $this->endpoint() . ',功能不受影响。'; } return [ 'ok' => true, 'read_ok' => $readErr === '', 'error' => '', 'message' => "连接成功({$mode})。桶 {$this->cfg['bucket']} 可读写," . ($prefix !== '' ? "前缀 {$prefix}/," : '') . ($readErr === '' ? '服务端读取已验证——可安全启用「服务端代理」访问方式。' : "但服务端读取失败:{$readErr}(请先排查读取权限,暂不要启用「服务端代理」)。") . $internalNote . '测试对象已清理。' . ($del['ok'] ? '' : '(注意:测试对象删除失败,请检查删除权限)'), ]; } // 把 HTTP 响应压成一句可读的错误 private function describe($res) { $body = trim((string)$res['body']); if ($body !== '') { if (preg_match('#(.*?)#s', $body, $m)) { $code = preg_match('#(.*?)#s', $body, $c) ? $c[1] : ''; return trim("{$code} {$m[1]}"); } if (preg_match('#(.*?)#s', $body, $c)) { return $c[1]; } } return $res['error']; } }