用户 2026-11-05:「如果图片是放在本地服务器的…就不管它。
如果是传到 OSS 里面的…就用 OSS 的压缩就行了。」
查证:OSS 图片处理确实存在,取图 URL 加 x-oss-process 即可实时压缩,
原图不动、不占额外存储。
三处改动(缺一个都会出错):
1. OssDriver
- get() 新增 $process 参数
- authorization() 新增 $subresource:x-oss-process 属于子资源,
必须计入 CanonicalizedResource,否则 403 SignatureDoesNotMatch
- URL 拼接带上 ?x-oss-process=...
2. Storage::read()
- 新增 $process,仅当驱动是 oss 时透传
- 本地驱动忽略(用户明确要求「本地不管它」)
- 带 process 时跳过"本地优先"缓存(那个缓存里是原图,
拿原图当压缩版返回等于压缩没生效)
3. media.php
- 新增 ?w= 参数,白名单三档 400/800/1600
- 按尺寸分档缓存到 _proc/<w>/<key>,避免互相覆盖
- 无 w 时行为完全不变(300+ 处既有 img 不受影响)
白名单而非透传的理由:OSS 图片处理按次计费,
放任 ?w= 任意值会让 ?w=1、?w=9999 这类请求把处理次数打上去。
实测(一张 6602 KB 的图):
原图 6602 KB
w=400 26 KB 省 99.6%
w=800 108 KB 省 98%
w=1600 468 KB 省 93%
应用位置(保守,先动最确定的):
_attachments.php 照片墙缩略图 → w=400
trips/show.php 行程题图 → w=1600
下载链接一律不加 w(用户要原文件,不能偷偷降质)
553 lines
23 KiB
PHP
553 lines
23 KiB
PHP
<?php
|
||
/**
|
||
* 阿里云 OSS 驱动(手写签名 V1 / HMAC-SHA1,不依赖官方 SDK)。
|
||
* (从 MovieLog_Server 移植)
|
||
*
|
||
* ⚠️ 三件事必须记住:
|
||
* ① 写请求把 Content-Type 放进签名串,**读请求要传空串** —— 这是 OSS 签名的坑。
|
||
* ② 内网 endpoint 只用于**服务端读取**,对外 URL 永远用公网/自定义域名。
|
||
* 跨地域时内网域名能解析出 A 记录但永远连不上,会白等满一次连接超时,
|
||
* 所以先比地域(零成本),再只给 2 秒连接超时。
|
||
* ③ endpoint 填写时**不要带 https://**,且必须与桶所在地域一致,
|
||
* 否则报 "must be addressed using the specified endpoint"。
|
||
*/
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Storage;
|
||
|
||
use App\Setting;
|
||
|
||
final class OssDriver implements StorageDriver
|
||
{
|
||
private array $cfg;
|
||
private int $familyId;
|
||
private ?array $cred = null;
|
||
|
||
public function __construct(int $familyId, array $cfg)
|
||
{
|
||
$this->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<int, array{name:string,ok:bool,detail:string,ms:int}>
|
||
*/
|
||
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;
|
||
}
|
||
} |