cfg = $cfg; } public function id() { return 'qiniu'; } public function label() { return '七牛云 Kodo'; } // ---------- 工具 ---------- // URL 安全 Base64(去掉填充、+→-、/→_) private function urlsafeB64($data) { return str_replace(['+', '/'], ['-', '_'], rtrim(base64_encode($data), '=')); } private function uploadHost() { $map = [ 'z0' => 'upload.qiniup.com', 'z1' => 'upload-z1.qiniup.com', 'z2' => 'upload-z2.qiniup.com', 'na0' => 'upload-na0.qiniup.com', 'as0' => 'upload-as0.qiniup.com', ]; $region = (string)($this->cfg['region'] ?? 'z0'); return $map[$region] ?? $map['z0']; } private function scheme() { return ($this->cfg['https'] ?? '1') === '1' ? 'https' : 'http'; } // 给对象键加上「对象前缀」(桶内目录)。库中始终存不带前缀的相对键, // 前缀只在驱动层拼接,因此改前缀不影响数据库里的历史记录。 private function objectKey($key) { $key = ltrim((string)$key, '/'); $prefix = trim((string)($this->cfg['prefix'] ?? ''), '/'); return $prefix === '' ? $key : $prefix . '/' . $key; } private function credentials() { $ak = trim((string)($this->cfg['access_key'] ?? '')); $sk = trim((string)($this->cfg['secret_key'] ?? '')); if ($ak === '' || $sk === '') { return ['ok' => false, 'error' => '未填写 AccessKey / SecretKey']; } return ['ok' => true, 'ak' => $ak, 'sk' => $sk]; } private function validateConfig() { foreach (['bucket' => 'Bucket 名称'] as $f => $label) { if (trim((string)($this->cfg[$f] ?? '')) === '') { return "未填写{$label}"; } } return ''; } // 生成上传凭证 private function uploadToken($key, $cred) { $policy = [ 'scope' => trim((string)$this->cfg['bucket']) . ':' . ltrim((string)$key, '/'), 'deadline' => time() + 3600, 'insertOnly' => 0, ]; $encoded = $this->urlsafeB64(json_encode($policy, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); $sign = $this->urlsafeB64(hash_hmac('sha1', $encoded, $cred['sk'], true)); return $cred['ak'] . ':' . $sign . ':' . $encoded; } // 管理凭证(用于删除) private function managementAuth($path, $cred) { $sign = $this->urlsafeB64(hash_hmac('sha1', $path . "\n", $cred['sk'], true)); return 'QBox ' . $cred['ak'] . ':' . $sign; } // ---------- 对外地址 ---------- public function url($key) { $key = ltrim((string)$key, '/'); $domain = trim((string)($this->cfg['custom_domain'] ?? '')); if ($domain === '') { return '/' . $key; // 未绑定域名时退回相对路径,避免生成半截 URL } return storageHostOf($domain, $this->scheme()) . '/' . storageEncodeKey($this->objectKey($key)); } // ---------- 操作 ---------- public function putFile($srcPath, $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']]; if (!is_file($srcPath)) return ['ok' => false, 'error' => "源文件不存在:{$srcPath}"]; $key = $this->objectKey($key); $post = [ 'token' => $this->uploadToken($key, $cred), 'key' => $key, 'file' => new CURLFile($srcPath, $mime ?: 'application/octet-stream', basename($key)), ]; $res = storageHttp('POST', $this->scheme() . '://' . $this->uploadHost() . '/', ['Expect:'], $post, 90); return $this->interpretUpload($res); } public function putContents($binary, $key, $mime = null) { $tmp = tempnam(sys_get_temp_dir(), 'qn'); if ($tmp === false || file_put_contents($tmp, $binary) === false) { return ['ok' => false, 'error' => '无法创建临时文件']; } $result = $this->putFile($tmp, $key, $mime); @unlink($tmp); return $result; } public function delete($key) { $cred = $this->credentials(); if (empty($cred['ok'])) return ['ok' => false, 'error' => $cred['error']]; $entry = $this->urlsafeB64(trim((string)$this->cfg['bucket']) . ':' . $this->objectKey($key)); $path = '/delete/' . $entry; $res = storageHttp('POST', $this->scheme() . '://rs.qiniup.com' . $path, ['Authorization: ' . $this->managementAuth($path, $cred)], null, 30); if (!$res['ok'] && $res['status'] !== 612) { // 612 = 资源不存在 return ['ok' => false, 'error' => '删除七牛对象失败:' . $this->describe($res)]; } return ['ok' => true, 'error' => '']; } public function exists($key) { return true; } 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']]; if (trim((string)($this->cfg['custom_domain'] ?? '')) === '') { return ['ok' => false, 'error' => '未填写绑定域名——七牛云必须绑定域名后才能对外访问图片']; } $key = '.movielog-probe/' . date('YmdHis') . '-' . bin2hex(random_bytes(4)) . '.txt'; $tmp = tempnam(sys_get_temp_dir(), 'qn'); file_put_contents($tmp, 'movielog storage probe ' . date('c')); $up = $this->putFile($tmp, $key, 'text/plain'); @unlink($tmp); if (empty($up['ok'])) return $up; $del = $this->delete($key); $prefix = trim((string)($this->cfg['prefix'] ?? ''), '/'); return [ 'ok' => true, 'error' => '', 'message' => "连接成功(AccessKey/SecretKey)。空间 {$this->cfg['bucket']} 可上传," . ($prefix !== '' ? "前缀 {$prefix}/," : '') . '测试对象已清理。' . ($del['ok'] ? '' : '(注意:测试对象删除失败,请检查删除权限)'), ]; } // ---------- 响应解析 ---------- private function interpretUpload($res) { $json = json_decode((string)$res['body'], true); if (is_array($json) && !empty($json['key'])) { return ['ok' => true, 'error' => '']; } if (!$res['ok'] || (is_array($json) && isset($json['error']))) { return ['ok' => false, 'error' => '上传到七牛失败:' . $this->describe($res)]; } return ['ok' => false, 'error' => '上传到七牛失败:响应异常']; } private function describe($res) { $json = json_decode((string)$res['body'], true); if (is_array($json) && !empty($json['error'])) { return (string)$json['error']; } return $res['error']; } }