Files
FamilyBook/app/Storage/OssDriver.php
T
laoyang d6af211a8e 图片走 OSS 实时压缩:取图带 x-oss-process,本地存储不受影响 (#56)
用户 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(用户要原文件,不能偷偷降质)
2026-11-05 13:06:29 +08:00

553 lines
23 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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;
}
}