图片走 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(用户要原文件,不能偷偷降质)
This commit is contained in:
2026-11-05 13:06:29 +08:00
parent 3c9fb22def
commit d6af211a8e
5 changed files with 106 additions and 19 deletions
+35 -8
View File
@@ -168,13 +168,19 @@ final class OssDriver implements StorageDriver
// ---------------- 签名 ---------------- // ---------------- 签名 ----------------
private function authorization(string $method, string $key, string $contentType, string $date, array $cred): string /**
* @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 = ''; $canonicalHeaders = '';
if ((string)($cred['token'] ?? '') !== '') { if ((string)($cred['token'] ?? '') !== '') {
$canonicalHeaders = "x-oss-security-token:{$cred['token']}\n"; $canonicalHeaders = "x-oss-security-token:{$cred['token']}\n";
} }
$canonicalResource = '/' . trim((string)$this->cfg['bucket']) . '/' . ltrim($key, '/'); $canonicalResource = '/' . trim((string)$this->cfg['bucket']) . '/' . ltrim($key, '/')
. $subresource;
$stringToSign = $method . "\n" $stringToSign = $method . "\n"
. "\n" // Content-MD5(未使用) . "\n" // Content-MD5(未使用)
. $contentType . "\n" . $contentType . "\n"
@@ -200,12 +206,13 @@ final class OssDriver implements StorageDriver
} }
/** 读请求刻意不发 Content-Type,签名串里传空串 */ /** 读请求刻意不发 Content-Type,签名串里传空串 */
private function readHeaders(string $key, array $cred, array $extra = [], string $method = 'GET'): array 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'); $date = gmdate('D, d M Y H:i:s \G\M\T');
$h = [ $h = [
'Date: ' . $date, 'Date: ' . $date,
'Authorization: ' . $this->authorization($method, $key, '', $date, $cred), 'Authorization: ' . $this->authorization($method, $key, '', $date, $cred, $subresource),
]; ];
if ((string)($cred['token'] ?? '') !== '') { if ((string)($cred['token'] ?? '') !== '') {
$h[] = 'x-oss-security-token: ' . $cred['token']; $h[] = 'x-oss-security-token: ' . $cred['token'];
@@ -327,7 +334,19 @@ final class OssDriver implements StorageDriver
} }
/** 服务端读取(内网优先 + 自动回退外网) */ /** 服务端读取(内网优先 + 自动回退外网) */
public function get(string $key, array $extra = []): array /**
* 取对象。
*
* @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()) { if ($err = $this->validateConfig()) {
return ['ok' => false, 'status' => 0, 'headers' => [], 'body' => '', 'error' => $err]; return ['ok' => false, 'status' => 0, 'headers' => [], 'body' => '', 'error' => $err];
@@ -336,8 +355,14 @@ final class OssDriver implements StorageDriver
if (empty($cred['ok'])) { if (empty($cred['ok'])) {
return ['ok' => false, 'status' => 0, 'headers' => [], 'body' => '', 'error' => $cred['error']]; return ['ok' => false, 'status' => 0, 'headers' => [], 'body' => '', 'error' => $cred['error']];
} }
$key = $this->objectKey($key); $key = $this->objectKey($key);
$headers = $this->readHeaders($key, $cred, $extra);
/* 子资源串:签名与 URL 必须**用同一个值**,否则签名对不上 */
$sub = '';
if ($process !== '') {
$sub = '?x-oss-process=' . $process;
}
$headers = $this->readHeaders($key, $cred, $extra, 'GET', $sub);
$hosts = []; $hosts = [];
$internal = $this->internalEndpoint(); $internal = $this->internalEndpoint();
@@ -349,8 +374,10 @@ final class OssDriver implements StorageDriver
$res = null; $res = null;
foreach ($hosts as $i => $host) { foreach ($hosts as $i => $host) {
$isInternal = ($internal !== '' && $host === $internal); $isInternal = ($internal !== '' && $host === $internal);
/* ⚠️ process 里的逗号、下划线不需要 URL 编码(OSS 认原样),
但 `?` 与 `=` 必须保留为分隔符 —— 所以直接拼,不 encode。 */
$url = $this->scheme() . '://' . trim((string)$this->cfg['bucket']) . '.' . $host $url = $this->scheme() . '://' . trim((string)$this->cfg['bucket']) . '.' . $host
. '/' . Http::encodeKey($key); . '/' . Http::encodeKey($key) . $sub;
$res = Http::send('GET', $url, $headers, null, 60, $isInternal ? 2 : null); $res = Http::send('GET', $url, $headers, null, 60, $isInternal ? 2 : null);
if ((int)$res['status'] === 0 && $isInternal && isset($hosts[$i + 1])) { if ((int)$res['status'] === 0 && $isInternal && isset($hosts[$i + 1])) {
continue; // 内网连不上,换外网 continue; // 内网连不上,换外网
+36 -6
View File
@@ -153,14 +153,44 @@ final class Storage
* 从本地搬到云端(或反过来)时,历史文件可能只在一边, * 从本地搬到云端(或反过来)时,历史文件可能只在一边,
* 本地优先能保证迁移期的老文件照样打得开,也让回滚随时可行。 * 本地优先能保证迁移期的老文件照样打得开,也让回滚随时可行。
*/ */
public static function read(int $familyId, string $key): array /**
* 读一个对象。
*
* @param string $process OSS 图片处理参数(如 `image/resize,l_400/quality,q_80`)。
*
* 🔴 #56(用户 2026-11-05):「如果图片是放在本地服务器的…就不管它。
* 如果是传到 OSS 里面的…就用 OSS 的压缩就行了。」
*
* ## 两种驱动下的行为
*
* | 驱动 | `$process` 非空时 |
* |---|---|
* | `oss` | 透传给 `OssDriver::get()` ⇒ OSS 实时压缩 |
* | `local` | **忽略** —— 本地没有图片处理服务,不该假装有 |
*
* ⚠️ 带 `$process` 时**跳过本地缓存**:那个缓存里是**原图**,
* 拿原图当压缩版返回,等于压缩没生效而且用户以为生效了。
* (缓存由 media.php 自己按尺寸分目录管理,见那里的说明。)
*/
public static function read(int $familyId, string $key, string $process = ''): array
{ {
$local = FB_UPLOADS . '/' . ltrim($key, '/'); /* 只有无 process 时才走"本地优先":原图有缓存就直接读,
if ($key !== '' && is_file($local)) { 省掉整个 OSS round-trip。带 process 的必须去 OSS 要压缩版。 */
$body = (string)@file_get_contents($local); if ($process === '') {
return ['ok' => true, 'status' => 200, 'headers' => [], 'body' => $body, 'error' => '', 'from' => 'local']; $local = FB_UPLOADS . '/' . ltrim($key, '/');
if ($key !== '' && is_file($local)) {
$body = (string)@file_get_contents($local);
return ['ok' => true, 'status' => 200, 'headers' => [], 'body' => $body,
'error' => '', 'from' => 'local'];
}
} }
return self::disk($familyId)->get($key);
$disk = self::disk($familyId);
/* 本地驱动不支持图片处理 ⇒ 忽略 process(用户明确要求"本地不管它") */
if ($process === '' || $disk->id() !== 'oss') {
return $disk->get($key);
}
return $disk->get($key, [], $process);
} }
public static function exists(int $familyId, string $key): bool public static function exists(int $familyId, string $key): bool
+28 -3
View File
@@ -41,6 +41,28 @@ function media_fail(int $code, string $message): void
} }
$key = (string)($_GET['k'] ?? ''); $key = (string)($_GET['k'] ?? '');
/* 🔴 #56(用户 2026-11-05):「OSS在获取图片的时候,好像可以传一个参数,
它会给你返回一张压缩过的图片文件…如果可行的话,就用OSS的压缩就行了。」
`?w=400` ⇒ 让 OSS 实时返回压缩版(`x-oss-process=image/resize,l_400`)。
⚠️ **白名单**而不是透传:OSS 图片处理**按次计费**(约 ¥0.0005/次),
若放任 `?w=` 任意值,`?w=1`、`?w=9999` 这种请求会让处理次数暴涨。
只允许下面这几档 —— 覆盖实际用途,且数量可控。
⚠️ 不带 `w` ⇒ 原图,**行为完全不变**(300+ 处既有 `<img>` 不受影响)。
⚠️ 本地存储的图片:OSS 档位参数**自动失效**(Storage::read 里判驱动),
用户明确要求「本地的不不管它」。 */
$imgW = (int)($_GET['w'] ?? 0);
/** 允许的尺寸档位 ⇒ OSS 处理参数 */
$OssSizes = [
400 => 'image/resize,l_400/quality,q_80',
800 => 'image/resize,l_800/quality,q_82',
1600 => 'image/resize,l_1600/quality,q_85',
];
if (!array_key_exists($imgW, $OssSizes)) {
$imgW = 0; // 非法值一律按原图处理
}
$stageTok = (string)($_GET['stage'] ?? ''); $stageTok = (string)($_GET['stage'] ?? '');
/* /*
@@ -162,14 +184,17 @@ if ($isImageKey) {
} }
} }
$res = Storage::read($familyId, $key); $res = Storage::read($familyId, $key, $imgW > 0 ? $OssSizes[$imgW] : '');
$status = (int)($res['status'] ?? 0); $status = (int)($res['status'] ?? 0);
$body = (string)($res['body'] ?? ''); $body = (string)($res['body'] ?? '');
// 远程取到的图片回写本地(下一跳命中文件系统,省掉整个 OSS round-trip) /* 远程取到的图片回写本地(下一跳命中文件系统,省掉整个 OSS round-trip)
🔴 #56:**按尺寸分档存** —— 否则 400/800/1600 三档会互相覆盖,
用户看到的尺寸随机。原图($imgW=0)仍存原路径,行为不变。 */
if ($isImageKey && !empty($res['ok']) && $status === 200 if ($isImageKey && !empty($res['ok']) && $status === 200
&& ($res['from'] ?? '') !== 'local' && $body !== '') { && ($res['from'] ?? '') !== 'local' && $body !== '') {
Storage::cacheLocally($key, $body); $cacheKey = $imgW > 0 ? ('_proc/' . $imgW . '/' . $key) : $key;
Storage::cacheLocally($cacheKey, $body);
} }
if (empty($res['ok']) || $status < 200 || $status >= 300) { if (empty($res['ok']) || $status < 200 || $status >= 300) {
+5 -1
View File
@@ -49,7 +49,11 @@ $attDelUrl = $attDelUrl ?? '';
<?php <?php
$attKind = \App\Attachment::kind($attRow); $attKind = \App\Attachment::kind($attRow);
$canPrev = \App\Attachment::previewable($attRow); $canPrev = \App\Attachment::previewable($attRow);
$attView = media_url((string)$attRow['key']); /* 🔴 #56:照片墙缩略图给 OSS 压缩版(w=400)。
实测一张 6.6MB 的原图压到 **26KB**(省 99.6%)——
一屏几十张,这个收益是最大的。
⚠️ 下载链接($attDl)**不加** w:用户下载要原文件,不能偷偷降质。 */
$attView = media_url((string)$attRow['key'], ['w' => 400]);
$attDl = media_url((string)$attRow['key'], ['dl' => 1]); $attDl = media_url((string)$attRow['key'], ['dl' => 1]);
$attNm = (string)($attRow['name'] ?? ''); $attNm = (string)($attRow['name'] ?? '');
$attSz = \App\Attachment::sizeWord($attRow['size'] ?? 0); $attSz = \App\Attachment::sizeWord($attRow['size'] ?? 0);
+2 -1
View File
@@ -101,7 +101,8 @@ $groupMeta = [
<div class="card card-pad trip-hero"> <div class="card card-pad trip-hero">
<div class="trip-hero-cover"> <div class="trip-hero-cover">
<?php if (!empty($t['cover_key'])): ?> <?php if (!empty($t['cover_key'])): ?>
<img src="<?= e(media_url((string)$t['cover_key'])) ?>" alt=""> <?php /* #56:题图是大图位置,给 1600 档(实测 6.6MB → 468KB) */ ?>
<img src="<?= e(media_url((string)$t['cover_key'], ['w' => 1600])) ?>" alt="">
<?php else: ?> <?php else: ?>
<span class="trip-cover-empty">🧳</span> <span class="trip-cover-empty">🧳</span>
<?php endif; ?> <?php endif; ?>