Files
FamilyBook/app/Storage/OssDriver.php
T
laoyang ff353b1eee v8:观影落地(MovieLog 整体迁入统一事件原语)+ 三处 UI 撤除
- Schema v8:events / movie_details / event_attendees / event_photos 四张表,
  uk_event_source(family_id, source_ref) 作迁移幂等锚
- Movie 模型:时间线/详情/增删改、同行人整组替换、照片逐张管理、感受英→中映射
- ImageSlot 新增 poster(2:3) / ticket(free) / photo(free) 三槽位;
  free 槽位不裁剪、整图等比缩进框(只缩不放大)
- OssDriver::copyObject:同桶 CopyObject(手写 V1 签名,x-oss-* 头按字典序进签名串)
- 路由:movies 时间线/详情/新增/编辑/删除/补照片/删照片;同行人按家庭过滤;
  访客只读(requireWrite)
- UI 撤除:首页「我的一句话」、家谱页「补充我的亲属关系 / 看整个名册」两个按钮;
  顶栏加「观影」入口
- 修潜伏 bug:ImageSlot::parseRatio 的正则用 / 作分隔符,字符类里的 / 把模式
  提前截断(Unknown modifier ']')—— ratio() 只在上传 POST 里被调到,一直没被观察到
- 线上数据迁移(临时 token 门禁页,用完即删):MovieLog 219 条观影 → events,
  14 位观影人新增进名册(带 MovieLog 头像),431 个 OSS 对象同桶拷贝
  (movielog/uploads/<dir>/x → family/<dir>/x),review/feelings/影院三段/照片全量搬
2026-09-30 20:22:46 +08:00

526 lines
21 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 '';
}
// ---------------- 签名 ----------------
private function authorization(string $method, string $key, string $contentType, string $date, array $cred): string
{
$canonicalHeaders = '';
if ((string)($cred['token'] ?? '') !== '') {
$canonicalHeaders = "x-oss-security-token:{$cred['token']}\n";
}
$canonicalResource = '/' . trim((string)$this->cfg['bucket']) . '/' . ltrim($key, '/');
$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'): array
{
$date = gmdate('D, d M Y H:i:s \G\M\T');
$h = [
'Date: ' . $date,
'Authorization: ' . $this->authorization($method, $key, '', $date, $cred),
];
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' => ''];
}
/** 服务端读取(内网优先 + 自动回退外网) */
public function get(string $key, array $extra = []): 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);
$headers = $this->readHeaders($key, $cred, $extra);
$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
. '/' . Http::encodeKey($key);
$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;
}
}